From 040d73871b34807b1a92bab221e9709dce846e3a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 30 Aug 2026 20:44:34 +0800 Subject: [PATCH 01/17] feat(agent-team): unify messages on steer --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 6 +- .../feature/2026-08-05-agent-teams.zh.md | 6 +- ...26-08-30-team-send-message-steer.i18n.yaml | 6 + .../2026-08-30-team-send-message-steer.md | 41 ++++ .../2026-08-30-team-send-message-steer.zh.md | 41 ++++ .../headless/tests/fixtures/team-llm.mjs | 6 + .../headless/tests/headless.expected.e2e.ts | 55 +++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 8 +- docs/persistence-catalog.zh.md | 8 +- docs/subsystems/agent-team.i18n.yaml | 4 +- docs/subsystems/agent-team.md | 7 +- docs/subsystems/agent-team.zh.md | 7 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 32 +-- docs/tool-catalog.zh.md | 30 +-- packages/experimental/README.i18n.yaml | 4 +- packages/experimental/README.md | 2 +- packages/experimental/README.zh.md | 2 +- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 4 +- packages/experimental/agent-team/README.zh.md | 4 +- packages/experimental/agent-team/src/index.ts | 2 +- .../experimental/agent-team/src/mailbox.ts | 23 +-- .../experimental/agent-team/src/projection.ts | 1 - packages/experimental/agent-team/src/types.ts | 2 - .../agent-team/tests/persistence.spec.ts | 31 +-- .../tests/projection-events.spec.ts | 1 - .../agent-team/tests/team.spec.ts | 188 +++++++++++------- .../tool-agent-team/README.i18n.yaml | 4 +- .../experimental/tool-agent-team/README.md | 16 +- .../experimental/tool-agent-team/README.zh.md | 18 +- .../experimental/tool-agent-team/src/index.ts | 43 ++-- .../tool-agent-team/tests/tool-team.spec.ts | 12 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/continuation.ts | 23 ++- packages/subagent/subagent/src/index.ts | 15 +- packages/subagent/subagent/src/internal.ts | 45 ++++- .../subagent/subagent/tests/control.spec.ts | 5 +- scripts/gen-tool-catalog.ts | 2 +- 47 files changed, 451 insertions(+), 289 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md create mode 100644 .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 8ed90b9b2c..e308dfc2f4 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-agent-teams.md -2026-08-05-agent-teams.md: fbcd8485a972323bc0f8ffb6a5cb7cca9a50044e -2026-08-05-agent-teams.zh.md: 0a1a8a81c47abfca275f5fc3c60b8d514cdddd30 +2026-08-05-agent-teams.md: df84b2710d597330e9fdf6f8f83edb1441f100ec +2026-08-05-agent-teams.zh.md: f1ef275610f6307578288d483eb561ee8d3c7531 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index fbcd8485a9..df84b2710d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -32,7 +32,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. -Quiet `send_message` injects, flushes, and acknowledges immediately for a live target without waking it; an inactive target remains queued until another event materializes that teammate. Waking `followup_task` becomes the target's next FIFO turn and may cold-resume it. Success means the message is already durable even when immediate delivery is deferred. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. +`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. @@ -48,7 +48,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may **Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. -**Store mail in each target Session before delivery.** Rejected because an inactive target is intentionally not materialized for quiet mail. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary. +**Store mail in each target Session before Lead-log enqueue.** Rejected because target materialization and acceptance can fail after the Team has committed the send. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary. **Treat task ownership or write scopes as locks.** Rejected because external writers bypass them, crashed owners remain durable, and path-prefix overlap cannot prove semantic independence. False mutual exclusion is more dangerous than an explicit warning. @@ -68,6 +68,6 @@ Package tests cover identity, name and authority checks, provider selection, res The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies. -An active roster member can be non-resident, so `inactive` is not failure and a wakeup can incur cold-resume latency. A quiet message for an inactive target can remain pending indefinitely until the target is otherwise materialized. A failed member permanently consumes its name and member slot, making provisioning failures visible instead of silently recycling identity. +An active roster member can be non-resident, so `inactive` is not failure and a send can incur cold-resume latency. Temporary inspection, resume, or inbox-admission failure can leave a durable message queued for recovery. A failed member permanently consumes its name and member slot, making provisioning failures visible instead of silently recycling identity. Coordination reduces likely checkout conflicts but cannot eliminate writes outside filesystem compare-and-set tools. The final diff and tests remain the Lead's integration boundary. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 0a1a8a81c4..f1ef275610 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -32,7 +32,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 -对于 live target,quiet `send_message` 会立即注入、flush 并确认,但不会唤醒它;inactive target 会保持 queued,直到其他事件 materialize 该 teammate。waking `followup_task` 成为 target 的下一个 FIFO turn,并可冷恢复。即使即时投递被推迟,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。 +`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../simplification/2026-08-30-team-send-message-steer.zh.md)负责单工具调度的理由。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 @@ -48,7 +48,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 **用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 -**投递前把 mail 存入每个 target Session。** 拒绝,因为 quiet mail 不会 materialize inactive target。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。 +**在 Lead 日志入队前把 mail 存入每个 target Session。** 拒绝,因为 Team 提交发送之后,target materialization 与准入仍可能失败。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。 **把 task ownership 或 write scope 当作锁。** 拒绝,因为外部 writer 会绕过它们,崩溃 owner 会持久保留,而路径前缀重叠不能证明语义独立。虚假的互斥保证比明确 warning 更危险。 @@ -68,6 +68,6 @@ Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、p Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。 -active roster member 可以不驻留,因此 `inactive` 不表示失败,wakeup 可能产生 cold-resume 延迟。发往 inactive target 的 quiet message 可能无限等待,直到 target 因其他原因 materialize。failed member 会永久占用名字与 member slot,使 provisioning failure 保持可见而不是静默回收身份。 +active roster member 可以不驻留,因此 `inactive` 不表示失败,send 可能产生 cold-resume 延迟。临时 inspection、resume 或 inbox 准入失败可能留下持久 queued 消息等待恢复。failed member 会永久占用名字与 member slot,使 provisioning failure 保持可见而不是静默回收身份。 协调可以降低 checkout 冲突概率,但无法消除文件系统 CAS 工具之外的写入。最终 diff 与测试仍是 Lead 的集成边界。 diff --git a/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.i18n.yaml new file mode 100644 index 0000000000..66a26247ec --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.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-30-team-send-message-steer.md +2026-08-30-team-send-message-steer.md: 9eb38ec96f51d074510bd918fc46bac9a61027d0 +2026-08-30-team-send-message-steer.zh.md: 44ed792a1c1a4e149c3309a538dfbe6418612b16 diff --git a/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md b/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md new file mode 100644 index 0000000000..9eb38ec96f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md @@ -0,0 +1,41 @@ +# Agent Note: Team messaging uses one Steer send_message operation + +Status: implemented + +English | [中文](2026-08-30-team-send-message-steer.zh.md) + +## Problem + +Agent Teams exposed two model operations for one durable mailbox: quiet `send_message` injected into a live target without waking it, while `followup_task` queued a distinct waking turn and cold-resumed an inactive teammate. Models had to choose a scheduling policy instead of stating whom to message, and quiet messages could accumulate for an inactive teammate until unrelated work resumed it. + +The ordinary continuable-Agent controls already use one direction-neutral `send_message` with fixed Steer scheduling. Retaining separate Team names and delivery modes made equivalent model communication depend on whether the target happened to be a direct child or a Team peer. + +## Decision + +Every Team member receives one `send_message({ target, message })` tool. The Team tool set contains nine operations; `followup_task` and model-selectable quiet delivery are absent. The durable `TeamMessageSnapshot` stores sender, target, content, and message identity without a scheduling field. + +Every accepted Team message uses Steer. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes through the continuation lifecycle. A successful Team send remains durable before delivery starts. `accepted` means the target inbox accepted the message; `queued` means a temporary inspection, resume, or inbox-admission failure left it in the Team mailbox for recovery. Neither result means the target completed the requested work. + +The Lead receives the Team-attributed user message through `Agent.steer()`. A teammate receives it through a symbol-keyed host-only continuation adapter that authorizes the exact Lead-to-direct-child edge, preserves the original `TeamMessageSource`, and performs resident or cold-resume Steer admission. Sibling and teammate-to-Lead messages therefore retain the real sender; the Team runtime never calls public adjacent-Agent `sendMessage()` while impersonating the Lead. + +The Lead Session remains the mailbox transaction owner. It flushes `team/message/queued` before dispatch, serializes immediate admissions per target in Lead-log order, and records `team/message/delivered` only after the target Session durably contains the same Team message id. Recovery retries queued-minus-delivered records in order, and target-side source folding prevents duplicate acceptance across the crash window between inbox insertion and acknowledgement. + +## Alternatives considered + +**Keep quiet `send_message` and waking `followup_task`.** This preserves caller control over turn scheduling but makes the model choose an implementation policy, permits unread durable mail on inactive targets, and diverges from adjacent-Agent messaging. + +**Keep `followup_task` as an alias for Steer.** Two names for identical behavior would preserve the tool-selection failure without adding an observable capability. + +**Route siblings through public adjacent-Agent `sendMessage()`.** That operation authorizes only exact direct-parent or direct-child model senders and derives its own `AgentMessageSource`. Calling it with the Lead would misattribute sibling mail; widening it to Team membership would weaken its adjacency rule. + +**Drop the Team mailbox and deliver directly.** Direct delivery loses durable enqueue-before-admission, recovery after temporary failure, stable message ids, and target-side de-duplication. + +## Testing + +Package tests pin running, idle, inactive, Lead, sibling, and recovery delivery; target-local ordering; sender attribution; inbox/history de-duplication; temporary failure returning `queued`; and the nine-tool schema. The keyless Agent Teams profile snapshot drives a running implementer, steers a researcher message into its next step, and verifies that both teammates still complete their assigned tasks before the Lead aggregates the result. + +## Consequences + +Models have one Team communication choice and cannot park quiet information intentionally. A message may extend the target's current turn, so prompts and tests require teammates to integrate new messages without abandoning work already in progress. + +The host-only Steer adapter becomes part of the internal continuation integration used by Team delivery. Human browser prompts keep the separate Queue adapter and remain distinct turns. The broader [Agent Teams decision](../feature/2026-08-05-agent-teams.md) retains mailbox, roster, task, and shared-checkout ownership; the [adjacent-Agent messaging decision](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) retains the public direct-edge authorization and model-message source. diff --git a/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md b/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md new file mode 100644 index 0000000000..44ed792a1c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Team 消息使用单一 Steer send_message 操作 + +Status: implemented + +[English](2026-08-30-team-send-message-steer.md) | 中文 + +## 问题 + +Agent Teams 为一个持久 mailbox 公开了两个模型操作:quiet `send_message` 注入 live target 而不唤醒它,`followup_task` 则排入一个独立 waking turn 并冷恢复 inactive teammate。模型必须选择调度策略,而不是只说明消息目标;quiet 消息可能为 inactive teammate 持续累积,直到无关工作恢复它。 + +普通 continuable-Agent 控件已经使用一个方向无关、固定 Steer 调度的 `send_message`。保留独立的 Team 名称与投递模式,会让等价的模型通信因为 target 恰好是 direct child 还是 Team peer 而采用不同语义。 + +## 决策 + +每个 Team member 都会获得一个 `send_message({ target, message })` 工具。Team 工具集包含九个操作;不存在 `followup_task` 与模型可选的 quiet 投递。持久 `TeamMessageSnapshot` 存储 sender、target、content 与 message identity,不存储调度字段。 + +每条已接受的 Team 消息都使用 Steer。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则通过 continuation lifecycle 冷恢复。每次成功的 Team send 都会在开始投递前完成持久化。`accepted` 表示 target inbox 已接受消息;`queued` 表示临时 inspection、resume 或 inbox 准入失败让消息留在 Team mailbox 等待恢复。两种结果都不表示 target 已完成所请求的工作。 + +Lead 通过 `Agent.steer()` 接收携带 Team 归因的用户消息。teammate 通过 symbol-keyed host-only continuation adapter 接收消息;该 adapter 会授权精确的 Lead-to-direct-child edge、保留原始 `TeamMessageSource`,并执行 resident 或 cold-resume Steer 准入。因此 sibling 与 teammate-to-Lead 消息保留真实 sender;Team 运行时绝不会伪装成 Lead 调用公开的相邻 Agent `sendMessage()`。 + +Lead Session 继续作为 mailbox transaction owner。它在 dispatch 前 flush `team/message/queued`,按 Lead 日志顺序为每个 target 串行化即时准入,并且只有 target Session 持久包含相同 Team message id 后才记录 `team/message/delivered`。恢复按顺序重试 queued-minus-delivered 记录;target 侧 source 折叠会防止 inbox insertion 与 acknowledgement 之间的 crash window 导致重复准入。 + +## 考虑过的替代方案 + +**保留 quiet `send_message` 与 waking `followup_task`。** 这会保留调用方对 turn 调度的控制,但要求模型选择实现策略、允许 inactive target 存在未读持久 mail,并与相邻 Agent 消息语义分叉。 + +**保留 `followup_task` 作为 Steer 别名。** 两个名字表达同一行为只会保留工具选择错误,不会增加可观察能力。 + +**通过公开的相邻 Agent `sendMessage()` 路由 sibling。** 该操作只授权精确的 direct-parent 或 direct-child 模型 sender,并派生自己的 `AgentMessageSource`。以 Lead 身份调用会错误归因 sibling mail;把它扩展到 Team membership 则会削弱相邻关系规则。 + +**删除 Team mailbox 并直接投递。** 直接投递会失去准入前持久入队、临时失败后的恢复、稳定 message id 与 target 侧去重。 + +## 测试 + +包测试固定 running、idle、inactive、Lead、sibling 与 recovery 投递,target-local ordering、sender attribution、inbox/history 去重、临时失败返回 `queued`,以及九工具 schema。无密钥 Agent Teams profile snapshot 驱动 running implementer,把 researcher 消息 Steer 到其下一步骤,并验证两个 teammate 都继续完成各自任务,之后 Lead 才汇总结果。 + +## 后果 + +模型只有一种 Team 通信选择,不能有意停放 quiet information。一条消息可能扩展 target 的当前 turn,因此提示词与测试要求 teammate 整合新消息,同时不放弃已经进行的工作。 + +host-only Steer adapter 成为 Team 投递使用的内部 continuation 集成。人类浏览器 prompt 保留独立 Queue adapter,并继续形成不同 turn。更广泛的 [Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续负责 mailbox、roster、task 与共享 checkout;[相邻 Agent 消息决策](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)继续负责公开 direct-edge authorization 与 model-message source。 diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 57f0fe3dd2..0e31795018 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -88,6 +88,9 @@ function implementer(messages) { const names = calls(messages) const last = latestAssistantCalls(messages) const text = latestToolText(messages) + const userText = messages.flatMap(message => message.role === 'user' + ? message.content.filter(block => block.type === 'text').map(block => block.text) + : []).join('\n') if (!names.includes('team_task_create')) { if (last.includes('team_task_get') && text.includes('"subject":"Research"')) { return toolChunks([{ name: 'team_task_create', args: { @@ -116,6 +119,9 @@ function implementer(messages) { } return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } + if (!userText.includes('Research complete: use the deterministic finding.')) { + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) + } if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index b87618c6ad..0df4046f60 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -558,6 +558,49 @@ describe('headless stream-json snapshots', () => { const tasks = rows.filter(row => row.type === 'team/task') .map(row => ((row.data as JsonObject).task as JsonObject)) const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task]))) + const implementer = logs.find(log => typeof log.header.parentSession === 'string' + && parseJsonl(log.content).some((row) => { + if (row.type !== 'user/message') return false + const content: unknown = (row.data as JsonObject).content + return Array.isArray(content) && content.some((block: unknown) => ( + typeof block === 'object' && block !== null && !Array.isArray(block) + && (block as JsonObject).type === 'text' + && typeof (block as JsonObject).text === 'string' + && ((block as JsonObject).text as string).includes('IMPLEMENTER_MARK') + )) + })) + if (implementer === undefined) throw new Error('Agent Teams snapshot did not persist the implementer') + const implementerRows = parseJsonl(implementer.content) + const steeredInboxIndex = implementerRows.findIndex((row) => { + if (row.type !== 'agent/inbox/spliced') return false + const data = row.data as JsonObject + const inserted: unknown = data.inserted + return data.target === 'next-step' && Array.isArray(inserted) + && inserted.some((message: unknown) => { + if (typeof message !== 'object' || message === null || Array.isArray(message)) return false + const source = (message as JsonObject).source + return typeof source === 'object' && source !== null && !Array.isArray(source) + && (source as JsonObject).kind === 'team-message' + }) + }) + const steeredMessageIndex = implementerRows.findIndex((row) => { + if (row.type !== 'user/message') return false + const source = (row.data as JsonObject).source + return typeof source === 'object' && source !== null && !Array.isArray(source) + && (source as JsonObject).kind === 'team-message' + }) + const openTurnStart = implementerRows.findLastIndex((row, index) => ( + index < steeredMessageIndex && row.type === 'turn/start' + )) + const openTurnEnd = implementerRows.findLastIndex((row, index) => ( + index < steeredMessageIndex && row.type === 'turn/end' + )) + const completionAfterSteer = implementerRows.some((row, index) => { + if (index <= steeredMessageIndex || row.type !== 'tool/call') return false + const data = row.data as JsonObject + if (data.name !== 'team_task_update' || typeof data.arguments !== 'string') return false + return (JSON.parse(data.arguments) as JsonObject).action === 'complete' + }) projection = { sessions: logs.length, memberEdges: members.length, @@ -573,6 +616,12 @@ describe('headless stream-json snapshots', () => { && (row.data as JsonObject).name === 'wait_agent'), checkedRoster: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'list_agents'), + steerEvidence: { + nextStepInbox: steeredInboxIndex >= 0, + messageEntered: steeredMessageIndex > steeredInboxIndex, + enteredOpenTurn: openTurnStart > openTurnEnd, + completedAfterMessage: completionAfterSteer, + }, } }, }) @@ -592,6 +641,12 @@ describe('headless stream-json snapshots', () => { "memberEdges": 4, "queuedMessages": 2, "sessions": 3, + "steerEvidence": { + "completedAfterMessage": true, + "enteredOpenTurn": true, + "messageEntered": true, + "nextStepInbox": true, + }, "tasks": [ { "revision": 3, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3989c90eb9..a5fb188f37 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 85aead7a93bd8dff0da4eb4f0e6b6b348d9616b9 -config-catalog.zh.md: f91ebc6c46989cbf98d2a773f6f2873539ad6351 +config-catalog.md: 9da02ad73092ec491d1dcfdfb1a9c053908dfd20 +config-catalog.zh.md: 47d2deebbf24124bde15c0a6eae2b86151a02b16 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 85aead7a93..9da02ad730 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -535,7 +535,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f91ebc6c46..47d2deebbf 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -537,7 +537,7 @@ export interface Config { } ``` -来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:124`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index c5520d4dd2..99ed2f2849 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: fd5b3a4a377069fb7b1fbc37b78d11ec037073a4 -persistence-catalog.zh.md: 9d8fc88a44da1620e2de9aa066b9d6c6204b29b1 +persistence-catalog.md: a3aa16ce1a0fd5daa55935e4d409a72d2d0a4601 +persistence-catalog.zh.md: e2a17d7e4ff29e706a44706b3db6c44c2500ac99 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index fd5b3a4a37..a3aa16ce1a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -781,7 +781,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts) @@ -799,7 +799,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:229`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) @@ -812,7 +812,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:229`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) @@ -825,7 +825,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 9d8fc88a44..e2a17d7e4f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -783,7 +783,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:206`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:204`](../packages/experimental/agent-team/src/types.ts) @@ -801,7 +801,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) @@ -814,7 +814,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:208`](../packages/experimental/agent-team/src/types.ts) @@ -827,7 +827,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:208`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:206`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 37a3cbfb1c..8e3d2f6a95 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md -agent-team.md: e3a746734b067bb7ef8f083c5c9af9cb3707dcf7 -agent-team.zh.md: 1f885a73861c091b50d27d15080ebc09f6d382ec +agent-team.md: 201123ea94499ff20cae25d68b1f7a826439d143 +agent-team.zh.md: d6973ae6899c1d884dc51bd71afbd36a41895527 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index e3a746734b..201123ea94 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -2,7 +2,7 @@ English | [中文](agent-team.zh.md) -Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). +Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; the [Team Steer messaging Agent Note](../../.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md) owns message scheduling; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). ## Identity and roster @@ -34,11 +34,12 @@ interface TeamMessageSnapshot { readonly senderId: SessionId readonly senderName: string readonly targetId: SessionId - readonly delivery: 'quiet' | 'wakeup' readonly content: ContentBlock[] } ``` +Every message attempts Steer delivery. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Scheduling is not stored in the durable record because callers cannot select another mode. + The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender. ```ts type-equiv @@ -116,7 +117,7 @@ async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 1f885a7386..d6973ae689 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -2,7 +2,7 @@ [English](agent-team.md) | 中文 -实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。 +实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;[Team Steer 消息 Agent Note](../../.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md)负责消息调度;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。 ## 身份与 roster @@ -34,11 +34,12 @@ interface TeamMessageSnapshot { readonly senderId: SessionId readonly senderName: string readonly targetId: SessionId - readonly delivery: 'quiet' | 'wakeup' readonly content: ContentBlock[] } ``` +每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。 + target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。 ```ts type-equiv @@ -116,7 +117,7 @@ async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index afcff35539..a70f3d6e35 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: f71c5dea97ca92fd816c45c6fbd816ee05309d63 -tool-catalog.zh.md: 3286da7015cfa793820f6107a96af1fc73472ddb +tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b +tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f71c5dea97..416585106d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -36,7 +36,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | -| `@deepseek-ai/dsh-experimental-tool-agent-team` | `followup_task`, `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | +| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -1742,32 +1742,6 @@ The kind-agnostic background-job controller: background bash commands, PTY sends ## `@deepseek-ai/dsh-experimental-tool-agent-team` -### `followup_task` - -Send a durable follow-up task to another Team member and start a turn when needed. - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Team member name, or lead." - }, - "message": { - "type": "string", - "description": "Self-contained message for the target." - } - }, - "required": [ - "target", - "message" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - ### `interrupt_agent` Interrupt one teammate's current turn while preserving its pending inbox. Team Lead only. @@ -1804,7 +1778,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper ### `send_message` -Send durable information to another Team member without starting an idle member. +Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes. ```json { @@ -2052,7 +2026,7 @@ Wait for the next teammate status, mailbox, or shared-task change after this cal Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) -All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. +All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 3286da7015..89d2e46786 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -40,7 +40,7 @@ | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | -| `@deepseek-ai/dsh-experimental-tool-agent-team` | `followup_task`、`interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 10 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | +| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -1748,32 +1748,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ## `@deepseek-ai/dsh-experimental-tool-agent-team` -### `followup_task` - -向另一名 Team member 发送持久 follow-up task,并在需要时启动一个 turn。 - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Team member name, or lead." - }, - "message": { - "type": "string", - "description": "Self-contained message for the target." - } - }, - "required": [ - "target", - "message" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - ### `interrupt_agent` 中断一名 teammate 的当前 turn,同时保留其待处理 inbox。仅 Team Lead 可用。 @@ -1810,7 +1784,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `send_message` -向另一名 Team member 发送持久信息,但不启动 idle member。 +向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;idle target 会启动一个 turn;inactive teammate 会冷恢复。 ```json { diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index a6d751aaa7..2a253523ab 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/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/experimental/README.md -README.md: 689739bc532ddde4c41c3fb550d098add0501f70 -README.zh.md: 18499a28849dd3f4671ef266f24818cdc30f15e8 +README.md: 1a5f60c84327f47d9edea7d6bccb87d710a766f7 +README.zh.md: b97fd66854bcf8ad435348ddcba605f44eeffe52 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 689739bc53..1a5f60c843 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -30,7 +30,7 @@ The experimental group contains prototype capabilities that are not part of any | [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — | | [`code-runtime-python`](code-runtime-python/README.md) | CPython subprocess backend for the code-execution seam | `ctx.codeRuntime` | | [`inspector`](inspector/README.md) | Cross-realm CDP hub for Host debugging, Client Runtime inspection, network capture, and Cordis trees | `ctx.inspector` | -| [`tool-agent-team`](tool-agent-team/README.md) | Ten tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` | +| [`tool-agent-team`](tool-agent-team/README.md) | Nine tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` | | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key | | [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 18499a2884..b97fd66854 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -30,7 +30,7 @@ kind: "package-group" | [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — | | [`code-runtime-python`](code-runtime-python/README.zh.md) | 代码执行 seam 的 CPython 子进程后端 | `ctx.codeRuntime` | | [`inspector`](inspector/README.zh.md) | 用于 Host 调试、Client Runtime 检查、网络采集与 Cordis 树的跨 realm CDP hub | `ctx.inspector` | -| [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的十个工具 | 按作用域注册工具到 `ctx.tools` | +| [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的九个工具 | 按作用域注册工具到 `ctx.tools` | | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩 VFS 镜像 | 库与 CLI,不使用 ctx key | | [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key | diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index f4ee635c11..5dda62d955 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/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/experimental/agent-team/README.md -README.md: 0316834f566112b708f513b46cd0e5e3ab503061 -README.zh.md: 0aae14bccc06c57a9e1698f302cadd6b293baca8 +README.md: 1f9f7e98fdd2d8c32c6da2d514ff763367d6eb65 +README.zh.md: d6b57d35b6305c94fad966e3c9b3e20f6a3e81f5 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 0316834f56..1f9f7e98fd 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -68,7 +68,7 @@ Only the Lead can create teammates or interrupt them. Any member can send a message to any other member or to the Lead. A live member receives it immediately; an offline member's messages queue and arrive when it resumes. Messages are never lost and never delivered twice. -Two delivery modes cover the two common intents: a quiet message delivers information without starting an idle teammate (use it for updates that can wait), and a follow-up makes the message the recipient's next turn (use it to hand over work). The sender always sees the outcome — delivered now, or queued. A queued message is already safely stored, so it must not be resent. +Every message uses Steer: a running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. The sender always sees the outcome — accepted by the target inbox, or retained as queued when delivery is temporarily unavailable. A queued message is already safely stored, so it must not be resent. ### Shared task board @@ -131,6 +131,8 @@ Every ordinary runtime root is the implicit Lead of a Team whose `TeamId` equals `sendMessage()` validates peer membership, appends `team/message/queued`, and flushes before attempting delivery. The target message begins with `Team message from :` and keeps the same id and sender in `TeamMessageSource`. A target receipt is acknowledged with `team/message/delivered` only after the target Session durably holds the message identity in its pending inbox or recorded history. Immediate admissions are serialized per target in durable queue order; recovery dispatches queued-minus-delivered records in the same order. Delivery folds both live and persisted target inbox/history state before retrying, so a crash between inbox acceptance and model claim does not duplicate the message. The guarantee is process-local retry plus target-Session de-duplication, not cross-process exactly-once delivery. +Lead delivery calls `Agent.steer()` directly. Teammate delivery uses the continuation owner's host-only Steer path, which preserves the Team sender source while authorizing the Lead-to-child edge and cold-resuming inactive targets. Sibling messages never impersonate the Lead through the public adjacent-Agent messaging operation. + ### Shared task board Tasks are complete versioned snapshots; every mutation carries `expectedRevision`, and a stale caller receives `TEAM_TASK_STALE_REVISION` instead of overwriting a newer value. Numeric `task-` ids require a safe-integer suffix, and id-space exhaustion reports `TEAM_TASK_LIMIT` instead of reusing the final id. Deleted tasks remain tombstones for replay and id stability but do not consume `maxTasks` or appear in `listTasks()`. `writeScopes` are normalized workspace-relative prefixes; views warn on overlap with in-progress tasks but never block claim or authorize writes. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 0aae14bccc..d6b57d35b6 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -68,7 +68,7 @@ roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:` 任何成员都可以向任何其他成员或 Lead 发送消息。live 成员会立即收到;离线成员的消息会排队,并在其恢复后到达。消息不会丢失,也绝不会重复投递。 -两种投递模式覆盖两种常见意图:quiet 消息在不让 idle teammate 启动的情况下传达信息(用于可以等待的更新),follow-up 让消息成为接收方的下一个轮次(用于移交工作)。发送方始终能看到结果——已送达,或正在排队。排队的消息已经安全存储,因此绝不能重发。 +每条消息都使用 Steer:running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。发送方始终能看到结果——target inbox 已接受,或在投递暂时不可用时保留为 queued。排队的消息已经安全存储,因此绝不能重发。 ### 共享任务板 @@ -131,6 +131,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 `sendMessage()` 校验 peer 成员关系,追加 `team/message/queued` 并在尝试投递前 flush。目标消息以 `Team message from :` 开头,并在 `TeamMessageSource` 中保留同一 id 与发送者。只有目标 Session 在 pending inbox 或已记录历史中持久持有消息身份后,才会以 `team/message/delivered` 确认投递。即时准入按目标与持久队列顺序串行化;恢复按同一顺序重新投递 queued-minus-delivered 记录。重试前会同时折叠 live 与持久目标 inbox/历史状态,因此 inbox 已接受但模型尚未 claim 时发生崩溃不会复制消息。该保证是进程内重试加 target Session 去重,而不是跨进程 exactly-once 投递。 +投递给 Lead 时直接调用 `Agent.steer()`。投递给 teammate 时使用 continuation owner 的 host-only Steer 路径;该路径会保留 Team 发送者 source,同时授权 Lead-to-child edge 并冷恢复 inactive target。sibling 消息绝不会通过公开的相邻 Agent 消息操作伪装成 Lead。 + ### 共享任务板 任务是完整版本化快照;每次变更都携带 `expectedRevision`,陈旧调用方会收到 `TEAM_TASK_STALE_REVISION`,而不会覆盖更新的值。数字 `task-` id 的后缀必须是安全整数,id 空间耗尽时报告 `TEAM_TASK_LIMIT`,而不是复用最后一个 id。已删除任务作为 tombstone 保留以供回放与维持 id 稳定,但不占用 `maxTasks`,也不出现在 `listTasks()` 中。`writeScopes` 是规范化后的 workspace 相对前缀;视图会对与 in-progress 任务的重叠发出警告,但绝不阻止 claim 或授予写权限。 diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index de0487e93b..6fa0500eeb 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -157,7 +157,7 @@ export class TeamService extends TypertRemoteService { /** * Queue one durable peer message, then attempt immediate delivery. * @param caller - exact live sending Team member. - * @param request - target name, content, scheduling mode, and pre-queue cancellation. + * @param request - target name, content, and pre-queue cancellation. * @returns durable message identity and immediate-delivery observation. */ async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise { diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 11f969edf8..59d3b5b6e1 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' +import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import { errorMessage, TeamError } from './error.ts' import type { TeamJournal } from './journal.ts' import type { TeamRuntimeLifecycle } from './lifecycle.ts' @@ -49,7 +49,7 @@ export class TeamMailbox { /** * Queue one durable peer message, then attempt immediate delivery. * @param caller - exact live sending Team member. - * @param request - target name, content, scheduling mode, and pre-queue cancellation. + * @param request - target name, content, and pre-queue cancellation. * @returns durable message identity and immediate-delivery observation. */ async send(caller: Agent, request: SendTeamMessageRequest): Promise { @@ -93,8 +93,6 @@ export class TeamMailbox { && (membership.role === 'lead' || message.targetId === agent.id)) for (const message of messages) { signal.throwIfAborted() - if (membership.role === 'lead' && message.delivery === 'quiet' - && message.targetId !== membership.root.id && this.ctx.agents.get(message.targetId) === undefined) continue await this.tryDispatch(membership.root, message, signal) } } @@ -134,7 +132,6 @@ export class TeamMailbox { senderId: caller.id, senderName: membership.name, targetId: target.id, - delivery: request.delivery, content, } if (Buffer.byteLength(JSON.stringify(this.deliveryContent(queued)), 'utf8') > this.maxMessageBytes) { @@ -191,8 +188,7 @@ export class TeamMailbox { ): Promise { const active = this.activeDispatches.get(message.targetId) const live = message.targetId === root.id ? root : this.ctx.agents.get(message.targetId) - if (active !== undefined && live !== undefined && message.delivery === 'quiet' - && this.messagePrecedes(root, message.id, active.id)) { + if (active !== undefined && live !== undefined && this.messagePrecedes(root, message.id, active.id)) { return await this.dispatchOnce(root, message, signal) } return await this.serializeDispatch(message, () => this.dispatchOnce(root, message, signal)) @@ -242,18 +238,9 @@ export class TeamMailbox { const content = this.deliveryContent(message) if (message.targetId === root.id) { const input = createUserMessage({ content, source }) - if (message.delivery === 'wakeup') { - root.followup(input) - return await this.checkpointDelivered(root, root.session, message.id) - } - root.inject(input) + root.steer(input) return await this.checkpointDelivered(root, root.session, message.id) } - if (message.delivery === 'quiet') { - if (target === undefined) return false - target.inject(createUserMessage({ content, source })) - return await this.checkpointDelivered(root, target.session, message.id) - } if (target === undefined) { const recorded = await this.persistedTargetRecorded(message.targetId, message.id, signal) if (recorded === undefined) return false @@ -262,7 +249,7 @@ export class TeamMailbox { return true } } - await queueHostSubagentPrompt(this.ctx.subagents, root, message.targetId, content, source, signal) + await steerHostSubagentPrompt(this.ctx.subagents, root, message.targetId, content, source, signal) return target === undefined ? true : await this.checkpointDelivered(root, target.session, message.id) diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index df68fa518c..2d60102197 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -90,7 +90,6 @@ const teamMessageSnapshotSchema = z.object({ senderId: sessionIdSchema, senderName: z.string(), targetId: sessionIdSchema, - delivery: z.enum(['quiet', 'wakeup']), content: z.array(contentBlockSchema), }).strict() as z.ZodType diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 8d5b2a8d62..5818a6bc5f 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -108,7 +108,6 @@ export interface TeamMessageSnapshot { readonly senderId: SessionId readonly senderName: string readonly targetId: SessionId - readonly delivery: 'quiet' | 'wakeup' readonly content: ContentBlock[] } @@ -160,7 +159,6 @@ export interface SpawnTeammateResult { export interface SendTeamMessageRequest { readonly target: string readonly content: ContentBlock[] - readonly delivery: 'quiet' | 'wakeup' readonly signal: AbortSignal } diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 9c85709e2e..fbacd0fca6 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -209,7 +209,6 @@ for (const backend of backends) { const receipt = await second.ctx.agentTeams.sendMessage(activeHandle.agent, { target: 'recoverable', content: [{ type: 'text', text: 'resume after reconciliation' }], - delivery: 'wakeup', signal: SIGNAL, }) expect(receipt.status).toBe('accepted') @@ -265,7 +264,7 @@ for (const backend of backends) { await second.dispose() }) - it('replays queued-minus-delivered mail in FIFO order without waking for quiet mail', { + it('retries queued mail through cold-resume Steer after restart', { timeout: PERSISTENCE_TEST_TIMEOUT_MS, }, async () => { const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-mail-${backend.name.toLowerCase()}-`)) @@ -283,14 +282,15 @@ for (const backend of backends) { signal: SIGNAL, }) await vi.waitFor(() => { expect(first.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) - const quiet = await first.ctx.agentTeams.sendMessage(firstLead, { + vi.spyOn(first.ctx.sessionPersistence, 'inspect') + .mockRejectedValueOnce(new Error('temporary target inspection failure')) + const queued = await first.ctx.agentTeams.sendMessage(firstLead, { target: 'mail-worker', - content: [{ type: 'text', text: 'durable quiet context' }], - delivery: 'quiet', + content: [{ type: 'text', text: 'durable retry context' }], signal: SIGNAL, }) - expect(quiet.status).toBe('queued') - expect(durable(firstLead).pendingMessages.map(message => message.id)).toEqual([quiet.messageId]) + expect(queued.status).toBe('queued') + expect(durable(firstLead).pendingMessages.map(message => message.id)).toEqual([queued.messageId]) await first.dispose() const second = await stack(backend, storageRoot, [textResponse('resumed teammate answer')]) @@ -298,19 +298,6 @@ for (const backend of backends) { resumeSessionId: rootId, agentOptions: { provider: 'mock', model: 'mock' }, }) - await vi.waitFor(() => { - expect(durable(rootHandle.agent).pendingMessages.map(message => message.id)) - .toEqual([quiet.messageId]) - }) - expect(second.ctx.agents.get(started.member.id)).toBeUndefined() - - const waking = await second.ctx.agentTeams.sendMessage(rootHandle.agent, { - target: 'mail-worker', - content: [{ type: 'text', text: 'resume after restart' }], - delivery: 'wakeup', - signal: SIGNAL, - }) - expect(waking.status).toBe('accepted') await vi.waitFor(() => { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) }) @@ -319,7 +306,7 @@ for (const backend of backends) { && event.data.source.kind === 'team-message' ? [event.data.source.messageId] : []) - expect(peerIds).toEqual([quiet.messageId, waking.messageId]) + expect(peerIds).toEqual([queued.messageId]) await rootHandle.dispose() await second.dispose() @@ -373,7 +360,6 @@ for (const backend of backends) { senderId: rootId, senderName: 'lead', targetId: started.member.id, - delivery: 'wakeup', content: [{ type: 'text', text: 'already recorded before acknowledgement' }], } firstLead.session.append('team/message/queued', { @@ -426,7 +412,6 @@ for (const backend of backends) { senderId: rootId, senderName: 'lead', targetId: childId, - delivery: 'wakeup', content: [{ type: 'text', text: 'already durable in target inbox' }], } root.session.append('team/member', { diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 3cd6b6103c..b4d1952050 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -71,7 +71,6 @@ function message(overrides: Partial = {}): TeamMessageSnaps senderId: ROOT, senderName: 'lead', targetId: CHILD, - delivery: 'quiet', content: [{ type: 'text', text: 'hello' }], ...overrides, } diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 291fd6b59d..3acae897fd 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -11,7 +11,7 @@ import { SessionId, type Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' -import { queueSubagentPrompt, type HostPromptQueue } from '@deepseek-ai/dsh-subagent/internal' +import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -331,7 +331,7 @@ describe('Team identity and provisioning', () => { diagnostics: ['string provider failure'], }) await expect(first.ctx.agentTeams.sendMessage(first.lead, { - target: 'string-failure', content: content('cannot deliver'), delivery: 'quiet', signal: SIGNAL, + target: 'string-failure', content: content('cannot deliver'), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' }) const second = await setup([]) @@ -578,7 +578,7 @@ describe('Team shared task DAG', () => { }) it('enforces CAS, ownership, dependencies, transitions, and write-scope warnings', async () => { - const { ctx, lead } = await setup(['hang', 'hang']) + const { ctx, lead } = await setup(['hang', 'hang', textResponse('beta integrated update')]) const firstMember = await spawn(ctx, lead, 'alpha') const alpha = await waitRunning(ctx, firstMember.member.id) const secondMember = await spawn(ctx, lead, 'beta') @@ -922,15 +922,14 @@ describe('Team Remote API', () => { }) describe('Team mailbox and waiting', () => { - it('injects a quiet message addressed to the Lead and checkpoints its receipt', async () => { - const { ctx, lead } = await setup([]) + it('steers a message addressed to the Lead and checkpoints its receipt', async () => { + const { ctx, lead } = await setup(['hang']) const message: TeamMessageSnapshot = { - id: TeamMessageId('quiet-lead-message'), + id: TeamMessageId('steer-lead-message'), senderId: SessionId('team-worker'), senderName: 'worker', targetId: lead.id, - delivery: 'quiet', - content: content('quiet report'), + content: content('progress report'), } lead.session.append('team/message/queued', { version: 1, @@ -939,12 +938,15 @@ describe('Team mailbox and waiting', () => { }) await expect(teamInternals(ctx).mailbox.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) - expect(lead.inbox.nextStep.some(input => input.source.kind === 'team-message' - && input.source.messageId === message.id)).toBe(true) + expect(lead.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' + && event.data.inserted.some(input => input.source.kind === 'team-message' + && input.source.messageId === message.id))).toBe(true) expect(durable(lead).pendingMessages).toEqual([]) + lead.cancel({ kind: 'parent' }) + await lead.whenIdle() }) - it('acknowledges waking messages persisted by a busy Lead before model claim', async () => { + it('acknowledges steered messages persisted by a busy Lead before model claim', async () => { const { ctx, lead, teamFiber } = await setup(['hang', 'hang'], { maxPendingMessagesPerMember: 1 }) const started = await spawn(ctx, lead, 'lead-reporter') const reporter = await waitRunning(ctx, started.member.id) @@ -952,10 +954,10 @@ describe('Team mailbox and waiting', () => { await waitRunning(ctx, lead.id) const first = await ctx.agentTeams.sendMessage(reporter, { - target: 'lead', content: content('first wakeup report'), delivery: 'wakeup', signal: SIGNAL, + target: 'lead', content: content('first progress report'), signal: SIGNAL, }) const second = await ctx.agentTeams.sendMessage(reporter, { - target: 'lead', content: content('second wakeup report'), delivery: 'wakeup', signal: SIGNAL, + target: 'lead', content: content('second progress report'), signal: SIGNAL, }) expect([first.status, second.status]).toEqual(['accepted', 'accepted']) expect(lead.status).toBe('running') @@ -1000,8 +1002,7 @@ describe('Team mailbox and waiting', () => { const target = await waitRunning(ctx, started.member.id) const immediate = await ctx.agentTeams.sendMessage(lead, { target: 'pending-target', - content: content('live quiet receipt'), - delivery: 'quiet', + content: content('live steer receipt'), signal: SIGNAL, }) expect(immediate.status).toBe('accepted') @@ -1014,7 +1015,6 @@ describe('Team mailbox and waiting', () => { senderId: lead.id, senderName: 'lead', targetId: target.id, - delivery: 'quiet', content: content('durable pending receipt'), } lead.session.append('team/message/queued', { @@ -1082,7 +1082,7 @@ describe('Team mailbox and waiting', () => { await waitNoAgent(ctx, target.id) }) - it('acknowledges waking messages accepted by a busy target inbox', async () => { + it('acknowledges steered messages accepted by a busy target inbox', async () => { const { ctx, lead } = await setup(['hang'], { maxPendingMessagesPerMember: 1 }) const started = await spawn(ctx, lead, 'busy-target') const target = await waitRunning(ctx, started.member.id) @@ -1094,24 +1094,24 @@ describe('Team mailbox and waiting', () => { }) const first = await ctx.agentTeams.sendMessage(lead, { - target: 'busy-target', content: content('first waking message'), delivery: 'wakeup', signal: SIGNAL, + target: 'busy-target', content: content('first steered message'), signal: SIGNAL, }) expect(first.status).toBe('accepted') expect(flushed).toEqual([lead.id, target.id, lead.id]) expect(durable(lead).pendingMessages).toEqual([]) - expect(target.inbox.nextTurn.some(message => message.source.kind === 'team-message' + expect(target.inbox.nextStep.some(message => message.source.kind === 'team-message' && message.source.messageId === first.messageId)).toBe(true) flushed.length = 0 const second = await ctx.agentTeams.sendMessage(lead, { - target: 'busy-target', content: content('second waking message'), delivery: 'wakeup', signal: SIGNAL, + target: 'busy-target', content: content('second steered message'), signal: SIGNAL, }) expect(second.status).toBe('accepted') expect(flushed).toEqual([lead.id, target.id, lead.id]) expect(durable(lead).pendingMessages).toEqual([]) - expect(target.inbox.nextTurn.filter(message => message.source.kind === 'team-message' + expect(target.inbox.nextStep.filter(message => message.source.kind === 'team-message' && (message.source.messageId === first.messageId || message.source.messageId === second.messageId))) .toHaveLength(2) @@ -1120,19 +1120,19 @@ describe('Team mailbox and waiting', () => { await waitNoAgent(ctx, target.id) }) - it('serializes concurrent waking delivery admission for one target', async () => { + it('serializes concurrent Steer delivery admission for one target', async () => { const { ctx, lead } = await setup([textResponse('target initial')]) const target = await spawn(ctx, lead, 'ordered-target') await waitNoAgent(ctx, target.member.id) const entered = Promise.withResolvers() const release = Promise.withResolvers() const admitted: string[] = [] - vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) + vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) .mockImplementation(async (_parent, _childId, blocks) => { const last = blocks.at(-1) const text = last?.type === 'text' ? last.text : '' admitted.push(text) - if (text === 'first waking') { + if (text === 'first steer') { entered.resolve(undefined) await release.promise } @@ -1140,15 +1140,15 @@ describe('Team mailbox and waiting', () => { }) const first = ctx.agentTeams.sendMessage(lead, { - target: 'ordered-target', content: content('first waking'), delivery: 'wakeup', signal: SIGNAL, + target: 'ordered-target', content: content('first steer'), signal: SIGNAL, }) await entered.promise let secondSettled = false const second = ctx.agentTeams.sendMessage(lead, { - target: 'ordered-target', content: content('second waking'), delivery: 'wakeup', signal: SIGNAL, + target: 'ordered-target', content: content('second steer'), signal: SIGNAL, }).finally(() => { secondSettled = true }) await new Promise((resolve) => { setTimeout(resolve, 0) }) - expect(admitted).toEqual(['first waking']) + expect(admitted).toEqual(['first steer']) expect(secondSettled).toBe(false) release.resolve(undefined) @@ -1156,7 +1156,63 @@ describe('Team mailbox and waiting', () => { { status: 'accepted' }, { status: 'accepted' }, ]) - expect(admitted).toEqual(['first waking', 'second waking']) + expect(admitted).toEqual(['first steer', 'second steer']) + }) + + it('admits an earlier durable message ahead of a later in-flight resume', async () => { + const { ctx, lead } = await setup(['hang']) + const started = await spawn(ctx, lead, 'reordered-target') + const target = await waitRunning(ctx, started.member.id) + const earlier: TeamMessageSnapshot = { + id: TeamMessageId('earlier-message'), + senderId: lead.id, + senderName: 'lead', + targetId: target.id, + content: content('earlier steer'), + } + const later: TeamMessageSnapshot = { + ...earlier, + id: TeamMessageId('later-message'), + content: content('later steer'), + } + for (const message of [earlier, later]) { + lead.session.append('team/message/queued', { + version: 1, + teamId: TeamId(lead.id), + message, + }) + } + + const laterEntered = Promise.withResolvers() + const releaseLater = Promise.withResolvers() + const admitted: string[] = [] + vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) + .mockImplementation(async (_parent, _childId, blocks, source) => { + const last = blocks.at(-1) + const text = last?.type === 'text' ? last.text : '' + if (text === 'later steer') { + laterEntered.resolve(undefined) + await releaseLater.promise + } + const input = createUserMessage({ content: blocks, source }) + target.inject(input) + admitted.push(text) + return input.id + }) + + const laterDispatch = teamInternals(ctx).mailbox.tryDispatch(lead, later, SIGNAL) + await laterEntered.promise + await expect(teamInternals(ctx).mailbox.tryDispatch(lead, earlier, SIGNAL)).resolves.toBe(true) + expect(admitted).toEqual(['earlier steer']) + + releaseLater.resolve(undefined) + await expect(laterDispatch).resolves.toBe(true) + expect(admitted).toEqual(['earlier steer', 'later steer']) + expect(durable(lead).pendingMessages).toEqual([]) + + ctx.agentTeams.interrupt(lead, 'reordered-target') + target.cancel({ kind: 'parent' }) + await waitNoAgent(ctx, target.id) }) it('deduplicates live target history and contains inspection and delivery failures', async () => { @@ -1169,7 +1225,6 @@ describe('Team mailbox and waiting', () => { senderId: lead.id, senderName: 'lead', targetId: live.id, - delivery: 'wakeup', content: content('already in live history'), } lead.session.append('team/message/queued', { @@ -1245,15 +1300,15 @@ describe('Team mailbox and waiting', () => { await waitNoAgent(ctx, inactiveStarted.member.id) const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockRejectedValueOnce(new Error('inspect unavailable')) const uncertain = await ctx.agentTeams.sendMessage(lead, { - target: 'inactive-target', content: content('inspection failure'), delivery: 'wakeup', signal: SIGNAL, + target: 'inactive-target', content: content('inspection failure'), signal: SIGNAL, }) expect(uncertain.status).toBe('queued') inspect.mockRestore() - vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) + vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) .mockRejectedValueOnce(new Error('delivery unavailable')) const failed = await ctx.agentTeams.sendMessage(lead, { - target: 'inactive-target', content: content('delivery failure'), delivery: 'wakeup', signal: SIGNAL, + target: 'inactive-target', content: content('delivery failure'), signal: SIGNAL, }) expect(failed.status).toBe('queued') expect(warnings.some(warning => warning.includes('inspect unavailable'))).toBe(true) @@ -1263,44 +1318,33 @@ describe('Team mailbox and waiting', () => { await waitNoAgent(ctx, live.id) }) - it('keeps quiet mail dormant, wakes on follow-up, preserves FIFO, and de-duplicates delivery', async () => { - const { ctx, lead } = await setup(['hang', textResponse('beta first'), textResponse('beta resumed')]) + it('cold-resumes an inactive sibling with sender attribution', async () => { + const { ctx, lead } = await setup(['hang', 'hang']) const alphaStarted = await spawn(ctx, lead, 'alpha') const alpha = await waitRunning(ctx, alphaStarted.member.id) const betaStarted = await spawn(ctx, lead, 'beta') - await waitNoAgent(ctx, betaStarted.member.id) + const beta = await waitRunning(ctx, betaStarted.member.id) + ctx.agentTeams.interrupt(lead, 'beta') + await waitNoAgent(ctx, beta.id) - const quiet = await ctx.agentTeams.sendMessage(alpha, { - target: 'beta', content: content('quiet info'), delivery: 'quiet', signal: SIGNAL, + const first = await ctx.agentTeams.sendMessage(alpha, { + target: 'beta', content: content('first update'), signal: SIGNAL, }) - expect(quiet.status).toBe('queued') - expect(ctx.agents.get(betaStarted.member.id)).toBeUndefined() - const waking = await ctx.agentTeams.sendMessage(alpha, { - target: 'beta', content: content('do another turn'), delivery: 'wakeup', signal: SIGNAL, - }) - expect(waking.status).toBe('accepted') + expect(first.status).toBe('accepted') await waitNoAgent(ctx, betaStarted.member.id) - await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) - const stored = await ctx.sessionPersistence.inspect(betaStarted.member.id) - const peerMessages = stored.events.filter(event => event.type === 'user/message' - && event.data.source.kind === 'team-message') - expect(peerMessages.map((event) => { - if (event.type !== 'user/message') return undefined - const block = event.data.content.at(-1) - return block?.type === 'text' ? block.text : undefined - })).toEqual(['quiet info', 'do another turn']) - expect(peerMessages.map(event => event.type === 'user/message' - ? event.data.content[0]?.type === 'text' && event.data.content[0].text - : undefined)).toEqual([ - expect.stringMatching(/^Team message .* from alpha:$/u), + const peerMessages = stored.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'team-message' ? [event.data] : []) + expect(peerMessages.map(message => message.content.at(-1))).toEqual([ + { type: 'text', text: 'first update' }, + ]) + expect(peerMessages.map(message => message.content[0]?.type === 'text' && message.content[0].text)).toEqual([ expect.stringMatching(/^Team message .* from alpha:$/u), ]) - expect(peerMessages.map(event => event.type === 'user/message' && event.data.source.kind === 'team-message' - ? [event.data.source.messageId, event.data.source.senderName] + expect(peerMessages.map(message => message.source.kind === 'team-message' + ? [message.source.messageId, message.source.senderName] : undefined)).toEqual([ - [quiet.messageId, 'alpha'], - [waking.messageId, 'alpha'], + [first.messageId, 'alpha'], ]) ctx.agentTeams.interrupt(lead, 'alpha') @@ -1315,25 +1359,26 @@ describe('Team mailbox and waiting', () => { const target = await spawn(ctx, lead, 'target') await waitNoAgent(ctx, target.member.id) await expect(ctx.agentTeams.sendMessage(lead, { - target: 'target', content: content('x'.repeat(300)), delivery: 'quiet', signal: SIGNAL, + target: 'target', content: content('x'.repeat(300)), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_MESSAGE_TOO_LARGE' }) + vi.spyOn(ctx.sessionPersistence, 'inspect').mockRejectedValueOnce(new Error('temporary inspection failure')) const queued = await ctx.agentTeams.sendMessage(lead, { - target: 'target', content: content('one'), delivery: 'quiet', signal: SIGNAL, + target: 'target', content: content('one'), signal: SIGNAL, }) expect(queued.status).toBe('queued') await expect(ctx.agentTeams.sendMessage(lead, { - target: 'target', content: content('two'), delivery: 'quiet', signal: SIGNAL, + target: 'target', content: content('two'), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_MAILBOX_FULL' }) await expect(ctx.agentTeams.sendMessage(lead, { - target: 'lead', content: content('self'), delivery: 'quiet', signal: SIGNAL, + target: 'lead', content: content('self'), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_SELF_MESSAGE' }) await expect(ctx.agentTeams.sendMessage(lead, { - target: 'missing', content: content('unknown target'), delivery: 'quiet', signal: SIGNAL, + target: 'missing', content: content('unknown target'), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' }) const controller = new AbortController() controller.abort(new TeamError('cancelled before queue', 'TEST_CANCELLED')) await expect(ctx.agentTeams.sendMessage(lead, { - target: 'target', content: content('cancelled'), delivery: 'quiet', signal: controller.signal, + target: 'target', content: content('cancelled'), signal: controller.signal, })).rejects.toMatchObject({ code: 'TEST_CANCELLED' }) }) @@ -1342,12 +1387,12 @@ describe('Team mailbox and waiting', () => { const started = await spawn(ctx, lead, 'worker') const worker = await waitRunning(ctx, started.member.id) const followup = await ctx.agentTeams.sendMessage(lead, { - target: 'worker', content: content('retained follow-up'), delivery: 'wakeup', signal: SIGNAL, + target: 'worker', content: content('retained follow-up'), signal: SIGNAL, }) expect(followup.status).toBe('accepted') expect(ctx.agentTeams.interrupt(lead, 'worker')).toEqual({ previousStatus: 'running' }) await vi.waitFor(() => { expect(worker.status).toBe('idle') }) - expect(worker.inbox.nextTurn.some(message => message.source.kind === 'team-message' + expect(worker.inbox.nextStep.some(message => message.source.kind === 'team-message' && message.source.messageId === followup.messageId)).toBe(true) worker.cancel({ kind: 'parent' }) await waitNoAgent(ctx, worker.id) @@ -1558,7 +1603,7 @@ describe('Team mailbox and waiting', () => { const entered = Promise.withResolvers() const aborted = Promise.withResolvers() const release = Promise.withResolvers() - vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt) + vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) .mockImplementation(async (_parent, _childId, _content, _source, signal) => { entered.resolve(undefined) return await new Promise((_resolve, reject) => { @@ -1575,7 +1620,6 @@ describe('Team mailbox and waiting', () => { const sending = ctx.agentTeams.sendMessage(lead, { target: 'mailbox-worker', content: content('resume during disposal'), - delivery: 'wakeup', signal: SIGNAL, }) await entered.promise @@ -1600,7 +1644,6 @@ describe('Team mailbox and waiting', () => { senderId: SessionId('sender'), senderName: 'sender', targetId: lead.id, - delivery: 'wakeup', content: content('acknowledge before disposal'), } lead.session.append('team/message/queued', { @@ -1679,14 +1722,13 @@ describe('Team mailbox and waiting', () => { signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) await expect(ctx.agentTeams.sendMessage(lead, { - target: 'nobody', content: content('must reject'), delivery: 'quiet', signal: SIGNAL, + target: 'nobody', content: content('must reject'), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' }) await expect(internal.mailbox.tryDispatch(lead, { id: TeamMessageId('post-disposal-message'), senderId: lead.id, senderName: 'lead', targetId: lead.id, - delivery: 'quiet', content: content('must not dispatch'), }, SIGNAL)).resolves.toBe(false) }) diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml index df245ca2ed..6eea81aad6 100644 --- a/packages/experimental/tool-agent-team/README.i18n.yaml +++ b/packages/experimental/tool-agent-team/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/experimental/tool-agent-team/README.md -README.md: b8474117d429d6f03562479834bb13daad3307b9 -README.zh.md: ca4dd17e0ccafef4112655f80543d539c9a11a4b +README.md: 4f8d589189540c40560c9abd4e4db999c3d18542 +README.zh.md: 15a87f5640e7115d09792f138f1cc36f20d9eab0 diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md index b8474117d4..4f8d589189 100644 --- a/packages/experimental/tool-agent-team/README.md +++ b/packages/experimental/tool-agent-team/README.md @@ -1,5 +1,5 @@ --- -description: "Ten tools that let the model create, message, and coordinate teammates, for compositions mounting the experimental Team plugins." +description: "Nine tools that let the model create, message, and coordinate teammates, for compositions mounting the experimental Team plugins." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-tool-agent-team` gives the model a team toolset on top of the team domain package: create named teammates, send them messages or follow-up work, see who is available, wait for progress, interrupt a stuck teammate, and manage a shared task board — ten tools in total. A short policy section in every member's prompt teaches the model when to form a team (only when you ask for one) and how to coordinate on a shared workspace. Mounting it replaces legacy subagent controls with the same tool names, so a composition that wants both must disable the legacy definitions. It is experimental: excluded from official releases, carries no stability promise, and creates teammates only when you explicitly ask for a team. +`dsh-experimental-tool-agent-team` gives the model a team toolset on top of the team domain package: create named teammates, steer messages to them, see who is available, wait for progress, interrupt a stuck teammate, and manage a shared task board — nine tools in total. A short policy section in every member's prompt teaches the model when to form a team (only when you ask for one) and how to coordinate on a shared workspace. Mounting it replaces legacy subagent controls with the same tool names, so a composition that wants both must disable the legacy definitions. It is experimental: excluded from official releases, carries no stability promise, and creates teammates only when you explicitly ask for a team. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Add this package on top of `@deepseek-ai/dsh-experimental-agent-team` when the model should run a team through tools. Once mounted, every team member — the Lead and each teammate — gets the same ten tools plus a policy paragraph that states its own role and name. +Add this package on top of `@deepseek-ai/dsh-experimental-agent-team` when the model should run a team through tools. Once mounted, every team member — the Lead and each teammate — gets the same nine tools plus a policy paragraph that states its own role and name. ### When to choose it @@ -54,10 +54,10 @@ Try it by asking the Lead model: "create a teammate named reviewer to check the ### What the model can do -The ten tools group into four capabilities: +The nine tools group into four capabilities: - **Create a teammate** — `spawn_teammate` takes a name, a description, and the initial task; only the Lead can call it. -- **Send messages** — `send_message` delivers information without waking an idle teammate; `followup_task` makes the message the recipient's next turn and wakes it when needed. +- **Send messages** — `send_message` steers a running member at its nearest step boundary, starts an idle member, and cold-resumes an inactive teammate. - **See and wait** — `list_agents` shows the roster with live status; `wait_agent` waits for the next team change; `interrupt_agent` stops a teammate's current turn (Lead only). - **Manage the task board** — `team_task_create`, `team_task_list`, `team_task_get`, and `team_task_update` add, browse, read, and update shared tasks. @@ -91,12 +91,12 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08- | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: config, the fixed policy text, and the ten scoped tool registrations | +| [`src/index.ts`](src/index.ts) | Plugin entry: config, the fixed policy text, and the nine scoped tool registrations | | — | No runtime invariant companion is published; the Team service owns durable and authorization relations. | ### Policy and tools -One `team:policy` section on the member scope teaches each member its role and the coordination rules; the fixed text and the ten tool registrations are declared in [`src/index.ts`](src/index.ts). The ten tool schemas appear only in Team member scopes, so non-Team subagents keep the default catalog. Scoped registrations with the same names as the legacy global continuable-subagent controls shadow those globals for team members only. +One `team:policy` section on the member scope teaches each member its role and the coordination rules; the fixed text and the nine tool registrations are declared in [`src/index.ts`](src/index.ts). The nine tool schemas appear only in Team member scopes, so non-Team subagents keep the default catalog. Scoped registrations with the same names as the legacy global continuable-subagent controls shadow those globals for team members only. ### Scoped registration and teardown @@ -125,7 +125,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -One stable policy section states the exact Team role/name/id, the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, quiet versus waking delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. The ten Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes. +One stable policy section states the exact Team role/name/id, the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. The nine Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes. #### Token effect diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md index ca4dd17e0c..15a87f5640 100644 --- a/packages/experimental/tool-agent-team/README.zh.md +++ b/packages/experimental/tool-agent-team/README.zh.md @@ -1,5 +1,5 @@ --- -description: "十个让模型创建、发消息与协调 teammate 的工具,供组合实验性 Team 插件的部署方阅读。" +description: "九个让模型创建、发消息与协调 teammate 的工具,供组合实验性 Team 插件的部署方阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-experimental-tool-agent-team` 在团队领域包之上给模型一套团队工具:创建具名 teammate、给它们发消息或后续任务、查看谁在线、等待进展、中断卡住的 teammate,以及管理共享任务板——共十个工具。每个成员的提示词中都有一段简短策略,教模型何时组建团队(只有你要求时)以及如何在共享工作区协作。挂载它会用同名的团队工具取代旧版 subagent 控件,因此想同时使用两者的组合必须禁用旧定义。它是实验性的:不进入正式发布、不承诺稳定性,并且只有你明确要求组建团队时才会创建 teammate。 +`dsh-experimental-tool-agent-team` 在团队领域包之上给模型一套团队工具:创建具名 teammate、向它们 Steer 消息、查看谁在线、等待进展、中断卡住的 teammate,以及管理共享任务板——共九个工具。每个成员的提示词中都有一段简短策略,教模型何时组建团队(只有你要求时)以及如何在共享工作区协作。挂载它会用同名的团队工具取代旧版 subagent 控件,因此想同时使用两者的组合必须禁用旧定义。它是实验性的:不进入正式发布、不承诺稳定性,并且只有你明确要求组建团队时才会创建 teammate。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当模型应该通过工具运行一支团队时,在 `@deepseek-ai/dsh-experimental-agent-team` 之上挂载本包。挂载后,每个团队成员——Lead 与每个 teammate——都会获得相同的十个工具,外加一段说明自身角色与名字的策略段落。 +当模型应该通过工具运行一支团队时,在 `@deepseek-ai/dsh-experimental-agent-team` 之上挂载本包。挂载后,每个团队成员——Lead 与每个 teammate——都会获得相同的九个工具,外加一段说明自身角色与名字的策略段落。 ### 何时选择 @@ -54,10 +54,10 @@ kind: "package-reference" ### 模型能做什么 -十个工具分为四类能力: +九个工具分为四类能力: - **创建 teammate**——`spawn_teammate` 接收名字、描述与初始任务;只有 Lead 可以调用它。 -- **发送消息**——`send_message` 在不唤醒 idle teammate 的情况下传达信息;`followup_task` 让消息成为接收方的下一个轮次,并在需要时唤醒它。 +- **发送消息**——`send_message` 在最近的步骤边界 Steer running member、启动 idle member,并冷恢复 inactive teammate。 - **查看与等待**——`list_agents` 显示带实时状态的 roster;`wait_agent` 等待下一次团队变化;`interrupt_agent` 停止 teammate 的当前轮次(仅限 Lead)。 - **管理任务板**——`team_task_create`、`team_task_list`、`team_task_get` 与 `team_task_update` 添加、浏览、读取与更新共享任务。 @@ -91,12 +91,12 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:配置、固定策略文本与十个 scoped 工具注册 | -| — | 不发布运行时不变式伴生入口;委托只能通过 `ctx.agentTeams` 观察。 | +| [`src/index.ts`](src/index.ts) | 插件入口:配置、固定策略文本与九个 scoped 工具注册 | +| — | 不发布运行时不变式伴生入口;Team 服务拥有持久化与授权关系。 | ### 策略与工具 -member scope 上的一个 `team:policy` 段落教每个成员自己的角色与协作规则;固定文本与十个工具注册都声明在 [`src/index.ts`](src/index.ts)。十个工具 schema 只出现在 Team member scope 中,因此非 Team subagent 保持默认目录。与旧全局 continuable-subagent 控件同名的 scoped 注册只会为团队成员覆盖这些全局控件。 +member scope 上的一个 `team:policy` 段落教每个成员自己的角色与协作规则;固定文本与九个工具注册都声明在 [`src/index.ts`](src/index.ts)。九个工具 schema 只出现在 Team member scope 中,因此非 Team subagent 保持默认目录。与旧全局 continuable-subagent 控件同名的 scoped 注册只会为团队成员覆盖这些全局控件。 ### 按作用域注册与拆除 @@ -125,7 +125,7 @@ member scope 上的一个 `team:policy` 段落教每个成员自己的角色与 #### 模型看到什么 -一段稳定策略会说明确切 Team role/name/id、显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、quiet 与 waking 投递区别、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的十个 Team schema 只出现在 Team member scope。 +一段稳定策略会说明确切 Team role/name/id、显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的九个 Team schema 只出现在 Team member scope。 #### Token 影响 diff --git a/packages/experimental/tool-agent-team/src/index.ts b/packages/experimental/tool-agent-team/src/index.ts index ee60a1aea1..824c042f23 100644 --- a/packages/experimental/tool-agent-team/src/index.ts +++ b/packages/experimental/tool-agent-team/src/index.ts @@ -34,10 +34,10 @@ The Team Lead and all teammates share the same working directory and filesystem. Prefer read/edit/write for file changes. If a file operation returns FS_STALE_VERSION, read the current file, rebase your intended change onto the new content, and retry. Bash, formatters, code generators, and scripts are not fully protected by the filesystem version guard; coordinate them explicitly and have the Lead review the final diff and run tests. -Use send_message for quiet information that must not start an idle teammate. Use followup_task when the target should run another turn. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use followup_task first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.` +send_message steers a running target at its nearest step boundary, starts an idle target, and cold-resumes an inactive teammate. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use send_message first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.` const ACTIVE_WAIT_STATUSES: ReadonlySet = new Set(['running', 'provisioning']) -const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.' +const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.' /** * One roster row, matching `TeamMemberView`. The Lead pseudo-row omits the @@ -198,29 +198,22 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo }, }))) - const messageTool = (toolName: 'send_message' | 'followup_task', delivery: 'quiet' | 'wakeup'): void => { - register(scoped.tools.register(defineTool({ - name: toolName, - description: delivery === 'quiet' - ? 'Send durable information to another Team member without starting an idle member.' - : 'Send a durable follow-up task to another Team member and start a turn when needed.', - parameters: { - target: { type: 'string', required: true, description: 'Team member name, or lead.' }, - message: { type: 'string', required: true, description: 'Self-contained message for the target.' }, - }, - output: jsonOutput(SEND_VALUE_SCHEMA), - execute(args, exec) { - return ctx.agentTeams.sendMessage(callingAgent(exec.agent, toolName), { - target: args.target, - content: [{ type: 'text', text: args.message }], - delivery, - signal: exec.signal, - }) - }, - }))) - } - messageTool('send_message', 'quiet') - messageTool('followup_task', 'wakeup') + register(scoped.tools.register(defineTool({ + name: 'send_message', + description: 'Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes.', + parameters: { + target: { type: 'string', required: true, description: 'Team member name, or lead.' }, + message: { type: 'string', required: true, description: 'Self-contained message for the target.' }, + }, + output: jsonOutput(SEND_VALUE_SCHEMA), + execute(args, exec) { + return ctx.agentTeams.sendMessage(callingAgent(exec.agent, 'send_message'), { + target: args.target, + content: [{ type: 'text', text: args.message }], + signal: exec.signal, + }) + }, + }))) register(scoped.tools.register(defineTool({ name: 'list_agents', diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 353586a37f..199a635b7a 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -26,7 +26,6 @@ const SIGNAL = new AbortController().signal const TOOL_NAMES = [ 'spawn_teammate', 'send_message', - 'followup_task', 'list_agents', 'wait_agent', 'interrupt_agent', @@ -179,7 +178,7 @@ describe('dsh-tool-team', () => { timedOut: false, noProgress: { reason: 'no-active-peer', - message: 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.', + message: 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.', }, }) for (const timeout_ms of [9_999, 3_600_001, Number.MAX_SAFE_INTEGER + 1]) { @@ -221,12 +220,12 @@ describe('dsh-tool-team', () => { // Every Team result reaches the model as compact JSON: indentation would // spend tokens on every roster, task, and receipt without adding meaning. expect(text(roster)).toBe(JSON.stringify(JSON.parse(text(roster)))) - const peer = await execute(ctx, child, 'send_message', { target: 'lead', message: 'quiet report' }) + const peer = await execute(ctx, child, 'send_message', { target: 'lead', message: 'progress report' }) expect(peer.isError).toBe(false) expect(JSON.parse(text(peer))).toMatchObject({ status: 'accepted' }) - const waking = await execute(ctx, child, 'followup_task', { target: 'lead', message: 'review the report' }) - expect(waking.isError).toBe(false) - expect(JSON.parse(text(waking))).toMatchObject({ status: 'accepted' }) + const followup = await execute(ctx, child, 'send_message', { target: 'lead', message: 'review the report' }) + expect(followup.isError).toBe(false) + expect(JSON.parse(text(followup))).toMatchObject({ status: 'accepted' }) await lead.whenIdle() const created = await execute(ctx, lead, 'team_task_create', { @@ -425,7 +424,6 @@ describe('dsh-tool-team', () => { await ctx.agentTeams.sendMessage(lead, { target: 'cold-worker', content: [{ type: 'text', text: 'resume with Team scope' }], - delivery: 'wakeup', signal: SIGNAL, }) const resumed = await waitRunning(ctx, childId) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7c6091b1e4..0716f61e2d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -370,7 +370,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise', description: 'Queue one durable peer message, then attempt immediate delivery.', - parameters: [{ name: 'caller', description: 'exact live sending Team member.' }, { name: 'request', description: 'target name, content, scheduling mode, and pre-queue cancellation.' }], + parameters: [{ name: 'caller', description: 'exact live sending Team member.' }, { name: 'request', description: 'target name, content, and pre-queue cancellation.' }], returns: 'durable message identity and immediate-delivery observation.', }, { @@ -4780,7 +4780,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SendTeamMessageRequest', - declaration: 'export interface SendTeamMessageRequest {\n readonly target: string;\n readonly content: ContentBlock[];\n readonly delivery: \'quiet\' | \'wakeup\';\n readonly signal: AbortSignal;\n}', + declaration: 'export interface SendTeamMessageRequest {\n readonly target: string;\n readonly content: ContentBlock[];\n readonly signal: AbortSignal;\n}', }, { name: 'SendTeamMessageResult', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 0d24bbc1ef..d01088076b 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: 257a76a4ef84583d3337f697bc8024d72219c4d6 -README.zh.md: 135086b982df38089cdef0acf85535d35354a9d7 +README.md: 6712af94000d82878d8f7cea6194eeb0d2f92421 +README.zh.md: ee0cc8861c174939cc767ba6791c1c19c1d2bc8c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 257a76a4ef..6712af9400 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -77,7 +77,7 @@ This section explains how the service is built and where the observable behavior |---|---| | [`src/index.ts`](src/index.ts) | Service entry: provider registry, start and continuation API, lifecycle events | | [`src/continuation.ts`](src/continuation.ts) | Continuable children: identity reservation, Activation residency, adjacent messaging, interrupt, settlement | -| [`src/internal.ts`](src/internal.ts) | Host-only Queue adapter for browser and Team message protocols | +| [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters for browser and Team message protocols | | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts | | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary | | [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers | diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 135086b982..ee0cc8861c 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -77,7 +77,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 服务入口:提供方注册表、启动与继续 API、生命周期事件 | | [`src/continuation.ts`](src/continuation.ts) | 可继续子级:身份预留、Activation 驻留、相邻消息、中断、结算 | -| [`src/internal.ts`](src/internal.ts) | 供浏览器与 Team 消息协议使用的 host-only Queue 适配器 | +| [`src/internal.ts`](src/internal.ts) | 供浏览器与 Team 消息协议使用的 host-only Queue 与 Steer 适配器 | | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 | | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 | | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 | diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 0ccf961407..08c55fbf63 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -133,7 +133,7 @@ export interface SubagentSendMessageOptions { /** Inputs shared by model steering and the human Queue adapter. */ type ChildDeliveryOptions = - | { readonly delivery: 'steer'; readonly signal: AbortSignal } + | { readonly delivery: 'steer'; readonly source?: MessageSource; readonly signal: AbortSignal } | { readonly delivery: 'queue'; readonly source: MessageSource; readonly signal: AbortSignal } /** @@ -567,6 +567,25 @@ export class SubagentContinuationManager { return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'queue' }) } + /** + * Steer one host-authored prompt to a direct continuable child. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - host-authored content to deliver. + * @param source - durable host-protocol provenance. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted message's inbox id. + */ + async steerPrompt( + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: MessageSource, + signal: AbortSignal, + ): Promise { + return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'steer' }) + } + /** Route one parent-originated delivery through residency and cold resume. */ private async deliverToChild( parent: Agent, @@ -1262,7 +1281,7 @@ export class SubagentContinuationManager { // Parent-originated delivery keeps the parent live through ownership, so // establish it before the message can enter the child's inbox. this.acquireOwnership(parent, activation.childId) - const message = options.delivery === 'steer' + const message = options.source === undefined ? agentMessage(parent, content) : createUserMessage({ content, source: options.source }) const accepted = this.admitWaking(activation, message.id, () => { diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 28e50930cd..8eb29021ad 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -75,7 +75,7 @@ import { listChildren as listSubagentChildren, listDescendants as listSubagentDe import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' -import { queueSubagentPrompt } from './internal.ts' +import { deliverSubagentPrompt, type HostPromptDeliveryMode } from './internal.ts' export * from './out-of-process.ts' export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' @@ -255,7 +255,7 @@ export class SubagentRuntime extends TypertRemoteService { } /** - * Queue one host-protocol message as a distinct direct-child turn. + * Deliver one host-protocol message to a direct continuable child. * Symbol-keyed so host adapters can preserve their own provenance without * widening the public Service Definition or impersonating an Agent sender. * @param parent - exact live direct parent authorizing delivery. @@ -263,16 +263,20 @@ export class SubagentRuntime extends TypertRemoteService { * @param content - host-authored content to deliver. * @param source - durable host-protocol provenance. * @param signal - caller cancellation before inbox acceptance. + * @param delivery - Queue as a distinct turn or Steer at the nearest step. * @returns the accepted message's inbox id. */ - private [queueSubagentPrompt]( + private [deliverSubagentPrompt]( parent: Agent, childId: SessionId, content: ContentBlock[], source: MessageSource, signal: AbortSignal, + delivery: HostPromptDeliveryMode, ): Promise { - return this.requireContinuations().queuePrompt(parent, childId, content, source, signal) + return delivery === 'steer' + ? this.requireContinuations().steerPrompt(parent, childId, content, source, signal) + : this.requireContinuations().queuePrompt(parent, childId, content, source, signal) } /** @@ -445,12 +449,13 @@ export class SubagentRuntime extends TypertRemoteService { content = await admitPromptContent(attachments, request.content) } return { - messageId: await this[queueSubagentPrompt]( + messageId: await this[deliverSubagentPrompt]( parent, childSessionId, content, source, signal, + 'queue', ), } } catch (error: unknown) { diff --git a/packages/subagent/subagent/src/internal.ts b/packages/subagent/subagent/src/internal.ts index 47a3bda127..51bca12607 100644 --- a/packages/subagent/subagent/src/internal.ts +++ b/packages/subagent/subagent/src/internal.ts @@ -34,20 +34,24 @@ export function isAdjacentAgentSendMessageTool(definition: ToolDefinition | unde } /** - * Process-stable symbol-keyed Queue delivery shared by the bundled runtime + * Process-stable symbol-keyed host delivery shared by the bundled runtime * entry and this unbundled internal subpath. * @internal */ -export const queueSubagentPrompt = Symbol.for('dsh.subagent.queuePrompt') +export const deliverSubagentPrompt = Symbol.for('dsh.subagent.deliverPrompt') -/** Runtime face required by the host-only Queue adapter. */ -export interface HostPromptQueue { - [queueSubagentPrompt]( +/** Scheduling mode for one host-only direct-child prompt. */ +export type HostPromptDeliveryMode = 'queue' | 'steer' + +/** Runtime face required by the host-only prompt adapters. */ +export interface HostPromptDeliverer { + [deliverSubagentPrompt]( parent: Agent, childId: SessionId, content: ContentBlock[], source: MessageSource, signal: AbortSignal, + delivery: HostPromptDeliveryMode, ): Promise } @@ -69,11 +73,40 @@ export function queueHostSubagentPrompt( source: MessageSource, signal: AbortSignal, ): Promise { - return (runtime as unknown as HostPromptQueue)[queueSubagentPrompt]( + return (runtime as unknown as HostPromptDeliverer)[deliverSubagentPrompt]( parent, childId, content, source, signal, + 'queue', + ) +} + +/** + * Steer one host-protocol message without exposing another Service operation. + * @param runtime - subagent runtime owning continuation residency. + * @param parent - exact live direct parent authorizing delivery. + * @param childId - durable direct-child session id. + * @param content - host-authored content to deliver. + * @param source - durable host-protocol provenance. + * @param signal - caller cancellation before inbox acceptance. + * @returns the accepted message's inbox id. + */ +export function steerHostSubagentPrompt( + runtime: SubagentRuntime, + parent: Agent, + childId: SessionId, + content: ContentBlock[], + source: MessageSource, + signal: AbortSignal, +): Promise { + return (runtime as unknown as HostPromptDeliverer)[deliverSubagentPrompt]( + parent, + childId, + content, + source, + signal, + 'steer', ) } diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 1acfb9a3bd..733564032b 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -13,7 +13,7 @@ import SubagentRuntime, { type SubagentListEntry, type SubagentPromptRequestId, } from '@deepseek-ai/dsh-subagent' -import { queueSubagentPrompt, type HostPromptQueue } from '@deepseek-ai/dsh-subagent/internal' +import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal' const PARENT = SessionId('parent') const CHILD = SessionId('child') @@ -36,7 +36,7 @@ async function bench(live?: Record) { /** Spy on the private human-Queue adapter without widening the public service. */ function promptDelivery(subagents: SubagentRuntime) { - return vi.spyOn(subagents as unknown as HostPromptQueue, queueSubagentPrompt) + return vi.spyOn(subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) } function childRow(id: SessionId, activity: 'running' | 'inactive'): SubagentListEntry { @@ -254,6 +254,7 @@ describe('subagent prompt Remote', () => { [{ type: 'text', text: 'continue' }], { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' }, signal, + 'queue', ) }) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 0f3cdc5d38..972eadc1b8 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -543,7 +543,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ }, scope: ctx => catalogChildScopes.get(ctx) as Agent, note: - 'All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.', + 'All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.', }, { pkg: '@deepseek-ai/dsh-tool-todo', From 1149d47e9a06f002b081801fc52d782cb6398ea9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 30 Aug 2026 20:51:43 +0800 Subject: [PATCH 02/17] test(tools): update team catalog expectation --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 66e1929039..0630cfaa8e 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -28,7 +28,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { expect(names).toEqual([ 'ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', - 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep', + 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'run_code', 'schedule_create', 'schedule_delete', From fcd109d29a4a7d298b1f2eeda2463c20b1cc09ac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:07:58 +0800 Subject: [PATCH 03/17] feat(storage): version read compatibility and backup-and-skip salvage for per-record units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DomainSpec may declare compatibleVersions: older domain versions whose stored records the current record schemas still accept. The json backend's per-record reads admit documents stamped with a declared version (writes always stamp the current one), and the legacy whole-unit bootstrap migrates only a file whose stored version is in the accepted set — previously it migrated any version and stamped the records current, turning a discardable stale cache into invalid-record failures that refused the whole domain at open and permanently poisoned the new tree on first boot. A DomainSpec may also declare invalidRecords: 'backup-and-skip' for domains whose records are disposable derived data: a stored record failing its zod schema is moved aside through the new optional KvUnit.backupRecord (.json.bak. under the json backend), logged with its cause, and skipped, instead of rejecting the open. The default stays fail-loud, and so do backends without backupRecord. --- packages/storage/storage-domain/src/index.ts | 23 +++++- packages/storage/storage-domain/src/spec.ts | 34 ++++++++ .../storage-domain/tests/domain.spec.ts | 41 ++++++++++ packages/storage/storage-json/src/format.ts | 18 +++-- .../storage-json/src/per-record-unit.ts | 75 +++++++++++++----- .../storage-json/tests/json-backend.spec.ts | 78 ++++++++++++++++++- packages/storage/storage/src/backend.ts | 23 ++++++ 7 files changed, 259 insertions(+), 33 deletions(-) diff --git a/packages/storage/storage-domain/src/index.ts b/packages/storage/storage-domain/src/index.ts index d2c16a3d69..7e0be5e151 100644 --- a/packages/storage/storage-domain/src/index.ts +++ b/packages/storage/storage-domain/src/index.ts @@ -88,7 +88,10 @@ export class DomainFacility { * (`facet-unsupported`); open the unit projected from the spec (backend * `version-mismatch`/`malformed-medium` pass through); load and validate * every stored record against the spec's zod schemas (`invalid-record` - * with the offending table and key); construct the domain. + * with the offending table and key — unless the spec declares + * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in + * which case the failing record is backed up, logged, and skipped); + * construct the domain. * * Lifecycle: the CALLER owns the returned handle and closes it via * `Domain.close()` (typically as its own `ctx.effect` disposer) — the @@ -118,7 +121,23 @@ export class DomainFacility { for (const [table, tableSpec] of Object.entries(spec.tables)) { const records = new Map() for (const [key, raw] of Object.entries(snapshot.tables[table] ?? {})) { - records.set(key, parseRecord(spec.name, table, key, () => tableSpec.valueSchema.parse(raw))) + let parsed: unknown + try { + parsed = parseRecord(spec.name, table, key, () => tableSpec.valueSchema.parse(raw)) + } catch (error) { + // Backup-and-skip policy (disposable derived data): move the record's + // document aside, log the concrete failure, and open without the + // record. Backends that cannot move a document keep the loud path. + if (spec.invalidRecords !== 'backup-and-skip' || unit.backupRecord === undefined) throw error + const moved = await unit.backupRecord(table, key) + // parseRecord always wraps the zod failure as the cause. + this.ctx.logger.error( + `domain '${spec.name}': stored record '${key}' in table '${table}' failed schema validation; ` + + `moved to '${moved}' and treated as absent. Cause: ${String((error as DomainError).cause)}`, + ) + continue + } + records.set(key, parsed) } tables.set(table, records) } diff --git a/packages/storage/storage-domain/src/spec.ts b/packages/storage/storage-domain/src/spec.ts index 74348918aa..f9267bcba5 100644 --- a/packages/storage/storage-domain/src/spec.ts +++ b/packages/storage/storage-domain/src/spec.ts @@ -45,6 +45,26 @@ export interface DomainSpec { * (a stale record document is discarded, never migrated). */ readonly layout?: 'single' | 'per-record' + /** + * Older domain versions whose stored records the current record schemas + * also accept (the declaring owner vouches for that, typically by + * declaring the fields older records lack as optional). `per-record` backends + * read documents stamped with a listed version instead of discarding them, + * and accept a legacy whole-unit file so stamped for the one-time + * bootstrap; writes always stamp {@link version}. + */ + readonly compatibleVersions?: readonly number[] + /** + * What `open` does with a stored table record that fails its zod schema. + * Absent (the default), the whole open rejects with `invalid-record` — + * right for authoritative data. `'backup-and-skip'` is for domains whose + * records are disposable derived data: the backend moves the record's + * document aside (`KvUnit.backupRecord`), the failure is logged with + * its cause, and the open continues with the record absent. A backend + * without `backupRecord` (no per-record document to move) falls back + * to the rejecting default. The global slot always rejects. + */ + readonly invalidRecords?: 'backup-and-skip' /** Optional global singleton slot. */ readonly global?: DomainGlobalSpec /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */ @@ -91,6 +111,13 @@ export function defineDomain(spec: S): S { if (!Number.isInteger(spec.version) || spec.version < 0) { throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`) } + for (const compat of spec.compatibleVersions ?? []) { + if (!Number.isInteger(compat) || compat < 0 || compat >= spec.version) { + throw new Error( + `domain '${spec.name}' compatibleVersions entries must be non-negative integers below version ${spec.version}, got ${compat}`, + ) + } + } if (spec.layout !== undefined) { // Runtime boundary: the union type is compile-time only — a spec built // from config could carry any value, and a bad one must fail loud here. @@ -99,6 +126,12 @@ export function defineDomain(spec: S): S { throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`) } } + if (spec.invalidRecords !== undefined) { + const policy: string = spec.invalidRecords + if (policy !== 'backup-and-skip') { + throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present, got ${policy}`) + } + } for (const table of Object.keys(spec.tables)) { if (!UNIT_NAME_RE.test(table)) { throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`) @@ -125,5 +158,6 @@ export function descriptorOf(spec: DomainSpec): KvUnitDescriptor { tables: Object.keys(spec.tables), hasGlobal: spec.global !== undefined, ...spec.layout === undefined ? {} : { layout: spec.layout }, + ...spec.compatibleVersions === undefined ? {} : { compatibleVersions: spec.compatibleVersions }, } } diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 3b3c8fa679..b21ec0faf8 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -58,6 +58,25 @@ describe('defineDomain', () => { })).toThrow(/must not accept null/) }) + it('validates compatibleVersions entries and projects them onto the descriptor', () => { + expect(() => defineDomain({ name: 'ok', version: 2, compatibleVersions: [1.5], tables: {} })) + .toThrow(/compatibleVersions/) + expect(() => defineDomain({ name: 'ok', version: 2, compatibleVersions: [2], tables: {} })) + .toThrow(/below version/) + expect(() => defineDomain({ name: 'ok', version: 2, compatibleVersions: [-1], tables: {} })) + .toThrow(/compatibleVersions/) + expect(descriptorOf(defineDomain({ name: 'ok', version: 2, compatibleVersions: [0, 1], tables: {} }))) + .toMatchObject({ compatibleVersions: [0, 1] }) + // An undeclared set is absent from the descriptor. + expect(descriptorOf(spec)).not.toHaveProperty('compatibleVersions') + }) + + it('rejects an unknown invalidRecords policy', () => { + expect(() => defineDomain({ + name: 'ok', version: 1, invalidRecords: 'zap' as 'backup-and-skip', tables: {}, + })).toThrow(/invalidRecords/) + }) + it('rejects an invalid layout and projects the declared one onto the descriptor', () => { // A spec built from config can carry any value; the union type is // compile-time only, so the runtime boundary check must reject it. @@ -139,6 +158,28 @@ describe('DomainFacility.open', () => { }) }) + it('keeps the rejecting default under backup-and-skip when the backend cannot move documents', async () => { + // The memory backend has no backupRecord, so the declared policy cannot + // apply and the open falls back to failing loud. + const salvageSpec = defineDomain({ + name: 'salvage', + version: 1, + invalidRecords: 'backup-and-skip', + tables: { items: domainTable(itemSchema) }, + }) + const pool = new MemoryMediaPool() + { + const { facility } = await harness({ pool }) + await (await facility.open(salvageSpec)).table('items').put('bad', { label: 'x', count: 2 }) + } + pool.media.get('salvage')!.tables.get('items')!.set('bad', { label: 'x', count: 'NaN' }) + const { facility } = await harness({ pool }) + await expect(facility.open(salvageSpec)).rejects.toMatchObject({ + code: 'invalid-record', + detail: { table: 'items', key: 'bad' }, + }) + }) + it('rejects a stored global that fails its schema with the global marker', async () => { const pool = new MemoryMediaPool() pool.versions.set('demo', 1) diff --git a/packages/storage/storage-json/src/format.ts b/packages/storage/storage-json/src/format.ts index 045485eaba..22cbe8f703 100644 --- a/packages/storage/storage-json/src/format.ts +++ b/packages/storage/storage-json/src/format.ts @@ -100,16 +100,18 @@ export function serializeRecord(version: number, value: unknown): string { /** * Parse one per-record document, validating its version stamp. A document - * that is malformed or stamped with a different version is FOREIGN and reads - * as absent — the per-record contract: one bad or stale record file must not - * brick the whole unit, and a version bump discards stale records instead of - * migrating them (the whole-unit format rejects instead, because there is - * exactly one document). + * that is malformed or stamped with an unaccepted version is FOREIGN and + * reads as absent — the per-record contract: one bad or stale record file + * must not brick the whole unit, and a version bump discards stale records + * instead of migrating them (the whole-unit format rejects instead, because + * there is exactly one document). * @param text - Raw per-record document content. - * @param version - Expected unit version; a mismatch discards the document. + * @param versions - Accepted unit versions (the current one plus the + * descriptor's compatibleVersions); any other stamp discards the + * document. * @returns the record value, or `undefined` for a foreign document. */ -export function parseRecord(text: string, version: number): unknown { +export function parseRecord(text: string, versions: readonly number[]): unknown { let document: unknown try { document = JSON.parse(text) @@ -118,6 +120,6 @@ export function parseRecord(text: string, version: number): unknown { } if (typeof document !== 'object' || document === null) return undefined const { version: stamped, record } = document as Record - if (stamped !== version) return undefined + if (typeof stamped !== 'number' || !versions.includes(stamped)) return undefined return record } diff --git a/packages/storage/storage-json/src/per-record-unit.ts b/packages/storage/storage-json/src/per-record-unit.ts index b75f9f9452..346c7a0d6a 100644 --- a/packages/storage/storage-json/src/per-record-unit.ts +++ b/packages/storage/storage-json/src/per-record-unit.ts @@ -10,20 +10,23 @@ * memory unchanged. * * Per-record contract: a record document that is malformed or stamped with a - * different version reads as an absent record — one bad or stale file never - * bricks the whole unit, and a version bump discards stale records instead - * of migrating them. Record keys become path segments, so they must be - * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write. + * version outside the accepted set (the descriptor's current version plus + * its `compatibleVersions`) reads as an absent record — one bad or stale + * file never bricks the whole unit, and a version bump discards stale + * records instead of migrating them. Record keys become path segments, so + * they must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write. * * Legacy bootstrap: when the new tree has no document path, a legacy * whole-unit file `/.json` (the pre-per-record layout) seeds - * per-record documents. Any new document path, including one whose contents - * are unreadable or stale, suppresses the bootstrap for the whole unit. The + * per-record documents, provided its stored unit version is in the accepted + * set — a legacy file stamped with any other version is left alone and reads + * as the empty unit. Any new document path, including one whose contents are + * unreadable or stale, suppresses the bootstrap for the whole unit. The * legacy file is never changed or deleted. * @module @deepseek-ai/dsh-storage-json/src/per-record-unit */ -import { mkdir, readFile, readdir, rm } from 'node:fs/promises' +import { mkdir, readFile, readdir, rename, rm } from 'node:fs/promises' import { dirname, join } from 'node:path' import type { Dirent } from 'node:fs' import { StorageError } from '@deepseek-ai/dsh-storage' @@ -64,6 +67,7 @@ export async function openPerRecordUnit( * @returns the authoritative state reconstructed from the tree. */ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Promise { + const versions = acceptedStamps(descriptor) const state: UnitState = { version: descriptor.version, global: null, @@ -83,11 +87,11 @@ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Pr if (entry.isDirectory()) { const records = state.tables.get(entry.name) if (records !== undefined) { - return loadTableRecords(records, descriptor.version, join(dir, entry.name)) + return loadTableRecords(records, versions, join(dir, entry.name)) } } if (entry.name === 'global.json' && descriptor.hasGlobal) { - const global = await readRecord(join(dir, entry.name), descriptor.version) + const global = await readRecord(join(dir, entry.name), versions) if (global !== undefined) state.global = global return true } @@ -97,12 +101,21 @@ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Pr return state } +/** The version stamps this unit reads as its own: current plus declared compatible versions. */ +function acceptedStamps(descriptor: KvUnitDescriptor): readonly number[] { + return [descriptor.version, ...descriptor.compatibleVersions ?? []] +} + /** * Bootstrap an empty per-record tree from a legacy whole-unit file * (`/.json`, the pre-per-record layout). Every declared-table * record is copied into a current-version document, while the legacy file is * retained unchanged. A missing, foreign (another unit's name), malformed, - * or non-unit legacy file is left alone; other read failures propagate. + * or non-unit legacy file is left alone, and so is one whose stored unit + * version is outside the accepted set — migrating records the owner never + * vouched for would stamp them with the current version and turn a + * discardable stale cache into schema failures at the domain layer. Other + * read failures propagate. * @param descriptor - Static identity and shape of the unit. * @param dir - The per-record unit directory (`/`). * @param state - The empty tree state; bootstrapped records are added. @@ -116,16 +129,18 @@ async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, st if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error return } - // The legacy document is runtime data: only `unit.name` and the tables map - // shape are checked here — the record values are migrated as-is and the - // domain layer's schemas judge them. - let document: { unit?: { name?: unknown }; tables?: unknown } + // The legacy document is runtime data: only `unit.name`, `unit.version`, + // and the tables map shape are checked here — the record values are + // migrated as-is and the domain layer's schemas judge them. + let document: { unit?: { name?: unknown; version?: unknown }; tables?: unknown } try { - document = JSON.parse(text) as { unit?: { name?: unknown }; tables?: unknown } + document = JSON.parse(text) as { unit?: { name?: unknown; version?: unknown }; tables?: unknown } } catch { return // Malformed legacy file: not ours to interpret or delete. } if (document.unit?.name !== descriptor.name) return + const stamped = document.unit.version + if (typeof stamped !== 'number' || !acceptedStamps(descriptor).includes(stamped)) return const tables = document.tables if (typeof tables !== 'object' || tables === null) return const recordsByTable = tables as Record> @@ -146,14 +161,14 @@ async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, st * @returns whether the directory contains any `.json` document path, * independently of key safety, readability, or stored version. */ -async function loadTableRecords(records: Map, version: number, dir: string): Promise { +async function loadTableRecords(records: Map, versions: readonly number[], dir: string): Promise { const files = await readdir(dir, { withFileTypes: true }) const hasDocuments = files.some(file => file.name.endsWith('.json')) const loaded = await Promise.all(files.map(async (file) => { if (!file.name.endsWith('.json')) return const key = file.name.slice(0, -'.json'.length) if (!SAFE_KEY_RE.test(key)) return - const record = await readRecord(join(dir, file.name), version) + const record = await readRecord(join(dir, file.name), versions) if (record !== undefined) return [key, record] as const })) for (const record of loaded) { @@ -163,9 +178,9 @@ async function loadTableRecords(records: Map, version: number, } /** Read one record document; a foreign (unreadable or stale) one reads as absent. */ -async function readRecord(path: string, version: number): Promise { +async function readRecord(path: string, versions: readonly number[]): Promise { try { - return parseRecord(await readFile(path, 'utf8'), version) + return parseRecord(await readFile(path, 'utf8'), versions) } catch { return undefined } @@ -213,6 +228,22 @@ export class PerRecordJsonUnit implements KvUnit { await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true })) } + /** + * Move one record's document aside as `.json.bak.`. The + * moved file no longer ends in `.json`, so every later read ignores it; the + * bytes stay on disk for inspection. A same-minute backup of the same + * key overwrites the previous backup (the newer bytes are the ones worth + * keeping). + */ + async backupRecord(table: string, key: string): Promise { + this.assertOpen() + assertSafeKey(this.descriptor.name, key) + const path = join(this.tableDir(table), `${key}.json`) + const moved = `${path}.bak.${backupStamp(new Date())}` + await this.tracked(rename(path, moved)) + return moved + } + /** Durably replace the global singleton. Only valid when declared. */ async setGlobal(value: unknown): Promise { this.assertOpen() @@ -267,6 +298,12 @@ export class PerRecordJsonUnit implements KvUnit { } } +/** Local-time `YYYYMMDDHHmm` suffix for backed-up documents. */ +function backupStamp(now: Date): string { + const pad = (value: number): string => String(value).padStart(2, '0') + return `${String(now.getFullYear())}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}` +} + /** Reject a record key that would be unsafe as a path segment. */ function assertSafeKey(unit: string, key: string): void { if (!SAFE_KEY_RE.test(key)) { diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index bafbf92ef4..57ad9d7633 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -329,10 +329,10 @@ describe('per-record layout', () => { it('bootstraps an empty per-record tree from a legacy whole-unit file and preserves it', async () => { const root = await freshRoot() - // A legacy single-layout file for the same unit (any older version); - // the extra table is not declared and must be skipped. + // A legacy single-layout file for the same unit, stamped with the current + // version; the extra table is not declared and must be skipped. const legacy = JSON.stringify({ - unit: { name: 'recs', version: 3 }, + unit: { name: 'recs', version: 2 }, global: null, tables: { t: { old1: { v: 1 }, old2: { v: 2 } }, undeclared: { k: { v: 0 } } }, }) @@ -347,6 +347,75 @@ describe('per-record layout', () => { await backend.close() }) + it('bootstraps from a legacy file only when its stored version is accepted', async () => { + // Version 3 is neither current (2) nor declared compat: the legacy file + // is left alone and the unit reads empty — migrating unvouched records + // would stamp them current and surface as schema failures at the domain + // layer instead of a discardable stale cache. + const root = await freshRoot() + const legacy = JSON.stringify({ + unit: { name: 'recs', version: 3 }, + global: null, + tables: { t: { old: { v: 1 } } }, + }) + await writeFile(join(root, 'recs.json'), legacy, 'utf8') + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open(descriptor) + expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null }) + await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy) + await unit.close() + await backend.close() + + // The same file bootstraps once version 3 is declared read-compatible… + const root2 = await freshRoot() + await writeFile(join(root2, 'recs.json'), legacy, 'utf8') + const backend2 = new JsonStorageBackend(root2) + const compat = { ...descriptor, version: 4, compatibleVersions: [3] } + const unit2 = await backend2.kv.open(compat) + expect(await unit2.loadAll()).toEqual({ tables: { t: { old: { v: 1 } } }, global: null }) + // …and the migrated documents are stamped with the CURRENT version. + expect(JSON.parse(await readFile(join(root2, 'recs', 't', 'old.json'), 'utf8'))) + .toEqual({ version: 4, record: { v: 1 } }) + await unit2.close() + await backend2.close() + }) + + it('backupRecord moves the document aside; reads see it absent and a write recreates it', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open(descriptor) + await unit.putRecord('t', 'k', { v: 1 }) + const moved = await unit.backupRecord!('t', 'k') + expect(moved).toMatch(/k\.json\.bak\.\d{12}$/) + expect(JSON.parse(await readFile(moved, 'utf8'))).toEqual({ version: 2, record: { v: 1 } }) + await expect(readFile(recordPath(root, 'k'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + // The moved file no longer ends in .json, so it reads as absent… + expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null }) + // …and the key is free for a fresh write. + await unit.putRecord('t', 'k', { v: 2 }) + expect(await unit.loadAll()).toEqual({ tables: { t: { k: { v: 2 } } }, global: null }) + await expect(unit.backupRecord!('t', 'a/b')).rejects.toThrow(/not path-safe/) + await unit.close() + await expect(unit.backupRecord!('t', 'k')).rejects.toMatchObject({ code: 'closed' }) + await backend.close() + }) + + it('reads per-record documents stamped with a declared compat version and stamps writes current', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const compat = { ...descriptor, compatibleVersions: [1] } + await mkdir(join(root, 'recs', 't'), { recursive: true }) + await writeFile(recordPath(root, 'oldrec'), JSON.stringify({ version: 1, record: { v: 'old' } }), 'utf8') + await writeFile(recordPath(root, 'ancient'), JSON.stringify({ version: 0, record: { v: 'no' } }), 'utf8') + const unit = await backend.kv.open(compat) + // Version 1 is declared compat and served; version 0 is not and discards. + expect(await unit.loadAll()).toEqual({ tables: { t: { oldrec: { v: 'old' } } }, global: null }) + await unit.putRecord('t', 'oldrec', { v: 'new' }) + expect(JSON.parse(await readFile(recordPath(root, 'oldrec'), 'utf8'))) + .toEqual({ version: 2, record: { v: 'new' } }) + await backend.close() + }) + it('ignores the legacy whole-unit file when any new document path exists', async () => { const root = await freshRoot() const legacy = JSON.stringify({ @@ -400,7 +469,8 @@ describe('per-record layout', () => { await backend4.close() const root5 = await freshRoot() - await writeFile(join(root5, 'recs.json'), JSON.stringify({ unit: { name: 'recs' }, tables: 'not an object' }), 'utf8') + // A current-version stamp so the shapeless `tables` is what stops the bootstrap. + await writeFile(join(root5, 'recs.json'), JSON.stringify({ unit: { name: 'recs', version: 2 }, tables: 'not an object' }), 'utf8') const backend5 = new JsonStorageBackend(root5) const unit5 = await backend5.kv.open(descriptor) expect(await unit5.loadAll()).toEqual({ tables: { t: {} }, global: null }) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 9cba748655..4a8e0ac2d8 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -61,6 +61,16 @@ export interface KvUnitDescriptor { * foreign documents. */ readonly layout?: 'single' | 'per-record' + /** + * Older unit versions whose stored records are also readable under the + * declaring owner's current record schemas (the owner vouches for that — + * typically by declaring the fields old records lack as optional). Reads of + * a `per-record` unit accept documents stamped with any listed version, and + * the legacy whole-unit bootstrap accepts a legacy file stamped with one; + * writes always stamp {@link version}. `single`-layout reads stay + * exact-version. + */ + readonly compatibleVersions?: readonly number[] } /** @@ -99,6 +109,19 @@ export interface KvUnit { */ deleteRecord(table: string, key: string): Promise + /** + * Move one record's stored document out of the unit's readable set, + * preserving its bytes for inspection instead of deleting them. Backends + * whose medium has no per-record document to move (the `single` layout, a + * row store) omit this member, and the caller falls back to its + * reject-loud path. Absent after the move: a later {@link loadAll} reads + * the key as missing and a later {@link putRecord} recreates it fresh. + * @param table - Declared table name. + * @param key - Record key. + * @returns the medium location the document was moved to (diagnostics). + */ + backupRecord?(table: string, key: string): Promise + /** * Write the global singleton durably. Only valid when the descriptor * declared `hasGlobal`. From 49df707c865f4ad22c26dd56c810c8c0bb0af393 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:08:41 +0800 Subject: [PATCH 04/17] fix(session-projection-cache): keep upgraded caches readable and boots safe across domain versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session_projcache domain declares compatibleVersions: [3, 4] and invalidRecords: 'backup-and-skip'. The two lineage identity fields become optional — records admitted from older versions predate them, and the single reader (identityMatches) interprets absence as the unseeded lineage: exact for unseeded sessions, while a seeded caller fails the match and refolds cold, so the lineage binding keeps its protection. Upgraded homes therefore boot and serve their cached listing titles immediately, including homes whose new tree already holds current-stamped documents without lineage fields, and a record failing validation anyway is backed up and skipped instead of refusing the plugin tree. tests/fixtures/ archives the real on-disk media of every shipped generation (v3 whole-unit file, v4 and v5 per-record documents, and the lineage-less current-stamped shape); fixtures.spec.ts proves each recovers through the real storage stack, rewrites to the current format on the next live write, and that a hopeless record is salvaged without costing the boot. --- .../session-projection-cache/src/index.ts | 12 +- .../session-projection-cache/src/spec.ts | 29 ++- .../tests/cache.spec.ts | 58 +++++ .../tests/fixtures.spec.ts | 240 ++++++++++++++++++ .../tests/fixtures/v3-single-unit.json | 136 ++++++++++ .../tests/fixtures/v4-session-doc.json | 212 ++++++++++++++++ .../tests/fixtures/v5-lineageless-doc.json | 128 ++++++++++ .../tests/fixtures/v5-session-doc.json | 220 ++++++++++++++++ 8 files changed, 1029 insertions(+), 6 deletions(-) create mode 100644 packages/session/session-projection-cache/tests/fixtures.spec.ts create mode 100644 packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json create mode 100644 packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json create mode 100644 packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json create mode 100644 packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 35bf5d9eed..22828a103c 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -367,12 +367,18 @@ function identityOf( } } -/** Whether a stored record's bound identity names the caller's lifecycle. */ +/** + * Whether a stored record's bound identity names the caller's lifecycle. + * Absent lineage fields (records admitted via `compatibleVersions` predate + * them) read as the unseeded lineage: exact for an unseeded caller, and a + * seeded caller's expectation then fails the match, discarding the record to + * a cold rebuild. + */ function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean { return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd - && stored.isSeeded === expected.isSeeded - && stored.inheritedEventCount === expected.inheritedEventCount + && (stored.isSeeded ?? false) === expected.isSeeded + && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount } export default SessionProjectionCache diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 35351917a5..d368764244 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -38,12 +38,19 @@ export const checkpointRow = z.object({ * old record pass every watermark check and seed state folded from an * unrelated log. Reads validate this against the live header (listing) or * the stored header (cold read) before accepting any record. + * + * The lineage fields are optional because records admitted through + * `compatibleVersions` predate them. The reader (`identityMatches`) + * interprets their absence as the unseeded lineage — exact for an unseeded + * session, while a seeded expectation fails the match and the record is + * discarded to a cold rebuild. Current-version writes always store both + * fields. */ export const checkpointIdentity = z.object({ createdAt: z.number().int().nonnegative(), cwd: z.string().optional(), - isSeeded: z.boolean(), - inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset), + isSeeded: z.boolean().optional(), + inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset).optional(), }) /** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */ @@ -68,11 +75,27 @@ export type CheckpointRecord = z.infer * bumps per session: after a bump, a stale session document is discarded on * open (cache semantics — a stale or unreadable cache costs a longer tail * replay, never a wrong value) while the rest of the domain stays usable, - * instead of rejecting the whole medium. + * instead of rejecting the whole medium. The `compatibleVersions` entries + * are declared because those records differ from the current version only + * by the absent optional lineage fields, so upgraded homes keep serving + * their cached listing projections instead of dropping every title until + * each session is reopened; the per-record version map lives in the + * read-compat Agent Note + * (.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md). + * The per-row `ver` guard and the identity match still discard anything the + * current fold semantics cannot vouch for. + * + * `invalidRecords: 'backup-and-skip'`: a stored record that fails the schema + * anyway is disposable derived data, so it must never cost the boot — the + * domain layer moves the document aside as `.json.bak.`, logs + * the concrete validation failure, and serves the session as uncached (a + * cold read rebuilds and rewrites it). */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', version: 5, + compatibleVersions: [3, 4], + invalidRecords: 'backup-and-skip', layout: 'per-record', tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index bfdca18567..4a5ce73d28 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -42,9 +42,11 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/marks2': Map 'cache-test/count': number 'cache-test/secret': string + 'cache-test/marks3': MarksState } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } + 'cache-test/marks3': { marks: string[] } } } @@ -71,6 +73,18 @@ const marksUnit = (stateVersion = 1) => ({ stateVersion, }) satisfies ProjectionDefinition<'cache-test/marks', MarksState> +const marks3Unit = { + key: 'cache-test/marks3', + stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), + init: () => null, + apply: state => state, + wire: { + viewSchema: z.object({ marks: z.array(z.string()) }), + view: state => state ?? { marks: [] }, + }, + stateVersion: 1, +} satisfies ProjectionDefinition<'cache-test/marks3', MarksState> + const secretUnit = { key: 'cache-test/secret', stateSchema: z.string(), @@ -376,6 +390,26 @@ describe('SessionProjectionCache listing read', () => { .toBeUndefined() }) + it('carries ONE cut across multiple served rows: the lowest watermark wins', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + // Equal watermarks: whichever row is visited second cannot lower the cut, + // so the one-cut fold sees both a lowering and a non-lowering row in + // every iteration order. + await seedRecord(root, 'multi-row', { + 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['a'] } }, + 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } }, + }) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(marks3Unit) + const block = cache.cachedSnapshot(headerOf(SessionId('multi-row')), SessionLogOffset(0)) + expect(block?.values).toEqual({ + 'cache-test/marks': { marks: ['a'] }, + 'cache-test/marks3': { marks: ['b'] }, + }) + expect(block?.asOfSeq).toBe(4) + }) + it('returns undefined when the stored record is version-mismatched', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) @@ -394,6 +428,30 @@ describe('SessionProjectionCache listing read', () => { .toBeUndefined() }) + it('serves a pre-lineage record (accepted old version) to an unseeded caller only', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + // A document stamped with an accepted older version whose identity + // predates the lineage fields: absent lineage reads as unseeded. + const path = recordPath(root, SessionId('pre-lineage')) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify({ + version: 4, + record: { + identity: { createdAt: 0 }, + rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['kept'] } } }, + }, + })) + const { cache } = await harness({ root }) + const id = SessionId('pre-lineage') + // Unseeded caller: the absent lineage is exactly its identity — served. + expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))) + .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['kept'] } } }) + // Seeded caller: the lineage-less record cannot vouch for the cut — refused. + expect(cache.cachedSnapshot({ ...headerOf(id), isSeeded: true }, SessionLogOffset(2))) + .toBeUndefined() + }) + it('returns undefined when every stored row is version-mismatched', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts new file mode 100644 index 0000000000..a85bcef5c6 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -0,0 +1,240 @@ +/** + * Cross-version recovery over archived on-disk artifacts. `fixtures/` holds + * real `session_projcache` media produced by released builds through their + * web apps (see scripts/releasefix): the v3 whole-unit file, a v4 per-record + * document, a current v5 document, and the v5-stamped lineage-less document + * that the formerly unguarded legacy bootstrap wrote over v3 records. Each + * must recover through the real storage stack — the domain opens and the + * listing read serves the archived title — and a record that fails schema + * validation anyway is backed up and skipped instead of failing the boot. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import Storage from '@deepseek-ai/dsh-storage' +import { + apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName, +} from '@deepseek-ai/dsh-storage-json' +import { + apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName, +} from '@deepseek-ai/dsh-storage-domain' +import SessionProjectionCache from '../src/index.ts' +import { projectionCacheDomainSpec } from '../src/spec.ts' + +// Declarations must match the shipped title unit's exactly (the repo-wide +// compile face sees both). +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + title: string | null + } + interface SessionProjectionMap { + title: string | null + } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + 'fixtures-test/set-title': { title: string } + } + + interface OutOfBandSessionEventMap { + 'fixtures-test/set-title': true + } +} + +// Mirrors the shipped title unit's storage face: stateVersion 1, bare-string +// state (the fixture rows carry exactly this shape in every archived +// version), folding a test event so the rewrite path has fresh data. +const titleUnit = { + key: 'title', + stateSchema: z.string().nullable(), + init: () => null, + apply: (state, event) => (event.type === 'fixtures-test/set-title' ? event.data.title : state), + wire: { viewSchema: z.string().nullable(), view: state => state }, + stateVersion: 1, +} satisfies ProjectionDefinition<'title', string | null> + +const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url)) + +/** One archived per-record document (`{version, record}`). */ +interface FixtureDoc { + version: number + record: { + identity: { createdAt: number; cwd?: string } + rows: Record + } +} + +async function fixtureJson(name: string): Promise { + return JSON.parse(await readFile(join(FIXTURES, name), 'utf8')) as T +} + +/** Header for the session a fixture record is bound to (identity witness). */ +function headerFor(id: SessionId, identity: FixtureDoc['record']['identity']): SessionHeader { + return { + version: 0, + id, + createdAt: identity.createdAt, + isSeeded: false, + ...identity.cwd === undefined ? {} : { cwd: identity.cwd }, + } +} + +const contexts: Context[] = [] +const roots: string[] = [] + +async function harness(root: string) { + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) + await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleUnit) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + return { ctx, cache: ctx.sessionProjectionCache } +} + +/** Lay one per-record fixture document into a fresh backend root. */ +async function placeDoc(root: string, id: string, name: string): Promise { + const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`) + await mkdir(dirname(path), { recursive: true }) + await cp(join(FIXTURES, name), path) + return fixtureJson(name) +} + +/** + * Drive a live write over a recovered session id and assert the archived + * document is replaced by a current-version one: v5 stamp, lineage present, + * and the freshly folded title — the write path never keeps the old format. + */ +async function assertRewrite(ctx: Context, root: string, id: SessionId): Promise { + const session = ctx.sessions.create(id) + session.append('fixtures-test/set-title', { title: '重写标题' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`) + await vi.waitFor(async () => { + const doc = JSON.parse(await readFile(path, 'utf8')) as FixtureDoc + expect(doc.version).toBe(projectionCacheDomainSpec.version) + expect(doc.record.identity).toMatchObject({ isSeeded: false, inheritedEventCount: 0 }) + expect(doc.record.rows['title']?.val).toBe('重写标题') + }, { timeout: 5_000 }) +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }))) +}) + +describe('archived version recovery', () => { + it('recovers the v3 whole-unit archive through the legacy bootstrap', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + await cp(join(FIXTURES, 'v3-single-unit.json'), join(root, `${projectionCacheDomainSpec.name}.json`)) + type SingleUnit = { + unit: { version: number } + tables: { sessions: Record } + } + const archive = await fixtureJson('v3-single-unit.json') + expect(archive.unit.version).toBe(3) // the fixture IS the old format + const [sid, record] = Object.entries(archive.tables.sessions)[0]! + + const { ctx, cache } = await harness(root) + const snapshot = cache.cachedSnapshot(headerFor(SessionId(sid), record.identity), SessionLogOffset(0), ['title']) + expect(snapshot?.values.title).toBe(record.rows['title']!.val) + + // The one-time bootstrap materialized a current-version document. + const migrated = JSON.parse( + await readFile(join(root, projectionCacheDomainSpec.name, 'sessions', `${sid}.json`), 'utf8'), + ) as { version: number } + expect(migrated.version).toBe(projectionCacheDomainSpec.version) + + await assertRewrite(ctx, root, SessionId(sid)) + }) + + for (const [fixture, storedVersion] of [ + ['v4-session-doc.json', 4], + ['v5-session-doc.json', 5], + ['v5-lineageless-doc.json', 5], + ] as const) { + it(`serves the archived title from ${fixture}, then rewrites it current`, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + const id = SessionId('fixture-session') + const doc = await placeDoc(root, id, fixture) + expect(doc.version).toBe(storedVersion) + + const { ctx, cache } = await harness(root) + const snapshot = cache.cachedSnapshot(headerFor(id, doc.record.identity), SessionLogOffset(0), ['title']) + expect(snapshot?.values.title).toBe(doc.record.rows['title']!.val) + + await assertRewrite(ctx, root, id) + }) + } + + it('refuses a lineage-less archive for a seeded caller (identity mismatch, cold rebuild)', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + const id = SessionId('fixture-seeded') + const doc = await placeDoc(root, id, 'v5-lineageless-doc.json') + + const { cache } = await harness(root) + const seeded = { ...headerFor(id, doc.record.identity), isSeeded: true } + expect(cache.cachedSnapshot(seeded, SessionLogOffset(2), ['title'])).toBeUndefined() + }) + + it('backs up and skips a record that fails schema validation instead of failing the boot', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-')) + roots.push(root) + const sessionsDir = join(root, projectionCacheDomainSpec.name, 'sessions') + await mkdir(sessionsDir, { recursive: true }) + // Current-version stamp, hopeless record content: no compat rung can save it. + await writeFile(join(sessionsDir, 'broken.json'), JSON.stringify({ + version: projectionCacheDomainSpec.version, + record: { identity: { createdAt: 'not-a-number' }, rows: 'not-an-object' }, + })) + const good = await placeDoc(root, SessionId('survivor'), 'v5-session-doc.json') + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) + await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleUnit) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + // The boot survives the broken record — this line rejecting IS the fixed bug. + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + + // Concrete console diagnostics: which record, where it went, and why. + expect(error).toHaveBeenCalledWith(expect.stringContaining("record 'broken'")) + expect(error).toHaveBeenCalledWith(expect.stringContaining('.json.bak.')) + + // The document was moved aside as .json.bak., bytes intact. + const entries = await readdir(sessionsDir) + expect(entries).not.toContain('broken.json') + const backup = entries.find(name => /^broken\.json\.bak\.\d{12}$/.test(name)) + expect(backup).toBeDefined() + expect(JSON.parse(await readFile(join(sessionsDir, backup!), 'utf8'))) + .toMatchObject({ record: { rows: 'not-an-object' } }) + + // The broken record reads as absent; its neighbors still serve. + const cache = ctx.sessionProjectionCache + expect(cache.cachedSnapshot(headerFor(SessionId('broken'), { createdAt: 0 }), SessionLogOffset(0))) + .toBeUndefined() + expect(cache.cachedSnapshot( + headerFor(SessionId('survivor'), good.record.identity), + SessionLogOffset(0), + ['title'], + )?.values.title).toBe(good.record.rows['title']!.val) + }) +}) diff --git a/packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json b/packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json new file mode 100644 index 0000000000..4b36243611 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json @@ -0,0 +1,136 @@ +{ + "unit": { + "name": "session_projcache", + "version": 3 + }, + "global": null, + "tables": { + "sessions": { + "session-1374fa81-15da-44ca-be12-b4c6fe8076a3": { + "identity": { + "createdAt": 1788286864454, + "cwd": "/tmp" + }, + "rows": { + "sessionStats": { + "ver": 1, + "seq": 54, + "val": { + "turns": 2, + "steps": 2, + "llmMs": 2169, + "toolMs": 0, + "ttftMs": 1872, + "ttftSteps": 2, + "decodeMs": 297, + "decodeTokens": 20, + "lastTurn": 2, + "openStep": null, + "pendingCalls": {} + } + }, + "title": { + "ver": 1, + "seq": 54, + "val": "验收标题-rc2" + }, + "goal": { + "ver": 4, + "seq": 54, + "val": null + }, + "tokenUsage": { + "ver": 1, + "seq": 54, + "val": { + "totals": { + "uncachedInputTokens": 6083, + "outputTokens": 20, + "cacheReadTokens": 9728, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 2, + "step": 1, + "buckets": { + "uncachedInputTokens": 112, + "outputTokens": 4, + "cacheReadTokens": 7808, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 54, + "val": { + "surfaceTokens": 178, + "contextWindow": 1000000, + "pressureTokens": 7920, + "sampledSurfaceTokens": 168 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 54, + "val": { + "systemTokens": 1620, + "toolsTokens": 6475, + "messageTokens": 178 + } + }, + "subagentTiming": { + "ver": 2, + "seq": 54, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 54, + "val": {} + }, + "permissions": { + "ver": 1, + "seq": 54, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask" + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 54, + "val": { + "blank": false, + "lastPromptAt": 1788286867410 + } + }, + "imageLimits": { + "ver": 1, + "seq": 54, + "val": null + }, + "todos": { + "ver": 2, + "seq": 54, + "val": null + }, + "plan": { + "ver": 2, + "seq": 54, + "val": { + "active": false, + "wanted": null, + "running": null + } + } + } + } + } + } +} diff --git a/packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json b/packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json new file mode 100644 index 0000000000..56d6868405 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json @@ -0,0 +1,212 @@ +{ + "version": 4, + "record": { + "identity": { + "createdAt": 1788286912530, + "cwd": "/tmp" + }, + "rows": { + "title": { + "ver": 1, + "seq": 68, + "val": "验收标题-alpha3" + }, + "titleInput": { + "ver": 3, + "seq": 68, + "val": { + "first": { + "seq": 7, + "text": "请只回复一个词:pong" + }, + "count": 2, + "lastSeq": 58 + } + }, + "llmRetry": { + "ver": 1, + "seq": 68, + "val": {} + }, + "sandboxMode": { + "ver": 1, + "seq": 68, + "val": "workspace-write" + }, + "goal": { + "ver": 6, + "seq": 68, + "val": { + "current": null, + "seenGoalIds": [], + "failure": null + } + }, + "tokenUsage": { + "ver": 2, + "seq": 68, + "val": { + "totals": { + "uncachedInputTokens": 8192, + "outputTokens": 35, + "cacheReadTokens": 8064, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 2, + "step": 1, + "buckets": { + "uncachedInputTokens": 86, + "outputTokens": 4, + "cacheReadTokens": 8064, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 68, + "val": { + "surfaceTokens": 193, + "contextWindow": 1000000, + "pressureTokens": 8150, + "sampledSurfaceTokens": 183 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 68, + "val": { + "systemTokens": 1760, + "toolsTokens": 6579, + "messageTokens": 193 + } + }, + "turnBoundary": { + "ver": 2, + "seq": 68, + "val": { + "openTurnStartSeq": null, + "lastStepStartSeq": 57, + "lastStepBoundary": { + "kind": "end", + "seq": 67 + }, + "lastTurn": 2 + } + }, + "sessionStats": { + "ver": 1, + "seq": 68, + "val": { + "turns": 2, + "steps": 2, + "llmMs": 3486, + "toolMs": 0, + "ttftMs": 3199, + "ttftSteps": 2, + "decodeMs": 287, + "decodeTokens": 35, + "lastTurn": 2, + "openStep": null, + "pendingCalls": {} + } + }, + "turnOutline": { + "ver": 2, + "seq": 68, + "val": { + "turns": [ + { + "turn": 1, + "seq": 4, + "prompt": "请只回复一个词:pong", + "response": "pong" + }, + { + "turn": 2, + "seq": 55, + "prompt": "请只回复一个词:pong2", + "response": "pong2" + } + ], + "draft": "" + } + }, + "agentPreset": { + "ver": 1, + "seq": 68, + "val": "standard" + }, + "subagentTiming": { + "ver": 2, + "seq": 68, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 68, + "val": {} + }, + "permissions": { + "ver": 2, + "seq": 68, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask", + "seeded": false + } + }, + "modelSelection": { + "ver": 2, + "seq": 68, + "val": { + "lastUsed": { + "provider": "deepseek-official", + "model": "deepseek-v4-flash", + "reasoningEffort": "high" + }, + "pending": null + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 68, + "val": { + "blank": false, + "lastPromptAt": 1788286917834 + } + }, + "imageLimits": { + "ver": 1, + "seq": 68, + "val": null + }, + "todos": { + "ver": 2, + "seq": 68, + "val": null + }, + "plan": { + "ver": 3, + "seq": 68, + "val": { + "active": false, + "wanted": null, + "running": null, + "activeAtLastHeader": false + } + }, + "subagentModelSelectionPolicy": { + "ver": 1, + "seq": 68, + "val": null + } + } + } +} diff --git a/packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json b/packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json new file mode 100644 index 0000000000..f887e980e2 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json @@ -0,0 +1,128 @@ +{ + "version": 5, + "record": { + "identity": { + "createdAt": 1788286864454, + "cwd": "/tmp" + }, + "rows": { + "sessionStats": { + "ver": 1, + "seq": 54, + "val": { + "turns": 2, + "steps": 2, + "llmMs": 2169, + "toolMs": 0, + "ttftMs": 1872, + "ttftSteps": 2, + "decodeMs": 297, + "decodeTokens": 20, + "lastTurn": 2, + "openStep": null, + "pendingCalls": {} + } + }, + "title": { + "ver": 1, + "seq": 54, + "val": "\u9a8c\u6536\u6807\u9898-rc2" + }, + "goal": { + "ver": 4, + "seq": 54, + "val": null + }, + "tokenUsage": { + "ver": 1, + "seq": 54, + "val": { + "totals": { + "uncachedInputTokens": 6083, + "outputTokens": 20, + "cacheReadTokens": 9728, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 2, + "step": 1, + "buckets": { + "uncachedInputTokens": 112, + "outputTokens": 4, + "cacheReadTokens": 7808, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 54, + "val": { + "surfaceTokens": 178, + "contextWindow": 1000000, + "pressureTokens": 7920, + "sampledSurfaceTokens": 168 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 54, + "val": { + "systemTokens": 1620, + "toolsTokens": 6475, + "messageTokens": 178 + } + }, + "subagentTiming": { + "ver": 2, + "seq": 54, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 54, + "val": {} + }, + "permissions": { + "ver": 1, + "seq": 54, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask" + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 54, + "val": { + "blank": false, + "lastPromptAt": 1788286867410 + } + }, + "imageLimits": { + "ver": 1, + "seq": 54, + "val": null + }, + "todos": { + "ver": 2, + "seq": 54, + "val": null + }, + "plan": { + "ver": 2, + "seq": 54, + "val": { + "active": false, + "wanted": null, + "running": null + } + } + } + } +} diff --git a/packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json b/packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json new file mode 100644 index 0000000000..9b822870a8 --- /dev/null +++ b/packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json @@ -0,0 +1,220 @@ +{ + "version": 5, + "record": { + "identity": { + "createdAt": 1788286864454, + "cwd": "/tmp", + "isSeeded": false, + "inheritedEventCount": 0 + }, + "rows": { + "title": { + "ver": 1, + "seq": 71, + "val": "验收标题-rc2" + }, + "titleInput": { + "ver": 3, + "seq": 71, + "val": { + "first": { + "seq": 7, + "text": "请只回复一个词:pong" + }, + "count": 3, + "lastSeq": 60 + } + }, + "llmRetry": { + "ver": 1, + "seq": 71, + "val": {} + }, + "sandboxMode": { + "ver": 1, + "seq": 71, + "val": "workspace-write" + }, + "goal": { + "ver": 6, + "seq": 71, + "val": { + "current": null, + "seenGoalIds": [], + "failure": null + } + }, + "tokenUsage": { + "ver": 2, + "seq": 71, + "val": { + "totals": { + "uncachedInputTokens": 14219, + "outputTokens": 24, + "cacheReadTokens": 9728, + "cacheWriteTokens": 0 + }, + "last": { + "turn": 3, + "step": 1, + "buckets": { + "uncachedInputTokens": 8136, + "outputTokens": 4, + "cacheReadTokens": 0, + "cacheWriteTokens": 0 + } + } + } + }, + "contextPressure": { + "ver": 4, + "seq": 71, + "val": { + "surfaceTokens": 200, + "contextWindow": 1000000, + "pressureTokens": 8136, + "sampledSurfaceTokens": 190 + } + }, + "contextBreakdown": { + "ver": 2, + "seq": 71, + "val": { + "systemTokens": 1729, + "toolsTokens": 6611, + "messageTokens": 200 + } + }, + "sessionStats": { + "ver": 1, + "seq": 71, + "val": { + "turns": 3, + "steps": 3, + "llmMs": 4368, + "toolMs": 0, + "ttftMs": 4030, + "ttftSteps": 3, + "decodeMs": 338, + "decodeTokens": 24, + "lastTurn": 3, + "openStep": null, + "pendingCalls": {} + } + }, + "agentPreset": { + "ver": 1, + "seq": 71, + "val": "standard" + }, + "subagentTiming": { + "ver": 2, + "seq": 71, + "val": { + "descriptorSeen": false, + "settledMs": 0 + } + }, + "subagent": { + "ver": 2, + "seq": 71, + "val": {} + }, + "turnBoundary": { + "ver": 2, + "seq": 71, + "val": { + "openTurnStartSeq": null, + "lastStepStartSeq": 59, + "lastStepBoundary": { + "kind": "end", + "seq": 70 + }, + "lastTurn": 3 + } + }, + "turnOutline": { + "ver": 2, + "seq": 71, + "val": { + "turns": [ + { + "turn": 1, + "seq": 4, + "prompt": "请只回复一个词:pong", + "response": "pong" + }, + { + "turn": 2, + "seq": 40, + "prompt": "请只回复一个词:pong2", + "response": "pong2" + }, + { + "turn": 3, + "seq": 57, + "prompt": "请只回复一个词:pong3", + "response": "pong3" + } + ], + "draft": "" + } + }, + "permissions": { + "ver": 2, + "seq": 71, + "val": { + "preset": "workspace-write", + "sandbox": "workspace-write", + "approval": "ask", + "seeded": true + } + }, + "modelSelection": { + "ver": 2, + "seq": 71, + "val": { + "lastUsed": { + "provider": "deepseek-official", + "model": "deepseek-v4-flash", + "reasoningEffort": "high" + }, + "pending": null + } + }, + "sessionListMetadata": { + "ver": 1, + "seq": 71, + "val": { + "blank": false, + "lastPromptAt": 1788287660106 + } + }, + "imageLimits": { + "ver": 1, + "seq": 71, + "val": null + }, + "todos": { + "ver": 2, + "seq": 71, + "val": null + }, + "plan": { + "ver": 3, + "seq": 71, + "val": { + "active": false, + "wanted": null, + "running": null, + "activeAtLastHeader": false + } + }, + "subagentModelSelectionPolicy": { + "ver": 1, + "seq": 71, + "val": null + } + } + } +} From bef26396e5a16b129b8f0f28b354b0ec3095b045 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:09:16 +0800 Subject: [PATCH 05/17] docs(session-projection-cache): cross-version read-compat note and schema-change fixture rule The proposed Agent Note records the three shipped on-disk generations of session_projcache, the read-compat and backup-and-skip decisions, the upgrade matrix, and the rejected alternatives. The package README documents the upgrade guarantees and requires every future schema or domain-version change to land with archived fixtures and tests proving its upgrade story. The storage subsystem page and the generated cordis catalog pick up the new DomainSpec fields. --- ...jcache-cross-version-read-compat.i18n.yaml | 6 ++ ...-02-projcache-cross-version-read-compat.md | 69 +++++++++++++++++++ ...-projcache-cross-version-read-compat.zh.md | 69 +++++++++++++++++++ docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 25 ++++++- docs/subsystems/storage.zh.md | 25 ++++++- .../extensions/tool-cordis/src/api-catalog.ts | 8 +-- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 3 + .../session-projection-cache/README.zh.md | 3 + 10 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md create mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml new file mode 100644 index 0000000000..412b47ab8f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.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/architecture/2026-09-02-projcache-cross-version-read-compat.md +2026-09-02-projcache-cross-version-read-compat.md: 9bd0cd9049ed9c9d4180f54d55f10f9b0f555dfc +2026-09-02-projcache-cross-version-read-compat.zh.md: 4682c5fe4732b21649214ee98f25d478c9bfcb18 diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md new file mode 100644 index 0000000000..9bd0cd9049 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md @@ -0,0 +1,69 @@ +# Agent Note: Projection-cache cross-version read compatibility (session_projcache v3/v4/v5) + +Status: proposed + +English | [中文](2026-09-02-projcache-cross-version-read-compat.zh.md) + +## Problem + +The `session_projcache` storage domain evolved through three on-disk generations across published releases. An upgraded DSH_HOME failed in two ways: + +- **A v3 single-file home bricked startup after the upgrade**: the per-record layout's legacy bootstrap migrated the old whole-unit file without checking its `unit.version`, stamping the old records with the current version into the new tree; the domain layer's per-record zod validation at open then hit the missing now-required fields → `invalid-record` → the whole domain refused to open → the plugin tree failed to load. And because the bootstrap writes before validation runs, **the first boot permanently wrote the bad documents into the new tree** ("poisoning") — every later boot saw a non-empty tree, never took the legacy path again, and the home stayed unusable. +- **A v4 per-record home lost its listing titles after the upgrade**: v4 documents were silently discarded by the version-stamp check (the per-record contract), and SessionList is a zero-I/O cache-only read, so a miss served the row without projections; titles only returned as each session was individually reopened. + +The cache domain's own contract is "a stale or unreadable cache costs a longer tail replay, never a wrong value, never a refused load" — the hard failure and the wholesale discard each broke the first half of that contract or the product expectation. + +## The three on-disk generations + +| domain version | shipped in | layout | on-disk form | identity fields | row fields | +|---|---|---|---|---|---| +| 3 | 0.1.1-rc.2 | single | one file `storages/session_projcache.json` (`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` | +| 4 | 0.1.2-alpha.3 | per-record | one file per session `storages/session_projcache/sessions/.json` (`{version, record}`) | `createdAt`, `cwd?` | same | +| 5 | 0.1.2-alpha.4 | per-record | same as v4 | + `isSeeded` (required → optional in this change), `inheritedEventCount` (same) | same (`seq` numbers mean the same as v4; only type brands were added) | + +The only substantive v4→v5 difference is the two new lineage identity fields; the `ver/seq/val` row shape is identical across all three generations, and `seq` numbering did not change ([the 2026-08-31 seq/offset brands note](../../implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md) pins the on-disk numbers as unchanged). v3→v4 was a layout migration with identical record content. + +One derived shape also exists: a v3 home that ran the v5 build once (the poisoned state) — its new tree holds documents **stamped 5 whose content is a v3 record** (no lineage fields). + +## Proposal + +Declared read compatibility — reads tolerate vouched-for older versions, writes always stamp the current one: + +1. **`DomainSpec.compatibleVersions` (new, optional)**: the domain owner declares "records stored under these older versions are also readable under the current record schemas" (typically by declaring the fields old records lack as optional). `defineDomain` validates each entry as a non-negative integer below the current version; `descriptorOf` projects the set onto the backend `KvUnitDescriptor`. +2. **json backend per-record reads** accept version stamps in "current ∪ compatibleVersions"; anything outside the set is still discarded as foreign. **The write path always stamps the current version** (the first checkpoint after reading an old record naturally advances it). The `single` layout stays exact-version. +3. **Legacy-bootstrap version gate (the actual bug fix)**: the old whole-unit file's `unit.version` must fall inside the accepted set to be migrated; otherwise the file is left alone and the unit reads empty — stamping records the owner never vouched for turns a discardable stale cache into hard schema failures at the domain layer. +4. **The projcache domain declares `version: 5, compatibleVersions: [3, 4]`**, and the two lineage fields become `.optional()`. The single reader of stored identities, `identityMatches`, normalizes absence to the unseeded lineage (`?? false` / `?? 0`): exact for unforked sessions, while a forked session's expectation is seeded → natural mismatch → discard and cold rebuild, so the lineage binding loses none of its protection. +5. **The poisoned state self-heals**: documents stamped 5 without lineage fields parse under the optional schema (their content is the real pre-upgrade cache data), so the home boots again and titles serve immediately. +6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). + +### Upgrade matrix + +| home shape | behavior after the fix | +|---|---| +| v3 single-file (not poisoned) | bootstrap migrates (3 ∈ accepted set) → titles serve immediately | +| v3 + poisoned new tree | new-tree documents read directly (optional tolerance) → boot restored, titles serve immediately | +| v4 per-record | documents read directly (4 ∈ accepted set) → titles serve immediately | +| v5 healthy | unaffected | +| old records of forked (seeded) sessions | identity mismatch → discarded, cold rebuild when the session opens (safe side) | + +## Alternatives considered + +- **Discard-and-rebuild only** (bootstrap gate + bump to v6): fixes the boot, but every SessionList title is lost after the upgrade until each session is reopened — fails the upgrade-and-go product requirement. +- **Schema `.default()` fills**: behaviorally equivalent to optional + reader normalization, but bakes the "absent = unseeded" interpretation into the durable schema's output type; ruled for optional — the schema honestly describes every accepted on-disk shape and the interpretation lives at the consumer (user ruling, 2026-09-02). +- **Roll the domain version back 5→4**: the smallest diff (three lines), but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache. + +## Risks + +- A deployment routing this domain to the sqlite backend gets none of the tolerance: sqlite implements neither `compatibleVersions` nor `backupRecord`, so behavior degrades to the old strict-version semantics (a whole-unit version mismatch still refuses with `version-mismatch`; nothing loosens, nothing serves wrong values). Shipped compositions route this domain to json, so this stays a deployment-configuration risk only. +- The optional lineage fields widen what a current-version document may omit: a v5-stamped record stripped of its lineage decodes as unseeded. The identity match still refuses it for seeded callers, and the per-row `ver` guard still screens every value, so the residual exposure is an unseeded caller reading an unseeded-shaped record — the same trust extended to genuine pre-lineage records. +- `backupRecord` overwrites a same-minute backup of the same key (the newer bytes win); distinct minutes and distinct keys never collide. + +## Acceptance criteria + +- `storage-json` unit tests: compat-stamped reads / out-of-set discards / writes stamping current; legacy bootstrap migrating only accepted versions (including the migrated-documents-stamp-current assertion); `backupRecord` move / absent read / rewrite / closed guard. +- `storage-domain` unit tests: `compatibleVersions` / `invalidRecords` declaration validation; backup-and-skip falling back to fail-loud when the backend has no `backupRecord`. +- `session-projection-cache` unit tests: records without lineage fields serve unseeded sessions verbatim and are discarded for seeded ones. +- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (current), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned shape, synthesized from the v3 record) — each opened through the real storage stack, asserting the listing serves the archived title and that a live write rewrites the document to the current version (v5 stamp + lineage fields + fresh value); plus the backup-and-skip behavior for a schema-failing record (boot survives, `.bak` lands, log is concrete, neighbor records unharmed). +- End-to-end acceptance: `scripts/releasefix/` (real old release artifacts building the v3 / v4 / poisoned homes; the SessionList RPC asserts titles restored verbatim). + +Future bump procedure: when a new version's shape can tolerate old records through "optional fields + reader normalization", add the old version to `compatibleVersions`; otherwise bump normally (discard and rebuild) and remove the no-longer-compatible versions from the set. Either way, the package README requires the bump to land with archived fixtures and tests proving the chosen disposition. diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md new file mode 100644 index 0000000000..4682c5fe47 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md @@ -0,0 +1,69 @@ +# Agent Note: 投影缓存跨版本读兼容(session_projcache v3/v4/v5) + +Status: proposed + +[English](2026-09-02-projcache-cross-version-read-compat.md) | 中文 + +## 问题 + +`session_projcache` 存储域在已发布版本间演进了三代磁盘结构。升级后的 DSH_HOME 出现两类故障: + +- **v3 单文件 home 升级后启动硬失败**:per-record 布局的 legacy bootstrap 迁移旧单文件时不检查其 `unit.version`,把旧记录原样打上当前版本戳写入新树;domain 层开域时逐条 zod 校验,旧记录缺新增必填字段 → `invalid-record` → 整个域拒开 → 插件树加载失败。且 bootstrap 先写盘后校验,**首次启动即把坏文档永久写入新树**("投毒")——此后每次启动新树非空、连 legacy 路径都不再走,home 持续不可用。 +- **v4 per-record home 升级后列表丢标题**:v4 文档被版本戳检查静默丢弃(per-record 契约),SessionList 是零 I/O 纯缓存读,miss 后整行不带投影;标题要等每个会话被逐个重新打开后才恢复。 + +缓存域自身的契约是"过期或不可读的缓存只付出更长的尾部重放,绝不给出错值、绝不拒载"——硬失败与整体丢弃都违背该契约的前半句或产品预期。 + +## 三代磁盘结构差异 + +| domain version | 携带发布 | 布局 | 磁盘形态 | identity 字段 | 行字段 | +|---|---|---|---|---|---| +| 3 | 0.1.1-rc.2 | single | 单文件 `storages/session_projcache.json`(`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` | +| 4 | 0.1.2-alpha.3 | per-record | 每会话一份 `storages/session_projcache/sessions/.json`(`{version, record}`) | `createdAt`, `cwd?` | 同上 | +| 5 | 0.1.2-alpha.4 | per-record | 同 v4 | + `isSeeded`(必填→本次改 optional)、`inheritedEventCount`(同) | 同上(`seq` 数值语义与 v4 相同,仅类型加 brand) | + +v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `ver/seq/val` 三代一致,`seq` 的数值含义未变([2026-08-31 seq/offset brands note](../../implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md) 明确 on-disk 数值不变)。v3→v4 是布局迁移,记录内容结构一致。 + +另有一种衍生形态:跑过一次 v5 版本的 v3 home(投毒态)——新树里存在**版本戳为 5 但内容是 v3 记录**(缺 lineage 字段)的文档。 + +## 提案 + +声明式读兼容——读容忍 owner 背书过的旧版本,写恒戳当前版本: + +1. **`DomainSpec.compatibleVersions`(新增,可选)**:域 owner 声明"这些旧版本的存量记录在当前记录 schema 下也可读"(典型手段:新增字段标 optional)。`defineDomain` 校验各项为小于当前 version 的非负整数;`descriptorOf` 透传到后端 `KvUnitDescriptor`。 +2. **json 后端 per-record 读**:接受"当前版本 ∪ compatibleVersions"内的版本戳,集合外照旧视为 foreign 丢弃;**写路径永远戳当前版本**(读到旧记录后的下一次 checkpoint 自然把它推进到当前版本)。single 布局维持 exact-version 不变。 +3. **legacy bootstrap 版本把关(bug 修复本体)**:旧单文件的 `unit.version` 必须落在接受集合内才迁移,否则视为空 unit 留在原地——为 owner 未背书的记录打当前版本戳,会把"可丢弃的过期缓存"变成 domain 层的 schema 硬失败。 +4. **projcache 域声明 `version: 5, compatibleVersions: [3, 4]`**;两个 lineage 字段改为 `.optional()`。唯一消费 stored identity 的读点 `identityMatches` 把缺失归一化为 unseeded lineage(`?? false` / `?? 0`):对非 fork 会话这是精确值;fork 会话的 expected 是 seeded → 天然 mismatch → 丢弃冷读重建,lineage 绑定的防护不放松。 +5. **投毒态自愈**:v5 戳缺 lineage 字段的文档被 optional schema 直接接受(内容本就是升级前的真实缓存数据),home 恢复可启动且标题立即可服务。 +6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。 + +### 升级矩阵 + +| home 形态 | 修复后行为 | +|---|---| +| v3 单文件(未投毒) | bootstrap 迁移(3 ∈ 接受集)→ 标题立即可服务 | +| v3 + 投毒新树 | 新树文档直接读入(optional 容忍)→ 启动恢复、标题立即可服务 | +| v4 per-record | 文档直接读入(4 ∈ 接受集)→ 标题立即可服务 | +| v5 正常 | 不受影响 | +| fork(seeded)会话的旧记录 | identity mismatch → 丢弃,打开会话时冷读重建(安全侧) | + +## 备选方案 + +- **只丢弃重建**(bootstrap 把关 + bump v6):启动可修,但升级后 SessionList 标题全丢、要逐会话打开才恢复——不满足升级即用的产品要求。 +- **schema `.default()` 填缺省**:行为与 optional+读点归一化等价,但把"缺失=unseeded"的解释固化进 durable schema 的输出类型;拍板为 optional——schema 如实描述介质上所有被接受的形态,解释权在消费点(2026-09-02 用户裁决)。 +- **域版本回退 5→4**:改动最小(三行),但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。 + +## 风险 + +- 部署方若把本域路由到 sqlite 后端,得不到任何容忍能力:sqlite 既未实现 `compatibleVersions` 也没有 `backupRecord`,行为退化为原有的严格版本语义(整 unit 版本不匹配仍 `version-mismatch` 拒开;不放松、不出错值)。shipped 组合固定路由 json,此风险仅存在于部署配置层面。 +- optional lineage 字段放宽了当前版本文档可缺省的范围:被剥离 lineage 的 v5 戳记录会解码为 unseeded。身份比对仍会对 seeded 调用方拒收,逐行 `ver` 守卫仍筛查每个值,残余暴露面只是 unseeded 调用方读到 unseeded 形态的记录——与真实 pre-lineage 记录享有的信任完全相同。 +- `backupRecord` 对同一键的同一分钟内重复备份会覆盖前一份(新字节胜出);不同分钟、不同键永不冲突。 + +## 验收标准 + +- `storage-json` 单测:compat 版本戳读入/集合外丢弃/写恒当前版本;legacy bootstrap 仅在版本被接受时迁移(含迁移后文档戳当前版本断言);`backupRecord` 移档/读缺席/重写/封闭守卫。 +- `storage-domain` 单测:`compatibleVersions`/`invalidRecords` 声明校验;后端无 `backupRecord` 时 backup-and-skip 回退 fail-loud。 +- `session-projection-cache` 单测:缺 lineage 字段的记录对 unseeded 会话按原值服务、对 seeded 会话丢弃。 +- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(当前版)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域,断言列表读出归档标题、且 live 写把文档重写为当前版本(v5 戳 + lineage 字段 + 新值);外加 schema 失败记录的 backup-and-skip 行为(启动不失败、`.bak` 落盘、日志具体、邻居记录不受累)。 +- 端到端验收:`scripts/releasefix/`(真实老版本发布包构造 v3/v4/投毒三态 home,SessionList RPC 断言标题原样恢复)。 + +未来 bump 流程:新版本结构若可用"optional 字段 + 读点归一化"容忍旧记录,就把旧版本加入 `compatibleVersions`;否则正常 bump(丢弃重建),并把不再兼容的版本从集合中移除。无论哪条路,包 README 都要求 bump 随附归档 fixture 和论证所选处置方式的测试。 diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 1e9a7e221c..d234297a9d 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/storage.md -storage.md: 1e4141e6ef1c6f8e1c2593e21e788b626d6b1ed7 -storage.zh.md: f0433c600674741c3de0ce3e99430297839ce124 +storage.md: e52f6d8869ee3092a5be99f0bd2873b410b45ed9 +storage.zh.md: f752862af9f10cfb32f39f2a1aa7801c922f841e diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 1e4141e6ef..e52f6d8869 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -65,6 +65,26 @@ interface DomainSpec { * (a stale record document is discarded, never migrated). */ readonly layout?: 'single' | 'per-record' + /** + * Older domain versions whose stored records the current record schemas + * also accept (the declaring owner vouches for that, typically by + * declaring the fields older records lack as optional). `per-record` backends + * read documents stamped with a listed version instead of discarding them, + * and accept a legacy whole-unit file so stamped for the one-time + * bootstrap; writes always stamp {@link version}. + */ + readonly compatibleVersions?: readonly number[] + /** + * What `open` does with a stored table record that fails its zod schema. + * Absent (the default), the whole open rejects with `invalid-record` — + * right for authoritative data. `'backup-and-skip'` is for domains whose + * records are disposable derived data: the backend moves the record's + * document aside (`KvUnit.backupRecord`), the failure is logged with + * its cause, and the open continues with the record absent. A backend + * without `backupRecord` (no per-record document to move) falls back + * to the rejecting default. The global slot always rejects. + */ + readonly invalidRecords?: 'backup-and-skip' /** Optional global singleton slot. */ readonly global?: DomainGlobalSpec /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */ @@ -180,7 +200,10 @@ The mounted domain facility. Opens declared domains over routed backends; one fa * (`facet-unsupported`); open the unit projected from the spec (backend * `version-mismatch`/`malformed-medium` pass through); load and validate * every stored record against the spec's zod schemas (`invalid-record` - * with the offending table and key); construct the domain. + * with the offending table and key — unless the spec declares + * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in + * which case the failing record is backed up, logged, and skipped); + * construct the domain. * * Lifecycle: the CALLER owns the returned handle and closes it via * `Domain.close()` (typically as its own `ctx.effect` disposer) — the diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index f0433c6006..f752862af9 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -65,6 +65,26 @@ interface DomainSpec { * (a stale record document is discarded, never migrated). */ readonly layout?: 'single' | 'per-record' + /** + * Older domain versions whose stored records the current record schemas + * also accept (the declaring owner vouches for that, typically by + * declaring the fields older records lack as optional). `per-record` backends + * read documents stamped with a listed version instead of discarding them, + * and accept a legacy whole-unit file so stamped for the one-time + * bootstrap; writes always stamp {@link version}. + */ + readonly compatibleVersions?: readonly number[] + /** + * What `open` does with a stored table record that fails its zod schema. + * Absent (the default), the whole open rejects with `invalid-record` — + * right for authoritative data. `'backup-and-skip'` is for domains whose + * records are disposable derived data: the backend moves the record's + * document aside (`KvUnit.backupRecord`), the failure is logged with + * its cause, and the open continues with the record absent. A backend + * without `backupRecord` (no per-record document to move) falls back + * to the rejecting default. The global slot always rejects. + */ + readonly invalidRecords?: 'backup-and-skip' /** Optional global singleton slot. */ readonly global?: DomainGlobalSpec /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */ @@ -180,7 +200,10 @@ The mounted domain facility. Opens declared domains over routed backends; one fa * (`facet-unsupported`); open the unit projected from the spec (backend * `version-mismatch`/`malformed-medium` pass through); load and validate * every stored record against the spec's zod schemas (`invalid-record` - * with the offending table and key); construct the domain. + * with the offending table and key — unless the spec declares + * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in + * which case the failing record is backed up, logged, and skipped); + * construct the domain. * * Lifecycle: the CALLER owns the returned handle and closes it via * `Domain.close()` (typically as its own `ctx.effect` disposer) — the diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 55761bf933..3c81eb2b3b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2151,7 +2151,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async open(spec: S): Promise>', - description: 'Open one declared domain. Steps, each failing the whole call: reject a name that is already open (`already-open`); resolve the backend route (`backend-not-found` passes through from the hub); require its `kv` facet (`facet-unsupported`); open the unit projected from the spec (backend `version-mismatch`/`malformed-medium` pass through); load and validate every stored record against the spec\'s zod schemas (`invalid-record` with the offending table and key); construct the domain.\n\nLifecycle: the CALLER owns the returned handle and closes it via `Domain.close()` (typically as its own `ctx.effect` disposer) — the facility does not tie the domain to any consumer fiber. Domains still open when the facility unmounts are closed by the plugin disposer.', + description: 'Open one declared domain. Steps, each failing the whole call: reject a name that is already open (`already-open`); resolve the backend route (`backend-not-found` passes through from the hub); require its `kv` facet (`facet-unsupported`); open the unit projected from the spec (backend `version-mismatch`/`malformed-medium` pass through); load and validate every stored record against the spec\'s zod schemas (`invalid-record` with the offending table and key — unless the spec declares `invalidRecords: \'backup-and-skip\'` and the unit can move documents aside, in which case the failing record is backed up, logged, and skipped); construct the domain.\n\nLifecycle: the CALLER owns the returned handle and closes it via `Domain.close()` (typically as its own `ctx.effect` disposer) — the facility does not tie the domain to any consumer fiber. Domains still open when the facility unmounts are closed by the plugin disposer.', parameters: [{ name: 'spec', description: 'The domain declaration, typically from `defineDomain`.' }], returns: 'the opened domain handle, typed by the spec.', }, @@ -3956,7 +3956,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DomainSpec', - declaration: 'export interface DomainSpec {\n readonly name: string;\n readonly version: number;\n readonly layout?: \'single\' | \'per-record\';\n readonly global?: DomainGlobalSpec;\n readonly tables: Record;\n}', + declaration: 'export interface DomainSpec {\n readonly name: string;\n readonly version: number;\n readonly layout?: \'single\' | \'per-record\';\n readonly compatibleVersions?: readonly number[];\n readonly invalidRecords?: \'backup-and-skip\';\n readonly global?: DomainGlobalSpec;\n readonly tables: Record;\n}', }, { name: 'DomainTableSpec', @@ -4264,11 +4264,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'KvUnit', - declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', + declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n backupRecord?(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', }, { name: 'KvUnitDescriptor', - declaration: 'export interface KvUnitDescriptor {\n readonly name: string;\n readonly version: number;\n readonly tables: readonly string[];\n readonly hasGlobal: boolean;\n readonly layout?: \'single\' | \'per-record\';\n}', + declaration: 'export interface KvUnitDescriptor {\n readonly name: string;\n readonly version: number;\n readonly tables: readonly string[];\n readonly hasGlobal: boolean;\n readonly layout?: \'single\' | \'per-record\';\n readonly compatibleVersions?: readonly number[];\n}', }, { name: 'LlmAdapter', diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index e4fe8490a2..4dd1489d4e 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/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/session-projection-cache/README.md -README.md: 51b9d86724af96304cf09a5c7c1b61b7394d336a -README.zh.md: bb84b67bdde678884fd4f2be1b14b2161da8c2a0 +README.md: 9fd9766d75ab3f9a460b2802ac59810839f21ae4 +README.zh.md: 98725d9ce76ab44821adf3d43e3807932cf6b675 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 51b9d86724..9fd9766d75 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -64,6 +64,8 @@ Three mandatory points always write: session creation persists the seed-derived The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row initialized under one fork cut cannot seed another. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. +Upgrades never cost the boot or the listing: records stamped with a version in the spec's `compatibleVersions` stay readable (their absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold), and a stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. + ----- @@ -126,6 +128,7 @@ These limits define where the cache needs operational care. They are current pac - **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself. - **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window. - **No cache-side cold refold** — the cache serves and refreshes its rows but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself. +- **Every schema or domain-version change must prove its upgrade story** — a change to the stored record schema or the domain version lands in the same PR with an archived fixture of the previously shipped on-disk format under `tests/fixtures/` and test cases in `tests/fixtures.spec.ts` proving the chosen disposition: read-compat recovery (`compatibleVersions`), current-version rewrite, or backup-and-skip salvage. A bump whose old records are simply discarded still proves that the discard neither fails the boot nor poisons the tree. ### Dev Note diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index bb84b67bdd..98725d9ce7 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -64,6 +64,8 @@ kind: "package-reference" 日志领先,缓存跟随:实时检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配实时单元 schema 与完整生命周期身份(`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此在一个 fork 切点下初始化的行不能播种另一个切点。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 +升级绝不拖垮启动或列表:版本戳落在 spec `compatibleVersions` 集合内的记录保持可读(缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠),而仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 + ----- @@ -126,6 +128,7 @@ kind: "package-reference" - **无淘汰或保留接口**——记录按会话持续累积;清理已存储检查点属于带外维护,与会话持久化采用相同策略。 - **间隔节流采用按会话的粗粒度控制**——一次无脏数据的写入完成后,计时器在首个脏事件到达时启动;持续但低于条数阈值的事件流每间隔写入一次,而非滑动窗口。 - **缓存侧不做冷重折叠**——缓存只服务并刷新自己的记录,从不读取会话日志,因为它不依赖持久化层;需要保证冷快照的消费方自行从日志重新折叠。 +- **每次 schema 或域版本变更都必须论证升级路径**——改动存储记录 schema 或域版本时,同一 PR 必须在 `tests/fixtures/` 下归档此前已发布的磁盘格式样本,并在 `tests/fixtures.spec.ts` 中用测试论证所选的处置方式:读兼容恢复(`compatibleVersions`)、当前版本重写,或 backup-and-skip 抢救。即便选择直接丢弃旧记录的 bump,也要证明丢弃既不炸启动、也不污染缓存树。 ### 开发备注 From db2dd2f840a3a82f3a5d4f1193aa5a7a61c12f51 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:42:35 +0800 Subject: [PATCH 06/17] docs(session-projection-cache): land the read-compat note as implemented and state fixture provenance in place Review follow-ups: the Agent Note triplet moves to implemented/ rewritten as shipped state (Decision/Consequences/Testing, present tense), cross-linked both ways with the 2026-07-28 storage recovery proposal whose projcache reset/destroy path it supersedes (that proposal stays live for authoritative and whole-medium damage). The fixtures spec header and the note state the fixture provenance as recorded facts of the released builds instead of citing local tooling, and the spec JSDoc points at the note's final home. --- ...projcache-cross-version-read-compat.i18n.yaml | 6 ++++++ ...-09-02-projcache-cross-version-read-compat.md | 16 ++++++++-------- ...-02-projcache-cross-version-read-compat.zh.md | 16 ++++++++-------- ...ge-root-and-derived-medium-recovery.i18n.yaml | 4 ++-- ...8-storage-root-and-derived-medium-recovery.md | 2 +- ...torage-root-and-derived-medium-recovery.zh.md | 2 +- ...projcache-cross-version-read-compat.i18n.yaml | 6 ------ .../session/session-projection-cache/src/spec.ts | 2 +- .../tests/fixtures.spec.ts | 16 +++++++++------- 9 files changed, 36 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-09-02-projcache-cross-version-read-compat.md (88%) rename .agents/notes/{proposed => implemented}/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md (87%) delete mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml new file mode 100644 index 0000000000..78edca4d40 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.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-09-02-projcache-cross-version-read-compat.md +2026-09-02-projcache-cross-version-read-compat.md: 64b52ba482e5850e1d0c3d473ca87ba7822af67f +2026-09-02-projcache-cross-version-read-compat.zh.md: c8adad9fd2b7184418a4da44e8b2e5d1f31feed7 diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md similarity index 88% rename from .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md rename to .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md index 9bd0cd9049..64b52ba482 100644 --- a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md @@ -1,6 +1,6 @@ # Agent Note: Projection-cache cross-version read compatibility (session_projcache v3/v4/v5) -Status: proposed +Status: implemented English | [中文](2026-09-02-projcache-cross-version-read-compat.zh.md) @@ -19,13 +19,13 @@ The cache domain's own contract is "a stale or unreadable cache costs a longer t |---|---|---|---|---|---| | 3 | 0.1.1-rc.2 | single | one file `storages/session_projcache.json` (`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` | | 4 | 0.1.2-alpha.3 | per-record | one file per session `storages/session_projcache/sessions/.json` (`{version, record}`) | `createdAt`, `cwd?` | same | -| 5 | 0.1.2-alpha.4 | per-record | same as v4 | + `isSeeded` (required → optional in this change), `inheritedEventCount` (same) | same (`seq` numbers mean the same as v4; only type brands were added) | +| 5 | 0.1.2-alpha.4 | per-record | same as v4 | + `isSeeded` (shipped required; now optional), `inheritedEventCount` (same) | same (`seq` numbers mean the same as v4; only type brands were added) | -The only substantive v4→v5 difference is the two new lineage identity fields; the `ver/seq/val` row shape is identical across all three generations, and `seq` numbering did not change ([the 2026-08-31 seq/offset brands note](../../implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md) pins the on-disk numbers as unchanged). v3→v4 was a layout migration with identical record content. +The only substantive v4→v5 difference is the two new lineage identity fields; the `ver/seq/val` row shape is identical across all three generations, and `seq` numbering did not change ([the 2026-08-31 seq/offset brands note](2026-08-31-session-sequence-and-log-offset-brands.md) pins the on-disk numbers as unchanged). v3→v4 was a layout migration with identical record content. One derived shape also exists: a v3 home that ran the v5 build once (the poisoned state) — its new tree holds documents **stamped 5 whose content is a v3 record** (no lineage fields). -## Proposal +## Decision Declared read compatibility — reads tolerate vouched-for older versions, writes always stamp the current one: @@ -34,7 +34,7 @@ Declared read compatibility — reads tolerate vouched-for older versions, write 3. **Legacy-bootstrap version gate (the actual bug fix)**: the old whole-unit file's `unit.version` must fall inside the accepted set to be migrated; otherwise the file is left alone and the unit reads empty — stamping records the owner never vouched for turns a discardable stale cache into hard schema failures at the domain layer. 4. **The projcache domain declares `version: 5, compatibleVersions: [3, 4]`**, and the two lineage fields become `.optional()`. The single reader of stored identities, `identityMatches`, normalizes absence to the unseeded lineage (`?? false` / `?? 0`): exact for unforked sessions, while a forked session's expectation is seeded → natural mismatch → discard and cold rebuild, so the lineage binding loses none of its protection. 5. **The poisoned state self-heals**: documents stamped 5 without lineage fields parse under the optional schema (their content is the real pre-upgrade cache data), so the home boots again and titles serve immediately. -6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). +6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage. ### Upgrade matrix @@ -52,18 +52,18 @@ Declared read compatibility — reads tolerate vouched-for older versions, write - **Schema `.default()` fills**: behaviorally equivalent to optional + reader normalization, but bakes the "absent = unseeded" interpretation into the durable schema's output type; ruled for optional — the schema honestly describes every accepted on-disk shape and the interpretation lives at the consumer (user ruling, 2026-09-02). - **Roll the domain version back 5→4**: the smallest diff (three lines), but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache. -## Risks +## Consequences - A deployment routing this domain to the sqlite backend gets none of the tolerance: sqlite implements neither `compatibleVersions` nor `backupRecord`, so behavior degrades to the old strict-version semantics (a whole-unit version mismatch still refuses with `version-mismatch`; nothing loosens, nothing serves wrong values). Shipped compositions route this domain to json, so this stays a deployment-configuration risk only. - The optional lineage fields widen what a current-version document may omit: a v5-stamped record stripped of its lineage decodes as unseeded. The identity match still refuses it for seeded callers, and the per-row `ver` guard still screens every value, so the residual exposure is an unseeded caller reading an unseeded-shaped record — the same trust extended to genuine pre-lineage records. - `backupRecord` overwrites a same-minute backup of the same key (the newer bytes win); distinct minutes and distinct keys never collide. -## Acceptance criteria +## Testing - `storage-json` unit tests: compat-stamped reads / out-of-set discards / writes stamping current; legacy bootstrap migrating only accepted versions (including the migrated-documents-stamp-current assertion); `backupRecord` move / absent read / rewrite / closed guard. - `storage-domain` unit tests: `compatibleVersions` / `invalidRecords` declaration validation; backup-and-skip falling back to fail-loud when the backend has no `backupRecord`. - `session-projection-cache` unit tests: records without lineage fields serve unseeded sessions verbatim and are discarded for seeded ones. - **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (current), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned shape, synthesized from the v3 record) — each opened through the real storage stack, asserting the listing serves the archived title and that a live write rewrites the document to the current version (v5 stamp + lineage fields + fresh value); plus the backup-and-skip behavior for a schema-failing record (boot survives, `.bak` lands, log is concrete, neighbor records unharmed). -- End-to-end acceptance: `scripts/releasefix/` (real old release artifacts building the v3 / v4 / poisoned homes; the SessionList RPC asserts titles restored verbatim). +- End-to-end acceptance, executed against the real release artifacts: the published 0.1.1-rc.2 and 0.1.2-alpha.3 npm builds seeded homes through their own web apps (model turns plus a rename RPC), the published 0.1.2-alpha.4 build reproduced both failures (including the poisoned tree), and the fixed build served every home shape — pristine v3, poisoned v3, v4, and fresh — with the SessionList RPC returning the recorded titles verbatim. Future bump procedure: when a new version's shape can tolerate old records through "optional fields + reader normalization", add the old version to `compatibleVersions`; otherwise bump normally (discard and rebuild) and remove the no-longer-compatible versions from the set. Either way, the package README requires the bump to land with archived fixtures and tests proving the chosen disposition. diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md similarity index 87% rename from .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md rename to .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md index 4682c5fe47..c8adad9fd2 100644 --- a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md @@ -1,6 +1,6 @@ # Agent Note: 投影缓存跨版本读兼容(session_projcache v3/v4/v5) -Status: proposed +Status: implemented [English](2026-09-02-projcache-cross-version-read-compat.md) | 中文 @@ -19,13 +19,13 @@ Status: proposed |---|---|---|---|---|---| | 3 | 0.1.1-rc.2 | single | 单文件 `storages/session_projcache.json`(`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` | | 4 | 0.1.2-alpha.3 | per-record | 每会话一份 `storages/session_projcache/sessions/.json`(`{version, record}`) | `createdAt`, `cwd?` | 同上 | -| 5 | 0.1.2-alpha.4 | per-record | 同 v4 | + `isSeeded`(必填→本次改 optional)、`inheritedEventCount`(同) | 同上(`seq` 数值语义与 v4 相同,仅类型加 brand) | +| 5 | 0.1.2-alpha.4 | per-record | 同 v4 | + `isSeeded`(v5 首发必填;现为 optional)、`inheritedEventCount`(同前) | 同上(`seq` 数值语义与 v4 相同,仅类型加 brand) | -v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `ver/seq/val` 三代一致,`seq` 的数值含义未变([2026-08-31 seq/offset brands note](../../implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md) 明确 on-disk 数值不变)。v3→v4 是布局迁移,记录内容结构一致。 +v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `ver/seq/val` 三代一致,`seq` 的数值含义未变([2026-08-31 seq/offset brands note](2026-08-31-session-sequence-and-log-offset-brands.zh.md) 明确 on-disk 数值不变)。v3→v4 是布局迁移,记录内容结构一致。 另有一种衍生形态:跑过一次 v5 版本的 v3 home(投毒态)——新树里存在**版本戳为 5 但内容是 v3 记录**(缺 lineage 字段)的文档。 -## 提案 +## 决策 声明式读兼容——读容忍 owner 背书过的旧版本,写恒戳当前版本: @@ -34,7 +34,7 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 ` 3. **legacy bootstrap 版本把关(bug 修复本体)**:旧单文件的 `unit.version` 必须落在接受集合内才迁移,否则视为空 unit 留在原地——为 owner 未背书的记录打当前版本戳,会把"可丢弃的过期缓存"变成 domain 层的 schema 硬失败。 4. **projcache 域声明 `version: 5, compatibleVersions: [3, 4]`**;两个 lineage 字段改为 `.optional()`。唯一消费 stored identity 的读点 `identityMatches` 把缺失归一化为 unseeded lineage(`?? false` / `?? 0`):对非 fork 会话这是精确值;fork 会话的 expected 是 seeded → 天然 mismatch → 丢弃冷读重建,lineage 绑定的防护不放松。 5. **投毒态自愈**:v5 戳缺 lineage 字段的文档被 optional schema 直接接受(内容本就是升级前的真实缓存数据),home 恢复可启动且标题立即可服务。 -6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。 +6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。 ### 升级矩阵 @@ -52,18 +52,18 @@ v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 ` - **schema `.default()` 填缺省**:行为与 optional+读点归一化等价,但把"缺失=unseeded"的解释固化进 durable schema 的输出类型;拍板为 optional——schema 如实描述介质上所有被接受的形态,解释权在消费点(2026-09-02 用户裁决)。 - **域版本回退 5→4**:改动最小(三行),但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。 -## 风险 +## 影响 - 部署方若把本域路由到 sqlite 后端,得不到任何容忍能力:sqlite 既未实现 `compatibleVersions` 也没有 `backupRecord`,行为退化为原有的严格版本语义(整 unit 版本不匹配仍 `version-mismatch` 拒开;不放松、不出错值)。shipped 组合固定路由 json,此风险仅存在于部署配置层面。 - optional lineage 字段放宽了当前版本文档可缺省的范围:被剥离 lineage 的 v5 戳记录会解码为 unseeded。身份比对仍会对 seeded 调用方拒收,逐行 `ver` 守卫仍筛查每个值,残余暴露面只是 unseeded 调用方读到 unseeded 形态的记录——与真实 pre-lineage 记录享有的信任完全相同。 - `backupRecord` 对同一键的同一分钟内重复备份会覆盖前一份(新字节胜出);不同分钟、不同键永不冲突。 -## 验收标准 +## 测试 - `storage-json` 单测:compat 版本戳读入/集合外丢弃/写恒当前版本;legacy bootstrap 仅在版本被接受时迁移(含迁移后文档戳当前版本断言);`backupRecord` 移档/读缺席/重写/封闭守卫。 - `storage-domain` 单测:`compatibleVersions`/`invalidRecords` 声明校验;后端无 `backupRecord` 时 backup-and-skip 回退 fail-loud。 - `session-projection-cache` 单测:缺 lineage 字段的记录对 unseeded 会话按原值服务、对 seeded 会话丢弃。 - **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(当前版)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域,断言列表读出归档标题、且 live 写把文档重写为当前版本(v5 戳 + lineage 字段 + 新值);外加 schema 失败记录的 backup-and-skip 行为(启动不失败、`.bak` 落盘、日志具体、邻居记录不受累)。 -- 端到端验收:`scripts/releasefix/`(真实老版本发布包构造 v3/v4/投毒三态 home,SessionList RPC 断言标题原样恢复)。 +- 端到端验收,以真实发布物执行:已发布的 0.1.1-rc.2 与 0.1.2-alpha.3 npm 包经各自 web app 造数(真实模型对话 + rename RPC),已发布的 0.1.2-alpha.4 包复现两类故障(含投毒树),修复后构建对纯净 v3、投毒 v3、v4、全新四种 home 形态经 SessionList RPC 原样返回记录在案的标题。 未来 bump 流程:新版本结构若可用"optional 字段 + 读点归一化"容忍旧记录,就把旧版本加入 `compatibleVersions`;否则正常 bump(丢弃重建),并把不再兼容的版本从集合中移除。无论哪条路,包 README 都要求 bump 随附归档 fixture 和论证所选处置方式的测试。 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 ea0fe4b44b..96ea4961d6 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: 1505be1c58d5cf829327b2919113bb2e42798ce7 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 1bab5ab663df1419cc826c3d6acb59bd8bff7de0 +2026-07-28-storage-root-and-derived-medium-recovery.md: 68a6792c38a8fe097558de3d5a857ab4ea0d3533 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 0c712b3b928f4b3df44e706b154c3a90a784403c diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index 1505be1c58..68a6792c38 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -10,7 +10,7 @@ The persisted projection cache ([note](2026-07-27-session-projection-and-command **Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session/session-persistence-jsonl/src/index.ts). -**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. Partially superseded for the projection cache: [the per-session cache files note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.md) removed the global `session_projcache` domain, so the cache half of this proposal (recovery on that domain) no longer applies; the `workspace.json` half remains current. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. +**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. Partially superseded for the projection cache: [the per-session cache files note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.md) removed the global `session_projcache` domain, so the cache half of this proposal (recovery on that domain) no longer applies; the `workspace.json` half remains current. The `invalid-record` class for the per-record projection cache is now also superseded: the shipped domain declares `invalidRecords: 'backup-and-skip'` ([cross-version read-compat note](../../implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md)), which backs the failing record up and skips it at open, so the reset/destroy proposal below stays relevant only for whole-medium damage on authoritative or single-document media. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. ## Proposal 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 1bab5ab663..0c712b3b92 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 @@ -10,7 +10,7 @@ Status: proposed **文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session/session-persistence-jsonl/src/index.ts)。 -**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`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")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 +**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`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")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。per-record 投影缓存的 `invalid-record` 一类如今也已被取代:已发布的域声明了 `invalidRecords: 'backup-and-skip'`([跨版本读兼容 note](../../implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md)),open 时把失败记录备份后跳过,因此下文 reset/destroy 提案仅对权威介质或单文档介质的整介质损坏仍然相关。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 ## 提案 diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml deleted file mode 100644 index 412b47ab8f..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.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 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md -2026-09-02-projcache-cross-version-read-compat.md: 9bd0cd9049ed9c9d4180f54d55f10f9b0f555dfc -2026-09-02-projcache-cross-version-read-compat.zh.md: 4682c5fe4732b21649214ee98f25d478c9bfcb18 diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index d368764244..1830853f6c 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -81,7 +81,7 @@ export type CheckpointRecord = z.infer * their cached listing projections instead of dropping every title until * each session is reopened; the per-record version map lives in the * read-compat Agent Note - * (.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md). + * (.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md). * The per-row `ver` guard and the identity match still discard anything the * current fold semantics cannot vouch for. * diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index a85bcef5c6..e2486b7a9d 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -1,12 +1,14 @@ /** * Cross-version recovery over archived on-disk artifacts. `fixtures/` holds - * real `session_projcache` media produced by released builds through their - * web apps (see scripts/releasefix): the v3 whole-unit file, a v4 per-record - * document, a current v5 document, and the v5-stamped lineage-less document - * that the formerly unguarded legacy bootstrap wrote over v3 records. Each - * must recover through the real storage stack — the domain opens and the - * listing read serves the archived title — and a record that fails schema - * validation anyway is backed up and skipped instead of failing the boot. + * real `session_projcache` media, each produced by driving the named release + * through its own web app (session created over RPC, real model turns, a + * rename): the v3 whole-unit file (published 0.1.1-rc.2), a v4 per-record + * document (published 0.1.2-alpha.3), a current v5 document, and the + * v5-stamped lineage-less document reproducing byte-for-byte what the + * formerly unguarded legacy bootstrap wrote over v3 records. Each must + * recover through the real storage stack — the domain opens and the listing + * read serves the archived title — and a record that fails schema validation + * anyway is backed up and skipped instead of failing the boot. */ import { afterEach, describe, expect, it, vi } from 'vitest' From 39b151bda38254caf089e4f527fe17d458198f51 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 2 Sep 2026 15:43:30 +0800 Subject: [PATCH 07/17] fix(issue-management): use Project-local Priority --- .agents/notes/archived/manifest.json | 3 + ...8-31-pr-opened-issue-start-dates.i18n.yaml | 4 +- .../2026-08-31-pr-opened-issue-start-dates.md | 1 + ...26-08-31-pr-opened-issue-start-dates.zh.md | 1 + ...-event-directed-pr-review-status.i18n.yaml | 4 +- ...6-08-10-event-directed-pr-review-status.md | 2 +- ...8-10-event-directed-pr-review-status.zh.md | 2 +- ...ject-local-issue-planning-fields.i18n.yaml | 6 ++ ...-02-project-local-issue-planning-fields.md | 41 ++++++++++ ...-project-local-issue-planning-fields.zh.md | 41 ++++++++++ .github/issue-management/policy.mjs | 56 ++++++++++---- .github/issue-management/policy.test.mjs | 75 +++++++++++++++++++ 12 files changed, 215 insertions(+), 21 deletions(-) rename .agents/notes/{implemented => archived}/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/process/2026-08-31-pr-opened-issue-start-dates.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-31-pr-opened-issue-start-dates.zh.md (99%) create mode 100644 .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md create mode 100644 .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index fa3abbea4f..e2bf423d56 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -391,6 +391,9 @@ "process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730", "process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b", "process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18", + "process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml": "sha256:c522daca5e126bf64227d6259f447648a589831125113dcf49814817d4b28f17", + "process/2026-08-31-pr-opened-issue-start-dates.md": "sha256:749f343576006b0d4950b9c67f434cea0bb9ae21a5fd8d74fefe77b59289ffbf", + "process/2026-08-31-pr-opened-issue-start-dates.zh.md": "sha256:a227dc6cba6e62d08a15b1c6b69b5941297c2d10035d257d6f3bc95d6ea54591", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml similarity index 68% rename from .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml rename to .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml index bd4d7036b2..b70dff2e32 100644 --- a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-31-pr-opened-issue-start-dates.md -2026-08-31-pr-opened-issue-start-dates.md: 49756d9960a7616993e4513c20c990e5cfba167e -2026-08-31-pr-opened-issue-start-dates.zh.md: 752533251be559cb4fe82f619553872bbcd748d1 +2026-08-31-pr-opened-issue-start-dates.md: f17e1bf0dcfdc541952312d1b504bdaa2e2817cc +2026-08-31-pr-opened-issue-start-dates.zh.md: 3aab8f5528ad125210fbb3839fd334d99ad49bb0 diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md rename to .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md index 49756d9960..f17e1bf0dc 100644 --- a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md +++ b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md @@ -1,6 +1,7 @@ # Agent Note: PR-opened Issue start dates Status: implemented +Archived: 2026-09-02 English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md rename to .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.zh.md index 752533251b..3aab8f5528 100644 --- a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md +++ b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.zh.md @@ -1,6 +1,7 @@ # Agent Note: 在 PR 创建时设置 Issue 开始日期 Status: implemented +Archived: 2026-09-02 [English](2026-08-31-pr-opened-issue-start-dates.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml index f4cca1265e..82e3d79cac 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-10-event-directed-pr-review-status.md -2026-08-10-event-directed-pr-review-status.md: 47f6f1731b037ae55a994c3373c0f99917da98dd -2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25 +2026-08-10-event-directed-pr-review-status.md: de4dc0700f2083772321fdf5f26c05fdf39928be +2026-08-10-event-directed-pr-review-status.zh.md: b8a8fbaa25673a376542965700e864f82ab0d739 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md index 47f6f1731b..de4dc0700f 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -16,7 +16,7 @@ The Issue lifecycle workflow treats review webhooks as commands. `pull_request.r Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. -The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [PR-opened Issue start dates](2026-08-31-pr-opened-issue-start-dates.md) own the separate date initialization for every same-repository Issue reference. +The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [Project-local Issue planning fields](2026-09-02-project-local-issue-planning-fields.md) own the separate date initialization for every same-repository Issue reference. [Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md index 8062ab5b1f..b8a8fbaa25 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -16,7 +16,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review 工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 -状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。 +状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[Project 局部 Issue 规划字段](2026-09-02-project-local-issue-planning-fields.zh.md)负责,并处理每个同仓库 Issue 引用。 [Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。 diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml new file mode 100644 index 0000000000..f2a98cb8a4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.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-09-02-project-local-issue-planning-fields.md +2026-09-02-project-local-issue-planning-fields.md: 97689de4e64d60e40ba479b89f20dff1f557ec21 +2026-09-02-project-local-issue-planning-fields.zh.md: 4d815af1789324f82394d4d26b638c50f2031ec4 diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md new file mode 100644 index 0000000000..97689de4e6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md @@ -0,0 +1,41 @@ +# Agent Note: Project-local Issue planning fields + +Status: implemented + +English | [中文](2026-09-02-project-local-issue-planning-fields.zh.md) + +## Problem + +The Issue lifecycle workflow needs structured planning metadata, but organization Issue fields require a separate GitHub App permission from organization Projects. A workflow token with Project write access can read and update Project custom fields while GitHub rejects Issue-field reads, so using both storage systems makes one policy depend on two independently administered permission sets. + +Priority, impact, cost, and dates are used to plan work in `DSH Issue Management`. Keeping those values on the Issue also exposes them outside that Project, but the repository has no workflow that needs cross-Project values. + +## Decision + +The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start Date`, and `Target Date` as Project custom fields. `Severity` uses the option meanings from the organization `影响面` field, and `Cost` uses the option meanings from `解决代价`. + +Repository policy resolves `Priority` and `Start Date` from the configured Project. It rejects an Issue-backed field or the wrong data type, reads Priority from the Project item, and writes Start Date through `updateProjectV2ItemFieldValue`. Organization Issue fields are retained only as `Legacy ...` migration sources and are not read by repository workflows. + +The Issue lifecycle workflow initializes `Start Date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the date only when the current Project value is empty. + +The [organization-field implementation](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md) records the superseded cross-Project ownership decision and its event-timing rationale. Event-directed Status transitions remain owned by [the lifecycle decision](2026-08-10-event-directed-pr-review-status.md). + +## Verification + +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items. + +## Alternatives considered + +**Keep organization Issue fields.** They make one value visible across Projects, but the workflow does not need that scope and the GitHub App would require separate organization Issue Fields access. + +**Dual-write Issue and Project fields.** Mirrored fields retain cross-Project visibility, but every writer and manual edit can create drift and requires a reconciliation policy. + +**Process every subscribed pull-request event or overwrite Start Date.** Later events could repair missing dates, but they would assign dates after work starts or replace a manual plan. The initializer therefore retains opened-only, empty-only behavior. + +## Consequences + +Planning metadata is scoped to one Project membership. The same Issue can have different values in another Project, and an Issue outside `DSH Issue Management` has no Project-local planning values. + +The GitHub App needs Project access rather than organization Issue Fields access for policy metadata. Field renames or type changes fail the workflow instead of falling back to legacy fields. + +The empty-value read makes ordinary retries idempotent. Project field updates have no compare-and-set precondition, so simultaneous pull requests can both observe an empty Start Date and the last mutation can win. diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md new file mode 100644 index 0000000000..4d815af178 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Project 局部 Issue 规划字段 + +Status: implemented + +[English](2026-09-02-project-local-issue-planning-fields.md) | 中文 + +## 问题 + +Issue 生命周期工作流需要结构化规划元数据,但组织 Issue 字段使用的 GitHub App 权限独立于组织 Project 权限。具有 Project 写权限的工作流 token 可以读取和更新 Project custom field,而 GitHub 会拒绝读取 Issue 字段,因此同时使用两套存储会让同一策略依赖两组独立管理的权限。 + +Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中规划工作。把这些值保存在 Issue 上还会让它们在该 Project 之外可见,但仓库没有需要跨 Project 值的工作流。 + +## 决策 + +`DSH Issue Management` Project 使用 Project custom field 存储 `Priority`、`Severity`、`Cost`、`Start Date` 和 `Target Date`。`Severity` 沿用组织字段 `影响面` 的选项含义,`Cost` 沿用 `解决代价` 的选项含义。 + +仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。 + +Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start Date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前 Project 值为空时写入日期。 + +[组织字段实现](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md)记录了已被取代的跨 Project 所有权决策及其事件时机依据。由事件直接指定的 Status 转换仍由[生命周期决策](2026-08-10-event-directed-pr-review-status.zh.md)负责。 + +## 验证 + +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。 + +## 考虑过的替代方案 + +**保留组织 Issue 字段。** 它们可以让一个值在多个 Project 中可见,但工作流不需要该范围,并且 GitHub App 还需要单独的组织 Issue Fields 权限。 + +**同时写入 Issue 和 Project 字段。** 镜像字段保留跨 Project 可见性,但每个写入方和人工编辑都可能产生偏差,并且还需要协调策略。 + +**处理每个已订阅 PR 事件或覆盖 Start Date。** 后续事件可以修复缺失日期,但会在工作开始后才赋值或替换人工计划。因此初始化器保留仅 opened、仅空值的行为。 + +## 后果 + +规划元数据限定在一个 Project 归属中。同一个 Issue 可以在另一个 Project 中使用不同的值,`DSH Issue Management` 之外的 Issue 没有 Project 局部规划值。 + +GitHub App 通过 Project 权限而不是组织 Issue Fields 权限访问策略元数据。字段改名或类型变化会让工作流失败,而不会回退到旧字段。 + +空值读取使通常的重试保持幂等。Project 字段更新没有比较并设置前提,因此同时引用同一个 Issue 的 PR 可能都会观察到空的 Start Date,最后一次 mutation 可能胜出。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index ef5583894f..b8c078510a 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -52,6 +52,9 @@ for (const status of ['In progress', 'In review']) { if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) { throw new Error('config.lifecycleActor 未设置') } +if (typeof config.priorityField !== 'string' || !config.priorityField) { + throw new Error('config.priorityField 未设置') +} if (typeof config.startDateField !== 'string' || !config.startDateField) { throw new Error('config.startDateField 未设置') } @@ -445,13 +448,16 @@ async function graphql(query, variables) { return result.data } -async function issueSnapshot(number, status = undefined) { +/** + * Read one Issue together with its Project planning values. + * @param {number} number Same-repository Issue number. + * @param {string|null|undefined} status Optional known Project status. + * @returns {Promise} Issue snapshot, or null when the number identifies a pull request. + */ +export async function issueSnapshot(number, status = undefined) { const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) if (issue.pull_request) return null - const values = await api( - `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, - ) - const field = (name) => values.find((value) => value.issue_field_name === name) + const context = await projectContext(number) return { number, nodeId: issue.node_id, @@ -460,8 +466,8 @@ async function issueSnapshot(number, status = undefined) { assignees: issue.assignees.map((assignee) => assignee.login), labels: issue.labels.map((label) => label.name), type: issue.type?.name ?? null, - priority: field(config.priorityField)?.single_select_option?.name ?? null, - status: status === undefined ? await projectStatus(number) : status, + priority: context.item?.priorityValue?.name ?? null, + status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status, state: issue.state, stateReason: issue.state_reason ?? null, } @@ -476,6 +482,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa $project: Int! $includeStatusActor: Boolean! $includeStartDate: Boolean! + $priorityField: String! $startDateField: String! ) { organization(login: $organization) { @@ -484,8 +491,19 @@ async function projectContext(number, includeStatusActor = false, includeStartDa title fields(first: 50) { nodes { - ... on ProjectV2Field { id name dataType isIssueField } - ... on ProjectV2SingleSelectField { id name dataType options { id name } } + ... on ProjectV2Field { + id + name + dataType + isIssueField + } + ... on ProjectV2SingleSelectField { + id + name + dataType + isIssueField + options { id name } + } } } } @@ -510,6 +528,9 @@ async function projectContext(number, includeStatusActor = false, includeStartDa fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name optionId } } + priorityValue: fieldValueByName(name: $priorityField) { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } startDateValue: fieldValueByName(name: $startDateField) @include(if: $includeStartDate) { ... on ProjectV2ItemFieldDateValue { date } @@ -526,6 +547,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa project: config.projectNumber, includeStatusActor, includeStartDate, + priorityField: config.priorityField, startDateField: config.startDateField, }, ) @@ -535,6 +557,14 @@ async function projectContext(number, includeStatusActor = false, includeStartDa if (!issue) throw new Error(`#${number} 不存在`) const statusField = project.fields.nodes.find((field) => field?.name === 'Status') if (!statusField) throw new Error('Project 缺少 Status 字段') + const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField) + if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`) + if (priorityField.dataType !== 'SINGLE_SELECT') { + throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`) + } + if (priorityField.isIssueField) { + throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`) + } const startDateField = includeStartDate ? project.fields.nodes.find((field) => field?.name === config.startDateField) : null @@ -555,12 +585,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name ? (latestStatusEvent.actor?.login ?? null) : null - return { project, issue, statusField, startDateField, item, statusActor } -} - -async function projectStatus(number) { - const context = await projectContext(number) - return context.item?.fieldValueByName?.name ?? null + return { project, issue, statusField, priorityField, startDateField, item, statusActor } } async function ensureProjectItem(number, includeStartDate = false) { @@ -579,6 +604,7 @@ async function ensureProjectItem(number, includeStartDate = false) { item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null, + priorityValue: null, startDateValue: null, }, } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 252654ce57..6f5bdbc8ba 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -5,6 +5,7 @@ import { countVisibleUnits, initializeIssueStartDate, initializePullRequestStartDates, + issueSnapshot, nextResolvingIssueStatus, parseReferences, projectDate, @@ -18,6 +19,10 @@ import { const projectGraphqlData = ({ projectItem = true, + priority = null, + priorityField = true, + priorityType = 'SINGLE_SELECT', + priorityIsIssueField = false, startDate = null, startDateField = true, startDateType = 'DATE', @@ -30,6 +35,17 @@ const projectGraphqlData = ({ fields: { nodes: [ { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] }, + ...(priorityField + ? [ + { + id: 'priority-project-field-id', + name: 'Priority', + dataType: priorityType, + isIssueField: priorityIsIssueField, + options: [], + }, + ] + : []), ...(startDateField ? [ { @@ -54,6 +70,8 @@ const projectGraphqlData = ({ id: 'item-id', project: { id: 'project-id' }, fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' }, + priorityValue: + priority === null ? null : { name: priority, optionId: `${priority}-option-id` }, startDateValue: startDate === null ? null : { date: startDate }, }, ] @@ -266,6 +284,43 @@ test('initializes every referenced Issue only for a PR opened event', async () = assert.equal(writes.length, 3) }) +test('reads Priority and Status from Project custom fields', async (t) => { + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + const urls = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + urls.push(url) + if (url.endsWith('/issues/42')) { + return Response.json({ + node_id: 'issue-id', + title: 'Project metadata', + body: null, + assignees: [], + labels: [], + type: { name: 'Task' }, + state: 'open', + state_reason: null, + }) + } + assert.equal(url, 'https://api.github.com/graphql') + assert.equal(options.headers.Authorization, 'Bearer test-token') + return Response.json({ data: projectGraphqlData({ priority: 'P1' }) }) + }) + + const issue = await issueSnapshot(42) + + assert.equal(issue.priority, 'P1') + assert.equal(issue.status, 'Inbox') + assert.deepEqual(urls, [ + 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42', + 'https://api.github.com/graphql', + ]) +}) + test('writes an empty Project Start Date with the configured field', async (t) => { const requests = mockGraphql(t, (request) => { if (request.query.includes('query(')) return projectGraphqlData() @@ -277,6 +332,8 @@ test('writes an empty Project Start Date with the configured field', async (t) = assert.equal(requests.length, 2) assert.match(requests[0].query, /isIssueField/) assert.doesNotMatch(requests[0].query, /issueField\s*\{/) + assert.match(requests[0].query, /priorityValue: fieldValueByName/) + assert.equal(requests[0].variables.priorityField, 'Priority') assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/) assert.match(requests[1].query, /updateProjectV2ItemFieldValue/) assert.match(requests[1].query, /value: \{date: \$date\}/) @@ -332,6 +389,24 @@ test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) = assert.equal(requests.length, 3) }) +test('rejects a missing, non-select, or Issue-level Priority field', async (t) => { + let response = projectGraphqlData({ priorityField: false }) + const requests = mockGraphql(t, () => response) + + await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Priority 字段/) + response = projectGraphqlData({ priorityType: 'TEXT' }) + await assert.rejects( + initializeIssueStartDate(42, '2026-08-28'), + /Priority 字段必须为 Single Select/, + ) + response = projectGraphqlData({ priorityIsIssueField: true }) + await assert.rejects( + initializeIssueStartDate(42, '2026-08-28'), + /Priority 字段必须为 Project custom field/, + ) + assert.equal(requests.length, 3) +}) + test('does not treat pull request references as Issue associations', () => { const references = { all: [123, 1180, 1181], From db6bdc3576c2d4e7c965e8e3ed0c2a731eed87f5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:48:33 +0800 Subject: [PATCH 08/17] release(dsh): 0.1.2-alpha.5 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/api/session-controller/package.json | 2 +- packages/api/settings-controller/package.json | 2 +- packages/api/workspace-controller/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/acp-app/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/sdk-app/package.json | 2 +- packages/bundle/sdk-minimal/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/store/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-approval/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-brand-official/package.json | 2 +- packages/client/ui-chat/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-reference/package.json | 2 +- packages/client/ui-renderer/package.json | 2 +- packages/client/ui-schedule/package.json | 2 +- packages/client/ui-session/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/authorization/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/experimental/agent-team-profile/package.json | 2 +- packages/experimental/agent-team-web-profile/package.json | 2 +- packages/experimental/agent-team/package.json | 2 +- packages/experimental/client-ui-agent-team/package.json | 2 +- packages/experimental/code-runtime-python/package.json | 2 +- packages/experimental/inspector/package.json | 2 +- packages/experimental/tool-agent-team/package.json | 2 +- packages/experimental/webworker-packer/package.json | 2 +- packages/experimental/webworker-runtime/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/deepseek-llm-api-extensions/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/plugin-package-inventory-deepseek/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-log-deepseek/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/session/session-turn-outline/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/subprocess/win32-process/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/test-support/session-snapshot/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/crypto/package.json | 2 +- packages/util/deque/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/time/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/util/values/package.json | 2 +- packages/util/workspace-path/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/webhook/webhook-github/package.json | 2 +- packages/webhook/webhook/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 252 files changed, 252 insertions(+), 252 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index ac50f4864a..9a17e1dcfb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/apps/web/package.json b/apps/web/package.json index e65aea0049..633c223758 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index 78502c93f2..0c4a164fc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 6040163821..5e8946c6eb 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 41d7c3b9b2..5f125b33e2 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 5940ea9594..a60099926e 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index b1fe281a9e..ed3e7101f7 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 56602b6c58..f3eb373993 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 0a64db9665..4fcec35476 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index e04af21abd..f249b1f5b5 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 64220b8e77..bd102976ed 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 4ec69c6079..27540bc343 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 0fe1c084c5..14e424df1f 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 56604d34c8..af9dab2635 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index ebc86eb5eb..e4d0227451 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 6a9785ddeb..10f7fd5816 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index e7a97c337c..b0601999e5 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index fa99da9163..7e568dfba7 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 77e803c655..63f32d737e 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 9214c8eaa9..d70fe8cbba 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 2e23f9fc4d..dd5fb1ab49 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 1de5c36ad5..21746815d5 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index f9349f31a3..7a6571a167 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index a0d44bd70f..c70d9b4f8e 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 3f7a158a1b..797bf1c6fb 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index e79953516a..7a4a01862d 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index fb6d39bf65..c017a58f02 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 62ec39a204..ae4c31086a 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 8226e45380..1b4a90f5f6 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index c7c0448ee9..800fc2f8be 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index a8e73146af..4c45fd9e12 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 44f7248e7d..ad99298a05 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index a30e4b90cc..36aa15cc6a 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 470c829b35..0b039464ff 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index b7d28df51b..ee664305f1 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 8e87e0dec6..69cf3fb724 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index d3e3c31030..08b7c20976 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 4a63c4e968..a67128f5b6 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index b2d0807e12..fdf391f56a 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 84b4f3b5c7..d4b49856fb 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index c05bd8d894..61d0c653dd 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index fd5a2dadc2..2c9dfc5684 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 3206005953..a2499cff0e 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 20fdda586d..6efa9c8910 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 8d49ea11d4..294a8dc1c2 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 7391fe6551..7eb16b43c9 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index c1aa8090a3..9694f67383 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 24cf297b7d..e3a6c50c9b 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 6c983d881f..2d558c536b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 0ee4218292..6d8f08385e 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index c48afbddeb..847a79b1c5 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index eba8fb5434..29d3b1b148 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 1c82545cee..f2f858c502 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d95faad9ef..1966bb26b5 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 741a154846..efaf783795 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index e93425b433..056479aa79 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7b927856ec..de0f11452d 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index d745d005c5..452b249f2b 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 24c2906800..a7b7b4a886 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index a85dc2a477..684201142a 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 27151a3dc1..9df135f57e 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 3ae0b8cc27..e3bc1516e8 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 1d73253e9b..2385f95eb4 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 7e4a5f9267..2c0c34ddd5 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index f458ee2162..3ac8030104 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 167a207365..0bf21a87f6 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index d14a54e685..75d7ad032e 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index a02e77184b..b0ff0b4ded 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 3d9c756297..3183dc3745 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 96ec478374..44e0e1c7fb 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 19869fb842..b89cd44d74 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index a01b6fea8a..4dd8363b11 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index ec2755683c..9b8e2ce0d8 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 3bb8e9f281..c50f407a3f 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 3e35684111..bc756fa9bc 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 953074cb37..506f14cc5f 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 3df5296e30..015bb7e1e1 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 9b553895ed..90c1675d1f 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 49b8612baf..d57cfedc13 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 88dcf337e6..1dc076fa5e 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 4d4805f4b6..04101b1bb9 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 312e27bafa..5c697c64e8 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index b22274e4aa..8cbbb477b6 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index eec2248f0b..9d18e09b94 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 006e862171..d907a56873 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index d1725fc989..d5fc47d473 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index c25b5dcbf8..a50525d332 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 5b9a76bdb3..ca7844f726 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index ab11e46bf3..bc3bf1b4f6 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index b0fff7c17c..342814bed4 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 32be82b680..00d32cea02 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Private Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 35ba66e338..75539a8169 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 7e03052ea9..2d40a2410c 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index 188128bf50..f79af60424 100644 --- a/packages/experimental/code-runtime-python/package.json +++ b/packages/experimental/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 96363ede0b..27d5fc5e50 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index e2b012d1ba..92e59e8d1c 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 61837e941b..5a0195dc72 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 57b33b5d22..f16e02acd2 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 1236cfac34..1ee8087acd 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index d53f017417..355ea66d9f 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 855e6aedd1..6891f808b0 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index a2b1207a37..0c241fbef6 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 1b6b1e869b..ab845a4683 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 594bb6a355..a30567d0ce 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 63c975a09a..6b00f5a4f5 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 09c3492c3b..3379fea8ec 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 3b496f360e..45308cf4e2 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 2584772eb8..138fa2d462 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 776f129dc1..14097343a1 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index bd6c0fbb40..e5e42d1022 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index cb89959c89..a849114c88 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 36bafa1d0b..d386516512 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 4169d63dd7..fc24c5ede4 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 2770c53221..7ec02573e0 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index b35bc1bf14..8f6b99d23a 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 3ce8a31a8e..9dd322c991 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 82e4eda1f4..b0889d7783 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 1bc5a5cb71..c1c04f3be7 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index fe735edd9a..ea853e8fa3 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index f0c96eeb14..8477fa83ac 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index a4cf3d86ef..01c5fcf132 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 76fb715690..ba37329049 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 06044425ae..d88ea47576 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index fbf77d3b35..02a7d650d6 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index bb32254def..05886f83f9 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 0bc31715d8..eafa27d9da 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 9bade860f3..aef01f1bab 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 8489124caf..8035a9457d 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 6900a383c2..790212b124 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index fd6cb5b225..23fdd4ba05 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index b76839104e..ed671365c1 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 3983fa725b..f733cf7570 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index a9c32ec8f0..4d02e66f6e 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index f2d9acda50..09a1520696 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 2c03ff7e48..c074160ab5 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index c9893a441e..b89b55e60f 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 97c360d6f4..b3e96291e0 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index a6978971ce..d12c430993 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 923654f98e..e2917f17a4 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index f1456d143e..08ff973ee6 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 15aa6cddf4..f812c8bd75 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 3af4e53894..9d708e0a32 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 0fe423246f..b59599b402 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 3d5b3bea02..ce9eefb609 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 8a1cbf9972..3f11633c2d 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index d31a6465f5..d5967c657d 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index badc29437d..181c9f5dbb 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index a28bf79dd9..6137699f07 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index ffda101c80..6f6f9805c2 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 67480ddd74..71ad037cb1 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 4a6ecf7d2a..df096f1840 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 6135d81948..fc79a97130 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index aca9b6b291..1a3b7d3f3c 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 92569fbcc2..e8e7101765 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 0a55d592b1..6d0e99fa9a 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index a91310aba1..19282525c2 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 806593fe8b..e8e140390f 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 854fd9ec48..9aa58ad3d3 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 163217d352..f01c1c2c9d 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 468e59ffb8..bf51c0372e 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index b637b13a7e..b1c6b88234 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 14013226c3..54fdafea1f 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index e6291db04a..2b064c1f92 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 7f3533befe..fbd84ee9b6 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index a5cad9a5d8..898d840483 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index a3bd097d83..6825b00c2e 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 1b4802d57c..580406aa93 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 73ce9ea58d..8b5ebdb655 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 547fc90ed1..7abf8292a6 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index d8c9fb3804..a2290391aa 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index b7b306e510..53cbe5d05a 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 65f4c5b478..a2acebfe6b 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 197b4ee8bb..bfda2819f5 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index bc64fbf187..759d25c917 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 949f393365..cc5260d597 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 4c6bc9999c..4796c2405e 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index f50b13ca8c..455fdace74 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 1fac8b8c7d..26a747974c 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index f370cc7636..a82cbca836 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index d89cc6ebe1..2463cbc122 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 43b3a18aec..fc82e1c504 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 241ed04e89..cc57761bd3 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index eb1f323fc4..07611b9efc 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4b2bfce68b..2ea30cb7b8 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index a2fd9d37cf..6571e6c3e5 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 42b1958e25..803139af30 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 68774b0c49..2db6c305bf 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 4cd2cabc85..f28de881bd 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 83ad8bc0e4..fc3f61498a 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 662f2fbc52..5400420046 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 90204f193f..26589bcc4e 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 0691320c01..396bade970 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index da592a0ec5..161cee3dc8 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index e2e9ee1427..3cb3f4dd8d 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index bec3f69135..c0c0c22650 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index d61cf92ffa..e71913a546 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 64b233b49c..0546ce688b 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index a3bbc4be5d..fb0a1c9c59 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index d3e708627d..c50a81eea7 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 9abe2460a5..be1fb943d8 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index bb050d095b..c7393cf1a4 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 7abbd315d1..15ecefc5df 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 0524fa8cfe..322fea2317 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 43adf4c56f..74d87e76f5 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 62fd0f37c0..82dc185678 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index dd1c59b1e6..a96b44c9ad 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 86485a17b6..e09a8123f2 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index b19a2ae48c..05be0b467a 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index b5d2c05742..25cd9bbb34 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 34318e3aa1..05367cbad1 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 70663bb503..618e26f29e 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 92cfa8131c..f44d031797 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index c90551d1d4..8263d99b72 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index fbdb39ff0b..978ed1318a 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 02cd59fba0..965166a540 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index bb75c1ae2d..417de38632 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 33dad665e7..cd464acfc7 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index acf24e1e33..2a67c094e2 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 13b7b55205..66279569f3 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index de87ab8353..56153cf056 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 1c79647f5c..7bde44e8d1 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index cd0c39aad6..1c543e7d7e 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index a5ffb42878..12fa142763 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 4ff26d9cb3..99faa2d453 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 5a77a4e4da..7789f7c5c4 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index dcab658721..303fece98c 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 0ba8c0ef78..354938683c 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 71a3730a24..b37b9f42ec 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index e8c4d3108d..adfedbba8c 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded primitive types for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 99f93075af..69f8b4077c 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index 53237374e2..db596ca69b 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 863f54c5fa..13064c8ec4 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index ced5ca67c4..e572c1f029 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 0359f5b21e..307d842840 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index fc97c68978..89bf06b3e9 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 1a75d46c5a..413e86bd6b 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index fe8a05aa6f..da889b60fb 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index 4f5a0e7497..e819fa7ff7 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index dfba2de7fb..a2122f9078 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 6689dbc553..bf7c8775d6 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index ac827505e1..5a1d992226 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 70a6b2559e..31bfc344e2 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 604d0e89f8..3b35725157 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9e273bdf65..131bd84498 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index faa6d421a9..e6b506e2f1 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 42e2b50afa..8df8673a1c 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 242ba27f2e..e65b012e52 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 051fd07de1..79d5867a84 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index a7354b805e..95e5bb47ac 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 7ad89934b2..6ec0dcabc5 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index eff283a88f..f3eda09122 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 677e92d4b5..bc49019b02 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, From 9ea2329084c87d5f3731bdd6c532877f773ada96 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 2 Sep 2026 16:07:02 +0800 Subject: [PATCH 09/17] fix(issue-management): grant policy Project read access --- ...ject-local-issue-planning-fields.i18n.yaml | 4 +-- ...-02-project-local-issue-planning-fields.md | 4 ++- ...-project-local-issue-planning-fields.zh.md | 4 ++- .github/issue-management/policy.mjs | 9 ++++++- .github/issue-management/policy.test.mjs | 27 ++++++++++++++----- .github/workflows/issue-policy.yml | 10 +++++++ scripts/ci-workflow.spec.ts | 27 +++++++++++++++++++ 7 files changed, 74 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml index f2a98cb8a4..4b67e3ea81 100644 --- a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-02-project-local-issue-planning-fields.md -2026-09-02-project-local-issue-planning-fields.md: 97689de4e64d60e40ba479b89f20dff1f557ec21 -2026-09-02-project-local-issue-planning-fields.zh.md: 4d815af1789324f82394d4d26b638c50f2031ec4 +2026-09-02-project-local-issue-planning-fields.md: efd0383c9701f9f4caf797d9bf1544b225e5f71c +2026-09-02-project-local-issue-planning-fields.zh.md: 3c22a506743621ac3204342575fac90d81ee8a37 diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md index 97689de4e6..efd0383c97 100644 --- a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md @@ -16,13 +16,15 @@ The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start D Repository policy resolves `Priority` and `Start Date` from the configured Project. It rejects an Issue-backed field or the wrong data type, reads Priority from the Project item, and writes Start Date through `updateProjectV2ItemFieldValue`. Organization Issue fields are retained only as `Legacy ...` migration sources and are not read by repository workflows. +The pull-request policy workflow uses the repository `GITHUB_TOKEN` for repository Issue and pull-request reads, and a GitHub App token restricted to organization Projects read access for ProjectV2 queries. Lifecycle mutations continue to use the write-capable App token. + The Issue lifecycle workflow initializes `Start Date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the date only when the current Project value is empty. The [organization-field implementation](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md) records the superseded cross-Project ownership decision and its event-timing rationale. Event-directed Status transitions remain owned by [the lifecycle decision](2026-08-10-event-directed-pr-review-status.md). ## Verification -[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items. +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, prove repository and Project reads use separate credentials, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Workflow tests pin the Project token's read-only permission. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md index 4d815af178..3c22a50674 100644 --- a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md @@ -16,13 +16,15 @@ Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中 仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。 +PR 策略工作流使用仓库 `GITHUB_TOKEN` 读取仓库 Issue 和 PR,并使用仅有组织 Projects 读取权限的 GitHub App token 执行 ProjectV2 查询。生命周期 mutation 继续使用有写权限的 App token。 + Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start Date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前 Project 值为空时写入日期。 [组织字段实现](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md)记录了已被取代的跨 Project 所有权决策及其事件时机依据。由事件直接指定的 Status 转换仍由[生命周期决策](2026-08-10-event-directed-pr-review-status.zh.md)负责。 ## 验证 -[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。 +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,证明仓库读取与 Project 读取使用不同凭据,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。工作流测试固定 Project token 的只读权限。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。 ## 考虑过的替代方案 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index b8c078510a..6b731f1318 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -418,6 +418,10 @@ function token() { return value } +function projectToken() { + return process.env.PROJECT_TOKEN || token() +} + async function api(path, options = {}) { const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { ...options, @@ -442,7 +446,10 @@ async function graphql(query, variables) { const result = await api('/graphql', { method: 'POST', body: JSON.stringify({ query, variables }), - headers: { 'Content-Type': 'application/json' }, + headers: { + Authorization: `Bearer ${projectToken()}`, + 'Content-Type': 'application/json', + }, }) if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) return result.data diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 6f5bdbc8ba..e2d01a5c78 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -34,7 +34,13 @@ const projectGraphqlData = ({ title: 'DSH Issue Management', fields: { nodes: [ - { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] }, + { + id: 'status-field-id', + name: 'Status', + dataType: 'SINGLE_SELECT', + isIssueField: false, + options: [], + }, ...(priorityField ? [ { @@ -285,16 +291,25 @@ test('initializes every referenced Issue only for a PR opened event', async () = }) test('reads Priority and Status from Project custom fields', async (t) => { - const previousToken = process.env.GH_TOKEN - process.env.GH_TOKEN = 'test-token' + const previousGhToken = process.env.GH_TOKEN + const previousGithubToken = process.env.GITHUB_TOKEN + const previousProjectToken = process.env.PROJECT_TOKEN + delete process.env.GH_TOKEN + process.env.GITHUB_TOKEN = 'repository-token' + process.env.PROJECT_TOKEN = 'project-token' t.after(() => { - if (previousToken === undefined) delete process.env.GH_TOKEN - else process.env.GH_TOKEN = previousToken + if (previousGhToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousGhToken + if (previousGithubToken === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = previousGithubToken + if (previousProjectToken === undefined) delete process.env.PROJECT_TOKEN + else process.env.PROJECT_TOKEN = previousProjectToken }) const urls = [] t.mock.method(globalThis, 'fetch', async (url, options) => { urls.push(url) if (url.endsWith('/issues/42')) { + assert.equal(options.headers.Authorization, 'Bearer repository-token') return Response.json({ node_id: 'issue-id', title: 'Project metadata', @@ -307,7 +322,7 @@ test('reads Priority and Status from Project custom fields', async (t) => { }) } assert.equal(url, 'https://api.github.com/graphql') - assert.equal(options.headers.Authorization, 'Bearer test-token') + assert.equal(options.headers.Authorization, 'Bearer project-token') return Response.json({ data: projectGraphqlData({ priority: 'P1' }) }) }) diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml index dde9462c33..c00f3eb71b 100644 --- a/.github/workflows/issue-policy.yml +++ b/.github/workflows/issue-policy.yml @@ -21,7 +21,17 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false + - name: Create Project read token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + permission-organization-projects: read - name: Validate pull request env: GITHUB_TOKEN: ${{ github.token }} + PROJECT_TOKEN: ${{ steps.app-token.outputs.token }} run: node .github/issue-management/policy.mjs pr diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 111372b79d..6e30fc5db9 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -683,6 +683,33 @@ describe('Issue lifecycle workflow', () => { const policyPullRequest = workflowEvent(policy, 'pull_request') expect(policyPullRequest.types).toContain('ready_for_review') }) + + it('uses a read-only Project token for pull request policy metadata', () => { + const policy = loadWorkflow('.github/workflows/issue-policy.yml') + const policyJob = workflowJob(policy, 'policy') + if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps') + const steps = policyJob.steps.filter(isRecord) + const tokenStep = steps.find(step => step.name === 'Create Project read token') + const validateStep = steps.find(step => step.name === 'Validate pull request') + + expect(tokenStep).toMatchObject({ + id: 'app-token', + uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1', + with: { + 'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}', + owner: 'deepseek-harness', + repositories: 'deepseek-harness', + 'permission-organization-projects': 'read', + }, + }) + expect(validateStep).toMatchObject({ + env: { + GITHUB_TOKEN: '${{ github.token }}', + PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}', + }, + }) + }) }) describe('npm release workflows', () => { From eeddd457cd6080fd046f581f70abd5eafb38c744 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 2 Sep 2026 17:14:44 +0800 Subject: [PATCH 10/17] fix(agent-team): preserve mailbox order on cold resume --- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 8 +- docs/persistence-catalog.zh.md | 8 +- .../experimental/agent-team/src/mailbox.ts | 51 ++++---- .../experimental/agent-team/src/projection.ts | 12 +- .../experimental/agent-team/src/roster.ts | 6 +- .../experimental/agent-team/src/task-board.ts | 4 +- packages/experimental/agent-team/src/types.ts | 8 +- .../agent-team/tests/invariant.spec.ts | 6 +- .../agent-team/tests/persistence.spec.ts | 18 +-- .../tests/projection-events.spec.ts | 86 ++++++------- .../agent-team/tests/team.spec.ts | 114 ++++++++---------- .../subagent/subagent/src/continuation.ts | 10 +- 13 files changed, 167 insertions(+), 168 deletions(-) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 84d0af864b..b6e2f8ac7f 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 5b8ae5748c997b829ce715534bee4390c0aac1f5 -persistence-catalog.zh.md: 9a5d1b40201a26d20839902f109d8c1ab8eb5301 +persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c +persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5b8ae5748c..1c0c691998 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -776,7 +776,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p ```ts persistence-catalog /** Whole teammate lifecycle value, stored only in the Team Lead Session. */ -'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot } +'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } ``` Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) @@ -790,7 +790,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experi ```ts persistence-catalog /** Durable acknowledgement that the target Session recorded the message. */ 'team/message/delivered': { - version: 1 + version: 2 teamId: TeamId messageId: TeamMessageId targetId: SessionId @@ -807,7 +807,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experi ```ts persistence-catalog /** Durable mailbox enqueue, stored before delivery is attempted. */ -'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot } +'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot } ``` Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) @@ -820,7 +820,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experi ```ts persistence-catalog /** Whole shared-task value, stored only in the Team Lead Session. */ -'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot } +'team/task': { version: 2; teamId: TeamId; task: TeamTaskSnapshot } ``` Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 9a5d1b4020..4cc8ba5b7f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -778,7 +778,7 @@ export type SessionEvent = { ```ts persistence-catalog /** Whole teammate lifecycle value, stored only in the Team Lead Session. */ -'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot } +'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } ``` 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) @@ -792,7 +792,7 @@ export type SessionEvent = { ```ts persistence-catalog /** Durable acknowledgement that the target Session recorded the message. */ 'team/message/delivered': { - version: 1 + version: 2 teamId: TeamId messageId: TeamMessageId targetId: SessionId @@ -809,7 +809,7 @@ export type SessionEvent = { ```ts persistence-catalog /** Durable mailbox enqueue, stored before delivery is attempted. */ -'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot } +'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot } ``` 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) @@ -822,7 +822,7 @@ export type SessionEvent = { ```ts persistence-catalog /** Whole shared-task value, stored only in the Team Lead Session. */ -'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot } +'team/task': { version: 2; teamId: TeamId; task: TeamTaskSnapshot } ``` 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 8af111cd76..acd842308a 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -26,7 +26,6 @@ import type { /** Owns every process-local state transition for the durable Team mailbox. */ export class TeamMailbox { private readonly dispatchTails = new Map>() - private readonly activeDispatches = new Map() private readonly inFlightMessages = new Set() private readonly inFlightDispatches = new Set>() @@ -139,7 +138,7 @@ export class TeamMailbox { throw new TeamError(`team message exceeds ${this.maxMessageBytes} bytes`, 'TEAM_MESSAGE_TOO_LARGE') } await this.journal.appendAndFlush(root, 'team/message/queued', { - version: 1, + version: 2, teamId: TeamId(root.id), message: queued, }) @@ -187,12 +186,7 @@ export class TeamMailbox { message: TeamMessageSnapshot, signal: AbortSignal, ): Promise { - const active = this.activeDispatches.get(message.targetId) - const live = message.targetId === root.id ? root : this.ctx.agents.get(message.targetId) - if (active !== undefined && live !== undefined && this.messagePrecedes(root, message.id, active.id)) { - return await this.dispatchOnce(root, message, signal) - } - return await this.serializeDispatch(message, () => this.dispatchOnce(root, message, signal)) + return await this.serializeDispatch(message, () => this.dispatchThrough(root, message, signal)) } /** Serialize delivery admission for one durable target in queued order. */ @@ -202,16 +196,8 @@ export class TeamMailbox { ): Promise { const targetId = message.targetId const prior = this.dispatchTails.get(targetId) ?? Promise.resolve() - const dispatch = async (): Promise => { - this.activeDispatches.set(targetId, message) - try { - return await operation() - } finally { - this.activeDispatches.delete(targetId) - } - } /* v8 ignore next -- dispatch tails absorb rejection, so the recovery callback is a fail-safe backstop. */ - const run = prior.then(dispatch, dispatch) + const run = prior.then(operation, operation) /* v8 ignore next -- dispatchOnce contains delivery failures and serializeDispatch itself does not throw. */ const tail = run.then(() => undefined, () => undefined) this.dispatchTails.set(targetId, tail) @@ -222,6 +208,29 @@ export class TeamMailbox { } } + /** Deliver every pending target message through `message` in durable queue order. */ + private async dispatchThrough( + root: Agent, + message: TeamMessageSnapshot, + signal: AbortSignal, + ): Promise { + const state = this.journal.state(root) + const pending = state.messages.filter(candidate => + candidate.targetId === message.targetId && !state.delivered.includes(candidate.id)) + const requested = pending.findIndex(candidate => candidate.id === message.id) + if (requested < 0) return state.delivered.includes(message.id) + for (const candidate of pending.slice(0, requested + 1)) { + const ownsInFlight = !this.inFlightMessages.has(candidate.id) + if (ownsInFlight) this.inFlightMessages.add(candidate.id) + try { + if (!await this.dispatchOnce(root, candidate, signal)) return false + } finally { + if (ownsInFlight) this.inFlightMessages.delete(candidate.id) + } + } + return true + } + /** Attempt one queued delivery after target-local ordering admits it. */ private async dispatchOnce(root: Agent, message: TeamMessageSnapshot, signal: AbortSignal): Promise { try { @@ -260,12 +269,6 @@ export class TeamMailbox { } } - /** Whether `left` was durably queued before `right` in one Lead log. */ - private messagePrecedes(root: Agent, left: TeamMessageId, right: TeamMessageId): boolean { - const ids = this.journal.state(root).messages.map(message => message.id) - return ids.indexOf(left) < ids.indexOf(right) - } - /** Flush one live target receipt before the Lead records its delivered edge. */ private async checkpointDelivered( root: Agent, @@ -286,7 +289,7 @@ export class TeamMailbox { const queued = state.messages.find(message => message.id === messageId) if (queued === undefined || queued.targetId !== targetId) return await this.journal.appendAndFlush(root, 'team/message/delivered', { - version: 1, + version: 2, teamId: TeamId(root.id), messageId, targetId, diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 2d60102197..85fdfd0e0d 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -99,25 +99,25 @@ const teamEventSelectorSchema = z.object({ }).loose() const teamMemberEventSchema = z.object({ - version: z.literal(1), + version: z.literal(2), teamId: teamIdSchema, member: teamMemberSnapshotSchema, }).strict() as z.ZodType const teamTaskEventSchema = z.object({ - version: z.literal(1), + version: z.literal(2), teamId: teamIdSchema, task: teamTaskSnapshotSchema, }).strict() as z.ZodType const teamMessageQueuedEventSchema = z.object({ - version: z.literal(1), + version: z.literal(2), teamId: teamIdSchema, message: teamMessageSnapshotSchema, }).strict() as z.ZodType const teamMessageDeliveredEventSchema = z.object({ - version: z.literal(1), + version: z.literal(2), teamId: teamIdSchema, messageId: teamMessageIdSchema, targetId: sessionIdSchema, @@ -224,7 +224,7 @@ function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): try { const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) if (selector.teamId !== state.id) return - if (selector.version !== 1) { + if (selector.version !== 2) { throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) } applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) @@ -306,7 +306,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void /** Host-only Team projection selected by the projected Session identity. */ export const teamProjectionDefinition = { key: 'agentTeam', - stateVersion: 2, + stateVersion: 3, stateSchema: teamProjectionEntrySchema, init: header => emptyTeamState(header.id), apply: (state, event) => { diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 7ff934b150..3d0ca8372e 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -274,7 +274,7 @@ export class TeamRoster { if (state.members.length >= this.maxMembers) { throw new TeamError(`Team member limit ${this.maxMembers} reached`, 'TEAM_MEMBER_LIMIT') } - await this.journal.appendAndFlush(root, 'team/member', { version: 1, teamId: TeamId(root.id), member }) + await this.journal.appendAndFlush(root, 'team/member', { version: 2, teamId: TeamId(root.id), member }) }) let started: ContinuableStart @@ -424,7 +424,7 @@ export class TeamRoster { ...phase === 'failed' ? { error: failure } : {}, } await this.journal.appendAndFlush(root, 'team/member', { - version: 1, + version: 2, teamId: TeamId(root.id), member: settled, }) @@ -472,7 +472,7 @@ export class TeamRoster { } if (current.phase !== 'provisioning') return current.phase await this.journal.appendAndFlush(root, 'team/member', { - version: 1, + version: 2, teamId: TeamId(root.id), member: terminal, }) diff --git a/packages/experimental/agent-team/src/task-board.ts b/packages/experimental/agent-team/src/task-board.ts index 5dd235f940..cc7064ceb6 100644 --- a/packages/experimental/agent-team/src/task-board.ts +++ b/packages/experimental/agent-team/src/task-board.ts @@ -67,7 +67,7 @@ export class TeamTaskBoard { writeScopes: this.writeScopes(request.writeScopes ?? []), } this.assertTaskGraph(state, task) - await this.journal.appendAndFlush(root, 'team/task', { version: 1, teamId: TeamId(root.id), task }) + await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task }) return this.taskView(root, state, task) }) } @@ -209,7 +209,7 @@ export class TeamTaskBoard { revision: current.revision + 1, } this.assertTaskGraph(state, task) - await this.journal.appendAndFlush(root, 'team/task', { version: 1, teamId: TeamId(root.id), task }) + await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task }) return this.taskView(root, state, task) }) } diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 5818a6bc5f..f7b8ca414f 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -218,14 +218,14 @@ export interface TeamWaitResult { declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** Whole teammate lifecycle value, stored only in the Team Lead Session. */ - 'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot } + 'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } /** Whole shared-task value, stored only in the Team Lead Session. */ - 'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot } + 'team/task': { version: 2; teamId: TeamId; task: TeamTaskSnapshot } /** Durable mailbox enqueue, stored before delivery is attempted. */ - 'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot } + 'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot } /** Durable acknowledgement that the target Session recorded the message. */ 'team/message/delivered': { - version: 1 + version: 2 teamId: TeamId messageId: TeamMessageId targetId: SessionId diff --git a/packages/experimental/agent-team/tests/invariant.spec.ts b/packages/experimental/agent-team/tests/invariant.spec.ts index 04d99d75a5..5dd8dba256 100644 --- a/packages/experimental/agent-team/tests/invariant.spec.ts +++ b/packages/experimental/agent-team/tests/invariant.spec.ts @@ -30,13 +30,13 @@ describe('Agent Teams stream invariant', () => { phase: 'provisioning' as const, } expect(() => { - session.append('team/member', { version: 1, teamId: TeamId(session.id), member }) + session.append('team/member', { version: 2, teamId: TeamId(session.id), member }) }).not.toThrow() const invalid = ctx.sessions.create(SessionId('team-invariant-invalid')) expect(() => { invalid.append('team/member', { - version: 1, + version: 2, teamId: TeamId(invalid.id), member: { ...member, phase: 'active' }, }) @@ -53,7 +53,7 @@ describe('Agent Teams stream invariant', () => { expect(() => { session.append('team/task', { - version: 1, + version: 2, teamId: TeamId(session.id), task: { id: TeamTaskId('task-1'), diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 12f3e1b9f3..0f550c591c 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -178,12 +178,12 @@ for (const backend of backends) { await Promise.resolve() activeRoot.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(activeRoot.id), member: provisioning(childId, 'recoverable'), }) failedRoot.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(failedRoot.id), member: provisioning(SessionId(`${backend.name}-missing`), 'missing'), }) @@ -248,7 +248,7 @@ for (const backend of backends) { await Promise.resolve() await Promise.resolve() root.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(root.id), member: provisioning(childId, 'pending-worker'), }) @@ -295,8 +295,8 @@ for (const backend of backends) { signal: SIGNAL, }) await vi.waitFor(() => { expect(first.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 }) - vi.spyOn(first.ctx.sessionPersistence, 'inspect') - .mockRejectedValueOnce(new Error('temporary target inspection failure')) + vi.spyOn(first.ctx.sessionPersistence, 'open') + .mockRejectedValueOnce(new Error('temporary target read failure')) const queued = await first.ctx.agentTeams.sendMessage(firstLead, { target: 'mail-worker', content: [{ type: 'text', text: 'durable retry context' }], @@ -376,7 +376,7 @@ for (const backend of backends) { content: [{ type: 'text', text: 'already recorded before acknowledgement' }], } firstLead.session.append('team/message/queued', { - version: 1, + version: 2, teamId: TeamId(rootId), message: queued, }) @@ -428,17 +428,17 @@ for (const backend of backends) { content: [{ type: 'text', text: 'already durable in target inbox' }], } root.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(root.id), member: provisioned, }) root.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(root.id), member: active, }) root.session.append('team/message/queued', { - version: 1, + version: 2, teamId: TeamId(root.id), message: queued, }) diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index d78b160ff0..97187e4eb6 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -79,15 +79,15 @@ function message(overrides: Partial = {}): TeamMessageSnaps describe('Agent Teams projection events', () => { it('projects current-team records independently from inherited records', () => { const records: SessionEvent[] = [ - event('team/member', { version: 1, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)), - event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(1)), + event('team/member', { version: 2, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)), + event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(1)), event('team/member', { - version: 1, + version: 2, teamId: TEAM, member: member({ phase: 'active' }), }, SessionSeq(2)), - event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, SessionSeq(3)), - event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(4)), + event('team/task', { version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, SessionSeq(3)), + event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(4)), ] const projected = project(ROOT, records) const state = teamState(projected) @@ -103,53 +103,53 @@ describe('Agent Teams projection events', () => { }) it('enforces teammate identity and lifecycle', () => { - const base = event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)) + const base = event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)) expect(() => projectTeam(ROOT, [event('team/member', { - version: 1, + version: 2, teamId: TEAM, member: member({ phase: 'active' }), }, SessionSeq(0))])).toThrow(/must begin provisioning/) expect(() => projectTeam(ROOT, [base, event('team/member', { - version: 1, + version: 2, teamId: TEAM, member: member({ name: 'renamed', phase: 'active' }), }, SessionSeq(1))])).toThrow(/immutable identity/) expect(() => projectTeam(ROOT, [base, event('team/member', { - version: 1, + version: 2, teamId: TEAM, member: member({ phase: 'active' }), }, SessionSeq(1)), event('team/member', { - version: 1, + version: 2, teamId: TEAM, member: member({ phase: 'failed' }), }, SessionSeq(2))])).toThrow(/invalid active -> failed/) const duplicateName = member({ id: SessionId('child-b') }) expect(() => projectTeam(ROOT, [base, event('team/member', { - version: 1, + version: 2, teamId: TEAM, member: duplicateName, }, SessionSeq(1))])).toThrow(/name .* reused/) }) it('enforces task revision continuity', () => { - const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)) + const first = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0)) expect(() => projectTeam(ROOT, [event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ revision: 2 }), }, SessionSeq(0))])).toThrow(/begin at revision 1/) expect(() => projectTeam(ROOT, [first, event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ revision: 3 }), }, SessionSeq(1))])).toThrow(/revision is not contiguous/) }) it('rejects every invalid persisted task dependency relation', () => { - const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)) + const first = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0)) const second = event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-2'), @@ -159,7 +159,7 @@ describe('Agent Teams projection events', () => { const invalid: Array<{ records: SessionEvent[]; message: RegExp }> = [ { records: [event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ blockedBy: [TeamTaskId('missing')] }), }, SessionSeq(0))], @@ -167,7 +167,7 @@ describe('Agent Teams projection events', () => { }, { records: [event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ blockedBy: [TeamTaskId('task-1')] }), }, SessionSeq(0))], @@ -182,7 +182,7 @@ describe('Agent Teams projection events', () => { }, { records: [first, second, event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ revision: 2, blockedBy: [TeamTaskId('task-2')] }), }, SessionSeq(2))], @@ -190,7 +190,7 @@ describe('Agent Teams projection events', () => { }, { records: [first, second, event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ revision: 2, status: 'deleted' }), }, SessionSeq(2))], @@ -205,7 +205,7 @@ describe('Agent Teams projection events', () => { it('leaves numeric allocation unchanged for a branded nonstandard task id', () => { const state = projectTeam(ROOT, [event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('external-task') }), }, SessionSeq(0))]) @@ -214,16 +214,16 @@ describe('Agent Teams projection events', () => { it('rejects a persisted numeric task id outside the safe integer range', () => { expect(() => projectTeam(ROOT, [event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-9007199254740992') }), }, SessionSeq(0))])).toThrow(/persisted Agent Teams team\/task payload is invalid/) }) it('enforces mailbox queue and acknowledgement relations', () => { - const queued = event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(0)) + const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(0)) const delivered = event('team/message/delivered', { - version: 1, + version: 2, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, @@ -241,42 +241,42 @@ describe('Agent Teams projection events', () => { it('validates every current-version persisted payload before projecting it', () => { const malformed = [ { - ...event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)), - data: { version: 1, teamId: TEAM, member: { ...member(), name: 42 } }, + ...event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)), + data: { version: 2, teamId: TEAM, member: { ...member(), name: 42 } }, }, { - ...event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)), - data: { version: 1, teamId: TEAM, task: { ...task(), blockedBy: [42] } }, + ...event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0)), + data: { version: 2, teamId: TEAM, task: { ...task(), blockedBy: [42] } }, }, { - ...event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(0)), + ...event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(0)), data: { - version: 1, + version: 2, teamId: TEAM, message: { ...message(), content: [{ type: 'text', text: 42 }] }, }, }, { ...event('team/message/delivered', { - version: 1, + version: 2, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, }, SessionSeq(0)), data: { - version: 1, + version: 2, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: 42, }, }, { - ...event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)), - data: { version: 1, teamId: TEAM, member: member(), unexpected: true }, + ...event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)), + data: { version: 2, teamId: TEAM, member: member(), unexpected: true }, }, { - ...event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)), - data: { version: 1, teamId: 42, task: task() }, + ...event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0)), + data: { version: 2, teamId: 42, task: task() }, }, ] as unknown as SessionEvent[] @@ -289,7 +289,7 @@ describe('Agent Teams projection events', () => { it('retains merge-extensible content blocks while rejecting malformed core variants', () => { const extension = { type: 'plugin/custom', payload: { value: 1 } } as never const state = projectTeam(ROOT, [event('team/message/queued', { - version: 1, + version: 2, teamId: TEAM, message: message({ content: [extension] }), }, SessionSeq(0))]) @@ -298,23 +298,23 @@ describe('Agent Teams projection events', () => { it('records unsupported event versions without applying them', () => { const invalid = event('team/task', { - version: 2 as 1, + version: 1 as 2, teamId: TEAM, task: task(), }, SessionSeq(0)) const later = event('team/task', { - version: 1, + version: 2, teamId: TEAM, task: task(), }, SessionSeq(1)) const state = project(ROOT, [invalid, later]) - expect(state.failure).toMatch(/unsupported Agent Teams event version 2/) + expect(state.failure).toMatch(/unsupported Agent Teams event version 1/) expect(isEmptyState(state)).toBe(true) }) it('isolates unsupported inherited Team records from the current Team', () => { const inherited = event('team/task', { - version: 2 as 1, + version: 1 as 2, teamId: TeamId('ancestor'), task: task(), }, SessionSeq(0)) @@ -326,12 +326,12 @@ describe('Agent Teams projection events', () => { it('ignores malformed current-version records inherited from another Team', () => { const inherited = { ...event('team/task', { - version: 1, + version: 2, teamId: TeamId('ancestor'), task: task(), }, SessionSeq(0)), data: { - version: 1, + version: 2, teamId: TeamId('ancestor'), task: { ...task(), subject: 42 }, }, diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 1a11e8c06c..4f099a0916 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -193,7 +193,7 @@ describe('Team identity and provisioning', () => { phase: 'provisioning' as const, } lead.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(lead.id), member: provisioning, }) @@ -354,7 +354,7 @@ describe('Team identity and provisioning', () => { const provisioning = durable(second.lead).members[0] if (provisioning === undefined) throw new Error('missing provisioning edge') second.lead.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(second.lead.id), member: { ...provisioning, phase: 'active' }, }) @@ -555,7 +555,7 @@ describe('Team shared task DAG', () => { const { ctx, lead } = await setup([]) const id = TeamTaskId(`task-${Number.MAX_SAFE_INTEGER}`) lead.session.append('team/task', { - version: 1, + version: 2, teamId: TeamId(lead.id), task: { id, @@ -948,7 +948,7 @@ describe('Team mailbox and waiting', () => { content: content('progress report'), } lead.session.append('team/message/queued', { - version: 1, + version: 2, teamId: TeamId(lead.id), message, }) @@ -1034,7 +1034,7 @@ describe('Team mailbox and waiting', () => { content: content('durable pending receipt'), } lead.session.append('team/message/queued', { - version: 1, + version: 2, teamId: TeamId(lead.id), message, }) @@ -1070,7 +1070,7 @@ describe('Team mailbox and waiting', () => { content: content('canceled before checkpoint'), } lead.session.append('team/message/queued', { - version: 1, + version: 2, teamId: TeamId(lead.id), message: disappearing, }) @@ -1137,14 +1137,14 @@ describe('Team mailbox and waiting', () => { }) it('serializes concurrent Steer delivery admission for one target', async () => { - const { ctx, lead } = await setup([textResponse('target initial')]) - const target = await spawn(ctx, lead, 'ordered-target') - await waitNoAgent(ctx, target.member.id) + const { ctx, lead } = await setup(['hang']) + const started = await spawn(ctx, lead, 'ordered-target') + const target = await waitRunning(ctx, started.member.id) const entered = Promise.withResolvers() const release = Promise.withResolvers() const admitted: string[] = [] vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) - .mockImplementation(async (_parent, _childId, blocks) => { + .mockImplementation(async (_parent, _childId, blocks, source) => { const last = blocks.at(-1) const text = last?.type === 'text' ? last.text : '' admitted.push(text) @@ -1152,7 +1152,9 @@ describe('Team mailbox and waiting', () => { entered.resolve(undefined) await release.promise } - return createUserMessage({ content: blocks, source: { kind: 'user' } }).id + const input = createUserMessage({ content: blocks, source }) + target.inject(input) + return input.id }) const first = ctx.agentTeams.sendMessage(lead, { @@ -1163,7 +1165,7 @@ describe('Team mailbox and waiting', () => { const second = ctx.agentTeams.sendMessage(lead, { target: 'ordered-target', content: content('second steer'), signal: SIGNAL, }).finally(() => { secondSettled = true }) - await new Promise((resolve) => { setTimeout(resolve, 0) }) + await vi.waitFor(() => { expect(durable(lead).pendingMessages).toHaveLength(2) }) expect(admitted).toEqual(['first steer']) expect(secondSettled).toBe(false) @@ -1173,58 +1175,43 @@ describe('Team mailbox and waiting', () => { { status: 'accepted' }, ]) expect(admitted).toEqual(['first steer', 'second steer']) + + ctx.agentTeams.interrupt(lead, 'ordered-target') + target.cancel({ kind: 'parent' }) + await waitNoAgent(ctx, target.id) }) - it('admits an earlier durable message ahead of a later in-flight resume', async () => { - const { ctx, lead } = await setup(['hang']) + it('delivers persisted mail before the later message that cold-resumes its target', async () => { + const { ctx, lead } = await setup([textResponse('target initial'), 'hang', 'hang']) const started = await spawn(ctx, lead, 'reordered-target') - const target = await waitRunning(ctx, started.member.id) + await waitNoAgent(ctx, started.member.id) const earlier: TeamMessageSnapshot = { id: TeamMessageId('earlier-message'), senderId: lead.id, senderName: 'lead', - targetId: target.id, + targetId: started.member.id, content: content('earlier steer'), } - const later: TeamMessageSnapshot = { - ...earlier, - id: TeamMessageId('later-message'), - content: content('later steer'), - } - for (const message of [earlier, later]) { - lead.session.append('team/message/queued', { - version: 1, - teamId: TeamId(lead.id), - message, - }) - } + lead.session.append('team/message/queued', { + version: 2, + teamId: TeamId(lead.id), + message: earlier, + }) + await ctx.sessions.flush(lead.session) - const laterEntered = Promise.withResolvers() - const releaseLater = Promise.withResolvers() - const admitted: string[] = [] - vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt) - .mockImplementation(async (_parent, _childId, blocks, source) => { - const last = blocks.at(-1) - const text = last?.type === 'text' ? last.text : '' - if (text === 'later steer') { - laterEntered.resolve(undefined) - await releaseLater.promise - } - const input = createUserMessage({ content: blocks, source }) - target.inject(input) - admitted.push(text) - return input.id - }) - - const laterDispatch = teamInternals(ctx).mailbox.tryDispatch(lead, later, SIGNAL) - await laterEntered.promise - await expect(teamInternals(ctx).mailbox.tryDispatch(lead, earlier, SIGNAL)).resolves.toBe(true) - expect(admitted).toEqual(['earlier steer']) - - releaseLater.resolve(undefined) - await expect(laterDispatch).resolves.toBe(true) - expect(admitted).toEqual(['earlier steer', 'later steer']) - expect(durable(lead).pendingMessages).toEqual([]) + const later = await ctx.agentTeams.sendMessage(lead, { + target: 'reordered-target', content: content('later steer'), signal: SIGNAL, + }) + expect(later.status).toBe('accepted') + const target = await waitRunning(ctx, started.member.id) + await vi.waitFor(() => { + const accepted = target.session.snapshotEvents().flatMap(event => event.type === 'agent/inbox/spliced' + ? event.data.inserted.flatMap(message => message.source.kind === 'team-message' + ? [message.source.messageId] + : []) + : []) + expect(accepted).toEqual([earlier.id, later.messageId]) + }) ctx.agentTeams.interrupt(lead, 'reordered-target') target.cancel({ kind: 'parent' }) @@ -1244,7 +1231,7 @@ describe('Team mailbox and waiting', () => { content: content('already in live history'), } lead.session.append('team/message/queued', { - version: 1, teamId: TeamId(lead.id), message, + version: 2, teamId: TeamId(lead.id), message, }) await ctx.sessions.flush(lead.session) live.session.append('user/message', createUserMessage({ @@ -1269,13 +1256,14 @@ describe('Team mailbox and waiting', () => { }), { surfaceOp: 'append' }) await expect(internal.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) await internal.markDelivered(lead, message.id, live.id) + await expect(internal.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true) const wrongTarget: TeamMessageSnapshot = { ...message, id: TeamMessageId('wrong-target-message'), } lead.session.append('team/message/queued', { - version: 1, teamId: TeamId(lead.id), message: wrongTarget, + version: 2, teamId: TeamId(lead.id), message: wrongTarget, }) await ctx.sessions.flush(lead.session) await internal.markDelivered(lead, wrongTarget.id, SessionId('wrong-target')) @@ -1383,7 +1371,7 @@ describe('Team mailbox and waiting', () => { await expect(ctx.agentTeams.sendMessage(lead, { target: 'target', content: content('x'.repeat(300)), signal: SIGNAL, })).rejects.toMatchObject({ code: 'TEAM_MESSAGE_TOO_LARGE' }) - vi.spyOn(ctx.sessionPersistence, 'inspect').mockRejectedValueOnce(new Error('temporary inspection failure')) + vi.spyOn(ctx.sessionPersistence, 'open').mockRejectedValueOnce(new Error('temporary read failure')) const queued = await ctx.agentTeams.sendMessage(lead, { target: 'target', content: content('one'), signal: SIGNAL, }) @@ -1589,7 +1577,7 @@ describe('Team mailbox and waiting', () => { phase: 'provisioning' as const, } lead.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(lead.id), member, }) @@ -1602,7 +1590,7 @@ describe('Team mailbox and waiting', () => { }) await waitRunning(ctx, childId) lead.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(lead.id), member: { ...member, @@ -1669,7 +1657,7 @@ describe('Team mailbox and waiting', () => { content: content('acknowledge before disposal'), } lead.session.append('team/message/queued', { - version: 1, + version: 2, teamId: TeamId(lead.id), message, }) @@ -1822,7 +1810,7 @@ describe('Team mailbox and waiting', () => { phase: 'provisioning' as const, } first.lead.session.append('team/member', { - version: 1, teamId: TeamId(first.lead.id), member: provisioning, + version: 2, teamId: TeamId(first.lead.id), member: provisioning, }) const reconcileFirst = teamInternals(first.ctx).roster await reconcileFirst.reconcileProvisioning(first.lead, SIGNAL) @@ -1842,7 +1830,7 @@ describe('Team mailbox and waiting', () => { const childId = SessionId('concurrently-settled-child') const member = { ...provisioning, id: childId, name: 'concurrent-child' } second.lead.session.append('team/member', { - version: 1, teamId: TeamId(second.lead.id), member, + version: 2, teamId: TeamId(second.lead.id), member, }) const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -1855,7 +1843,7 @@ describe('Team mailbox and waiting', () => { const reconciling = reconcileSecond.reconcileProvisioning(second.lead, SIGNAL) await entered.promise second.lead.session.append('team/member', { - version: 1, + version: 2, teamId: TeamId(second.lead.id), member: { ...member, phase: 'failed', error: 'settled elsewhere' }, }) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 6fabb6ea33..f127bc51de 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -134,7 +134,15 @@ export interface SubagentSendMessageOptions { /** Inputs shared by model steering and the human Queue adapter. */ type ChildDeliveryOptions = - | { readonly delivery: 'steer'; readonly source?: MessageSource; readonly signal: AbortSignal } + | { + readonly delivery: 'steer' + /** + * A provided host source is preserved on the user message; omission attributes + * an adjacent-Agent message to the parent. + */ + readonly source?: MessageSource + readonly signal: AbortSignal + } | { readonly delivery: 'queue'; readonly source: MessageSource; readonly signal: AbortSignal } /** From 2f091ee8e6330f422d9a4a024c3062d4d3c73114 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:58:36 +0800 Subject: [PATCH 11/17] docs(record-browser-gif): publish PR GIFs with gh --attach Prefer gh v2.99.0's repeatable --attach flag for publishing demonstration GIFs: it uploads the artifact and rewrites the body's local-path reference in place, keeping media out of git history without an orphan assets branch. The assets-branch workflow remains the fallback when the GIF exceeds 10 MB, gh is older than v2.99.0, or the repository is not on github.com. --- ...08-08-browser-gif-evidence-chain.i18n.yaml | 4 +-- .../2026-08-08-browser-gif-evidence-chain.md | 8 ++--- ...026-08-08-browser-gif-evidence-chain.zh.md | 8 ++--- .agents/skills/record-browser-gif/SKILL.md | 35 +++++++++++++++---- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml index 0a540855bb..f97b5ff3cc 100644 --- a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-browser-gif-evidence-chain.md -2026-08-08-browser-gif-evidence-chain.md: b1ee54aa21949c50d69be3384a35547b752e6858 -2026-08-08-browser-gif-evidence-chain.zh.md: ab60c01409a4c7cb28ca4e1980ad9d28d6634e59 +2026-08-08-browser-gif-evidence-chain.md: ec54e0c39339b7522c4939bc050cbd216ab2134e +2026-08-08-browser-gif-evidence-chain.zh.md: 3012fc5df0937a0586dfb9788fb606c45e3d6ca1 diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md index b1ee54aa21..ec54e0c393 100644 --- a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md @@ -8,7 +8,7 @@ English | [中文](2026-08-08-browser-gif-evidence-chain.zh.md) A browser-demo storyboard can contain individually truthful screenshots without proving one truthful execution. Reusing global application state can admit old settings or sessions, capture automation can accidentally combine frames from separate model runs, and a chat transcript can show a successful fallback without exposing the tool rejection that caused it. Fuzzy accessible-name matching can also accept prompt echoes or descendant text instead of the intended result. -Headless production recording has two further boundaries. A product default may open a native operating-system surface that automation cannot drive, while replacing that surface with a mock or test hook would mean the GIF no longer shows the production path. After publication, a successful git push does not prove that a private-repository GIF is fetchable or that GitHub recognizes the pull-request Markdown as an image. +Headless production recording has two further boundaries. A product default may open a native operating-system surface that automation cannot drive, while replacing that surface with a mock or test hook would mean the GIF no longer shows the production path. After publication, a successful upload or push does not prove that the uploaded asset is reachable and intact on the review path, or that GitHub recognizes the pull-request Markdown as an image. ## Decision @@ -18,7 +18,7 @@ Browser automation waits for unique, exact semantic states. When the claim conce The available browser-control workflow remains preferred. When it is unavailable, the recorder uses the repository-declared Playwright dependency in an isolated headless browser rather than installing another driver or opening the user's browser. A native production surface may be replaced only through normal application configuration with an official browser-operable production backend, and that override is stated next to the GIF. Fixtures, mock transports, synthetic events, and test-only hooks do not substantiate a real-production claim. -Publication verifies the boundary again. The assets branch contains media only, the staged and published bytes match the verified artifact, and a private-repository asset is checked through authenticated API or raw requests for its path, byte size, checksum, response status, and media type. This proves the repository-member review path only; the [documentation-site image decision](2026-08-06-doc-site-carries-its-images.md) owns why a public site cannot depend on a private raw URL. Immediately before the pull-request body changes, the live head must still equal the recorded head. After the edit, the live head is checked again and must remain at that recorded value; GitHub's Markdown renderer separately must produce the expected image. +Publication verifies the boundary again. The preferred path attaches with `gh --attach` (v2.99.0 or later; github.com only; at most 10 MB): the body file references the local GIF path, the command uploads the verified artifact and rewrites that reference in place, and the live body must then show the rewritten uploaded URL, which must respond with `200` and `image/gif`. When attach cannot apply — the GIF exceeds 10 MB, `gh` is older, or the repository is GitHub Enterprise Server — the assets-branch path remains: the branch contains media only, the staged and published bytes match the verified artifact, and a private-repository asset is checked through authenticated API or raw requests for its path, byte size, checksum, response status, and media type, proving only the repository-member review path (the [documentation-site image decision](2026-08-06-doc-site-carries-its-images.md) owns why a public site cannot depend on a private raw URL). Immediately before the pull-request body changes, the live head must still equal the recorded head. After the edit, the live head is checked again and must remain at that recorded value; GitHub's Markdown renderer separately must produce the expected image. ## Alternatives considered @@ -28,10 +28,10 @@ Publication verifies the boundary again. The assets branch contains media only, **Replace inaccessible native UI with a fixture or test hook.** That makes automation easier by changing the product path under observation. Selecting an official production backend through normal configuration keeps the exercised implementation real and makes the narrower mode explicit. -**Trust a successful assets-branch push or an anonymous fetch.** A push proves only that git accepted bytes, while private repositories intentionally reject unauthenticated raw requests. Authenticated byte verification plus GitHub Markdown rendering tests the two publication boundaries that reviewers use. +**Trust a successful upload or push.** An upload or push proves only that GitHub accepted the bytes, not that the body reference points at the uploaded asset or that the Markdown renders the image. Re-reading the live body and rendering it through GitHub's Markdown API test the two publication boundaries reviewers use. ## Consequences GUI evidence now establishes one causal execution rather than a collage of plausible states, and reviewers can inspect both a structured tool failure and the completed result. Publication detects stale pull-request heads, corrupted or misplaced media, and invalid image Markdown before the body is treated as finished. -The workflow spends additional scratch state, may repeat a real model round after a capture failure, and usually adds a detail frame plus authenticated publication checks. Headless recordings can use fewer production backends than an interactive desktop, and every selected backend is stated next to the GIF. +The workflow spends additional scratch state, may repeat a real model round after a capture failure, and usually adds a detail frame plus publication checks — live head and body re-reads, Markdown rendering, and an uploaded-URL fetch; the assets-branch path adds its authenticated asset checks. Headless recordings can use fewer production backends than an interactive desktop, and every selected backend is stated next to the GIF. diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md index ab60c01409..3012fc5df0 100644 --- a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md @@ -8,7 +8,7 @@ Status: implemented 浏览器演示的分镜可以由每张都真实的截图组成,却无法证明这些截图来自同一次真实执行。复用应用全局状态可能引入旧设置或旧会话;录制自动化可能误将不同模型运行的画面合并;聊天 transcript(文本记录)可能显示降级处理成功,却没有揭示触发降级的工具拒绝。按无障碍名称进行模糊匹配,还可能误把提示词回显或后代文本当成预期结果。 -无头模式下的生产环境录制还有两道边界。产品默认配置可能打开自动化无法操控的原生操作系统界面,而用 mock 或测试钩子替换该界面,就意味着 GIF 不再展示生产路径。发布之后,git 推送成功也不能证明私有仓库中的 GIF 可以获取,或 GitHub 能将 PR(Pull Request)的 Markdown 识别为图片。 +无头模式下的生产环境录制还有两道边界。产品默认配置可能打开自动化无法操控的原生操作系统界面,而用 mock 或测试钩子替换该界面,就意味着 GIF 不再展示生产路径。发布之后,上传或推送成功也不能证明上传的资产在评审路径上可达且完好,或 GitHub 能将 PR(Pull Request)的 Markdown 识别为图片。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented 仍应优先使用已有的浏览器控制工作流。如果该工作流不可用,录制程序应在隔离的无头浏览器中使用仓库已声明的 Playwright 依赖,而不是安装其他驱动或打开用户的浏览器。只有通过正常应用配置选用官方且可由浏览器操作的生产后端,才能替换原生生产界面,并且必须在 GIF 旁注明这一覆盖。fixture(测试前置数据)、mock 传输层、合成事件和测试专用钩子均不能支撑真实生产实现的主张。 -发布环节会再次验证边界。资产分支只包含媒体文件,暂存和发布的字节必须与已验证产物一致;对于私有仓库中的资产,应通过经身份验证的 API 或原始内容请求,检查其路径、字节大小、校验和、响应状态和媒体类型。这只能证明仓库成员的评审访问路径;[文档站点图片决策](2026-08-06-doc-site-carries-its-images.zh.md)解释了公共站点为何不能依赖私有的原始内容 URL。修改 PR 正文之前,必须再次确认在线 head 仍与录制时的 head 相同。编辑后还要再次检查在线 head,且它必须保持为该记录值;GitHub 的 Markdown 渲染器则须单独生成预期图片。 +发布环节会再次验证边界。首选路径使用 `gh --attach` 上传(v2.99.0 或更高版本;仅限 github.com;不超过 10 MB):正文文件引用本地 GIF 路径,命令上传已验证的产物并把该引用就地改写,之后在线正文必须显示改写后的上传 URL,且该 URL 必须返回 `200` 与 `image/gif`。当 attach 无法使用时——GIF 超过 10 MB、`gh` 版本过旧或仓库位于 GitHub Enterprise Server——仍保留资产分支路径:该分支只包含媒体文件,暂存和发布的字节必须与已验证产物一致,私有仓库中的资产须通过经身份验证的 API 或原始内容请求检查其路径、字节大小、校验和、响应状态和媒体类型,这只能证明仓库成员的评审访问路径([文档站点图片决策](2026-08-06-doc-site-carries-its-images.zh.md)解释了公共站点为何不能依赖私有的原始内容 URL)。修改 PR 正文之前,必须再次确认在线 head 仍与录制时的 head 相同。编辑后还要再次检查在线 head,且它必须保持为该记录值;GitHub 的 Markdown 渲染器则须单独生成预期图片。 ## 曾考虑的替代方案 @@ -28,10 +28,10 @@ Status: implemented **使用 fixture 或测试钩子替换无法访问的原生 UI。**这种做法通过改变被观察的产品路径来简化自动化。通过正常配置选用官方生产后端,既能保持受测实现真实,也能明确表述所采用的较窄运行模式。 -**相信资产分支推送成功,或依赖匿名请求。**推送只能证明 git 接受了相应字节,而私有仓库会有意拒绝未经身份验证的原始内容请求。经身份验证的字节校验与 GitHub Markdown 渲染验证,覆盖了评审者实际使用的两道发布边界。 +**相信上传成功,或相信推送成功。**上传或推送只能证明 GitHub 接受了相应字节,不能证明正文引用指向已上传的资产,或 Markdown 能把图片渲染出来。重新读取在线正文并经 GitHub 的 Markdown API 渲染,覆盖了评审者实际使用的两道发布边界。 ## 后果 GUI 证据现在能证明一次具有因果关系的执行,而不会把不同执行中的可信画面当作同一次执行的证据;评审者既可以检查结构化的工具失败,也可以检查最终完成的结果。在 PR 正文被视为完成之前,发布验证可以发现陈旧的 PR head、损坏或位置错误的媒体文件,以及无效的图片 Markdown。 -该工作流会占用额外的临时状态;录制失败后,可能需要再运行一次由真实模型驱动的场景;通常还会增加一张详情帧和经身份验证的发布检查。相比交互式桌面,无头录制可使用的生产后端更少;每个所选后端都会在 GIF 旁说明。 +该工作流会占用额外的临时状态;录制失败后,可能需要再运行一次由真实模型驱动的场景;通常还会增加一张详情帧以及发布检查——在线 head 与正文的复读、Markdown 渲染和上传 URL 请求;资产分支路径还会增加其经身份验证的资产检查。相比交互式桌面,无头录制可使用的生产后端更少;每个所选后端都会在 GIF 旁说明。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index 3566dfb763..93a55769c0 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -1,24 +1,24 @@ --- name: record-browser-gif -description: Record browser or Web UI interaction demos as optimized GIFs using the available built-in browser, state-based frame capture, and deterministic encoding, then publish to a dedicated assets branch when the task includes attaching the GIF to a pull request. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. +description: Record browser or Web UI interaction demos as optimized GIFs using the available built-in browser, state-based frame capture, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. --- # Record Browser GIF -Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the assets-branch workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. +Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the attach workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. The [evidence-chain decision](../../notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md) owns why one storyboard comes from one isolated run and why publication revalidates both the artifact and the demonstrated pull-request head. ## Every GUI pull request includes a GIF -A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the assets-branch workflow](#publish-to-an-assets-branch). +A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the attach workflow](#publish-the-gif). The recording itself is part of the evidence: use a real server booted from that pull request's branch tree, a real API key, and real model rounds. Never substitute fixture queries, mock transports, synthetic event injection, or test-only hooks unless the user explicitly asked for a fixture recording. Next to the embed, state the exact demonstrated commit SHA, the tree and origin that served it, any mode flags or browser-state exceptions, and whether a real model round ran, so reviewers know exactly what the recording proves. ## Keep recording separate from publication - Recording produces frame images and one local `.gif` artifact only; it never mutates remote state. -- Publication — pushing the GIF to an assets branch and embedding it in a pull request body — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. +- Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. - Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture. - Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt. @@ -76,11 +76,34 @@ For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; re 3. Run `git status --short` and confirm frames and the artifact landed only under ignored paths. 4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. When the task does not include attaching the GIF to a pull request, stop here. -## Publish to an assets branch +## Publish the GIF Perform this step only when the task includes attaching the GIF to a pull request. -Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. GIFs live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`). +Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. Prefer `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media. + +### Attach with gh + +`gh --attach` requires `gh` v2.99.0 or later (`gh --version`), a repository on github.com — GitHub Enterprise Server is not supported — write access to the repository, and a GIF at or below 10 MB. Confirm the verified artifact fits that limit; when it does not, shrink it with `--max-width`, then `--colors` or `--fps`, before attaching. + +Write the GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites the reference in place to the uploaded URL, keeping its position and alt text: + +```markdown +![]() +``` + +Immediately before attaching, re-read the pull request's live head — for a new pull request, the pushed branch tip — and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. Then attach: + +```sh +gh pr create --body-file --attach # new pull request +gh pr edit --body-file --attach # existing pull request +``` + +`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '#'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected ``, and fetch the uploaded URL once to confirm `200` and `image/gif`. + +### Fall back to an assets branch + +Use the assets-branch workflow only when `gh --attach` cannot apply: the GIF still exceeds 10 MB, `gh` is older than v2.99.0, or the repository is not on github.com. GIFs then live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`). Before either workflow below pushes, verify that the assets branch contains media only and that the staged GIF's checksum matches the verified local artifact. From ef88756f13c73df88eb1f1cf82281d20a62d9853 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:02:10 +0800 Subject: [PATCH 12/17] feat(session, agent, web): support same-session message editing --- .agents/notes/archived/manifest.json | 3 + ...7-31-drop-user-message-edit-stub.i18n.yaml | 6 + .../2026-07-31-drop-user-message-edit-stub.md | 1 + ...26-07-31-drop-user-message-edit-stub.zh.md | 1 + ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 12 +- ...07-29-human-transcript-append-origin.zh.md | 12 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 4 +- ...eb-transcript-log-ordered-projection.zh.md | 4 +- ...-same-session-user-message-edit.i18n.yaml} | 6 +- ...26-09-01-same-session-user-message-edit.md | 49 ++ ...09-01-same-session-user-message-edit.zh.md | 49 ++ .../markdown-cjk-strong/ui.expected.md | 2 + .../expected/markdown-images/ui.expected.md | 2 + .../markdown-inline-code-links/ui.expected.md | 2 + .../expected/math-rendering/ui.expected.md | 2 + .../message-edit/replay.override.json | 12 + .../reference-composer/order.expected.md | 2 + .../skill-user-invoke/ui-expanded.expected.md | 2 + .../expected/skill-user-invoke/ui.expected.md | 2 + .../stats-paged-history/ui.expected.md | 2 + .../expected/steer-all/mid-steer.expected.md | 2 + apps/web/tests/message-actions.e2e.ts | 6 +- apps/web/tests/message-edit.e2e.ts | 90 +++ .../mid-stream.expected.md | 2 + apps/web/tsconfig.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 7 +- docs/architecture.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 6 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 42 +- docs/event-producer-consumer.zh.md | 42 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 13 +- docs/persistence-catalog.zh.md | 13 +- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 12 +- docs/subsystems/conversation.zh.md | 12 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 39 +- docs/subsystems/core.zh.md | 39 +- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 6 +- docs/subsystems/session-query.zh.md | 6 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 4 +- docs/subsystems/session-title.zh.md | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 68 +- docs/subsystems/session.zh.md | 68 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 6 +- docs/tool-catalog.zh.md | 6 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 10 +- packages/api/session-controller/README.zh.md | 10 +- .../src/client/contract/session.ts | 14 + .../src/client/contract/snapshot.ts | 18 +- .../src/client/sessions/session.ts | 60 +- .../api/session-controller/src/commands.ts | 368 ++++++++- .../api/session-controller/src/history.ts | 11 +- packages/api/session-controller/src/index.ts | 14 + packages/api/session-controller/src/types.ts | 30 + .../tests/fake-api.client.ts | 5 + .../tests/session-edit.host.spec.ts | 723 ++++++++++++++++++ .../session-history-journal.host.spec.ts | 45 ++ ...session-pending-submissions.client.spec.ts | 61 +- .../session-controller/tests/test-remote.ts | 7 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 12 +- packages/client/ui-chat/README.zh.md | 12 +- packages/client/ui-chat/src/client/apply.ts | 4 + .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 5 +- .../ui-chat/src/client/chat/ChatView.tsx | 80 +- .../src/client/chat/MessageIconActions.tsx | 17 +- .../src/client/chat/MessageItem.module.css | 88 +++ .../ui-chat/src/client/chat/MessageItem.tsx | 177 ++++- .../ui-chat/src/client/contract/slots.ts | 16 + .../ui-chat/src/client/contract/snapshot.ts | 3 + .../chat-snapshot-builder.ts | 19 +- .../src/client/conversation-nodes/message.ts | 3 +- packages/client/ui-chat/src/client/locale.ts | 6 + .../tests/apply-inject.client.spec.tsx | 23 + .../ui-chat/tests/chat-view.client.spec.tsx | 167 +++- .../tests/gate-branch-tails.client.spec.tsx | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/contract/conversation.ts | 8 + .../src/client/conversation/assembler.ts | 32 +- .../src/client/conversation/assembly.ts | 27 +- .../src/client/conversation/presentation.ts | 85 ++ .../ui-conversation/src/client/index.ts | 2 +- .../conversation-presentation.client.spec.ts | 65 ++ .../conversation-registry.client.spec.ts | 2 + .../tests/queue-dock.client.spec.tsx | 1 + .../schedule-catalog-action.client.spec.tsx | 1 + .../tests/ui-session.client.spec.ts | 1 + .../ui-trajectory/tests/views.client.spec.tsx | 1 + .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + .../compaction-basic/README.i18n.yaml | 4 +- .../compaction/compaction-basic/README.md | 2 +- .../compaction/compaction-basic/README.zh.md | 2 +- .../compaction/compaction-basic/src/index.ts | 5 +- .../tests/compaction-basic.spec.ts | 55 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 45 +- .../agent-loop/tests/interception.spec.ts | 98 +++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 8 +- packages/core/agent/README.zh.md | 8 +- packages/core/agent/src/inbox.ts | 102 ++- packages/core/agent/src/runtime-types.ts | 21 +- packages/core/agent/src/types.ts | 17 +- packages/core/agent/tests/agent.spec.ts | 73 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 5 +- packages/core/session/README.zh.md | 5 +- packages/core/session/package.json | 4 + packages/core/session/src/conversation.ts | 127 +++ packages/core/session/src/index.ts | 26 +- packages/core/session/src/types.ts | 17 +- .../core/session/tests/conversation.spec.ts | 120 +++ .../src/client/api-catalog.ts | 10 +- .../src/client/slot-catalog.ts | 15 +- .../extensions/tool-cordis/src/api-catalog.ts | 30 +- .../session-query-sqlite/tests/sqlite.spec.ts | 50 ++ .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 6 +- .../session-query/session-query/README.zh.md | 6 +- .../session-query/src/documents.ts | 5 + .../session-query/src/tracing.ts | 10 +- .../session-query/session-query/src/types.ts | 2 +- .../session-query/tests/tracing.spec.ts | 32 + .../tool-session-query/README.i18n.yaml | 4 +- .../tool-session-query/README.md | 2 +- .../tool-session-query/README.zh.md | 2 +- .../tool-session-query/src/input.ts | 16 +- .../tool-session-query/src/operations.ts | 4 +- .../tests/tool-session-query.spec.ts | 4 +- .../session-persistence/tests/contract.ts | 29 +- .../session/session-title/README.i18n.yaml | 4 +- packages/session/session-title/README.md | 4 +- packages/session/session-title/README.zh.md | 4 +- packages/session/session-title/src/index.ts | 40 +- .../session-title/tests/provider.spec.ts | 98 +++ .../session-turn-outline/README.i18n.yaml | 4 +- .../session/session-turn-outline/README.md | 2 +- .../session/session-turn-outline/README.zh.md | 2 +- .../session-turn-outline/src/projection.ts | 21 +- .../tests/projection.spec.ts | 64 +- .../client-runtime/src/fixtures.ts | 1 + .../client-runtime/src/sessions.ts | 5 + .../tests/runtime.client.spec.tsx | 1 + scripts/gen-cordis-catalog.ts | 4 + scripts/type-equiv.manifest.json | 35 + .../tool-schemas.expected.json | 6 +- snapshots/web/bash-abort-row/ui.expected.md | 2 + .../web/cordis-tool-round/ui.expected.md | 2 + .../feedback-command/ack-expanded.expected.md | 2 + .../web/feedback-command/ack.expected.md | 2 + .../feedback-release/ack-expanded.expected.md | 2 + .../web/feedback-release/ack.expected.md | 2 + .../fresh-round-trip/ui-expanded.expected.md | 2 + snapshots/web/fresh-round-trip/ui.expected.md | 2 + .../reloaded-expanded.expected.md | 2 + .../web/lifecycle-chrome/reloaded.expected.md | 2 + .../cancel-expanded.expected.md | 2 + .../web/live-interactions/cancel.expected.md | 2 + .../live-interactions/error-auth.expected.md | 2 + .../web/live-interactions/loading.expected.md | 2 + .../retry-exhausted.expected.md | 2 + .../retry-expanded.expected.md | 2 + .../web/live-interactions/retry.expected.md | 2 + .../running-draft.expected.md | 2 + snapshots/web/message-actions/ui.expected.md | 2 + snapshots/web/message-edit/snapshot.yml | 9 + snapshots/web/message-edit/ui.expected.md | 44 ++ snapshots/web/minimal-preset/ui.expected.md | 2 + snapshots/web/ptc-round/ui.expected.md | 2 + .../answered-expanded.expected.md | 2 + .../question-composer/answered.expected.md | 2 + .../question-composer/cancelled.expected.md | 2 + .../web/queue-actions/collapsed.expected.md | 2 + .../web/queue-actions/editing.expected.md | 2 + .../preserved-expanded.expected.md | 2 + .../web/queue-actions/preserved.expected.md | 2 + snapshots/web/queue-actions/ui.expected.md | 2 + .../web/queued-image/delivered.expected.md | 2 + snapshots/web/queued-image/queued.expected.md | 2 + snapshots/web/skill-tool-row/ui.expected.md | 2 + snapshots/web/steering/mid-steer.expected.md | 2 + .../turn-tail-actions/completed.expected.md | 2 + .../web/turn-tail-actions/focused.expected.md | 2 + .../web/turn-tail-actions/running.expected.md | 2 + .../web/turn-tail-actions/settled.expected.md | 2 + .../usage-expanded.expected.md | 2 + snapshots/web/web-search-round/ui.expected.md | 2 + snapshots/web/workflow-run/ui.expected.md | 2 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 208 files changed, 4057 insertions(+), 348 deletions(-) create mode 100644 .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-31-drop-user-message-edit-stub.md (98%) rename .agents/notes/{implemented => archived}/simplification/2026-07-31-drop-user-message-edit-stub.zh.md (98%) rename .agents/notes/implemented/{simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml => feature/2026-09-01-same-session-user-message-edit.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md create mode 100644 .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md create mode 100644 apps/web/tests/expected/message-edit/replay.override.json create mode 100644 apps/web/tests/message-edit.e2e.ts create mode 100644 packages/api/session-controller/tests/session-edit.host.spec.ts create mode 100644 packages/client/ui-conversation/src/client/conversation/presentation.ts create mode 100644 packages/client/ui-conversation/tests/conversation-presentation.client.spec.ts create mode 100644 packages/core/session/src/conversation.ts create mode 100644 packages/core/session/tests/conversation.spec.ts create mode 100644 snapshots/web/message-edit/snapshot.yml create mode 100644 snapshots/web/message-edit/ui.expected.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index fa3abbea4f..3238b019df 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -469,6 +469,9 @@ "simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml": "sha256:ad42430fef4a5db610f8c56acfcda79b40396418694f664a5b0fb093bff1f114", "simplification/2026-07-30-sidebar-resize-without-visible-pill.md": "sha256:6f2cfc5121371ec19c7178b31777c223e16bebdc9b63b7654a61cadc4a765b63", "simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md": "sha256:ab859eeb12c6a74da3c37d411af52fce37bed3195941570b2f23ccd9a00d55fe", + "simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml": "sha256:b1c80890f8ad1fb6a62536f2dfcd44879e66fbce4e36ca471dde6734faee8b4a", + "simplification/2026-07-31-drop-user-message-edit-stub.md": "sha256:7c3cc891454822e99b9574a9db296d890261bcb4f86077343e049c757c8f6bdb", + "simplification/2026-07-31-drop-user-message-edit-stub.zh.md": "sha256:1725034af33f8f45157a6e244ee51c7582fff62f2ea54aade570746c9f0c1ef1", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438", diff --git a/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml new file mode 100644 index 0000000000..91b829274f --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md +2026-07-31-drop-user-message-edit-stub.md: 06b3f7cf1096b1a02ae0efeeec94e1a86a5a4b10 +2026-07-31-drop-user-message-edit-stub.zh.md: cad2981ed0aabd356570291a0039dfed207d0ee2 diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md rename to .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md index 5a34be1dd2..06b3f7cf10 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md +++ b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md @@ -1,6 +1,7 @@ # Agent Note: Drop the user-message edit stub Status: implemented +Archived: 2026-09-01 English | [中文](2026-07-31-drop-user-message-edit-stub.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md rename to .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md index 288f2bda5d..cad2981ed0 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md +++ b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除 user 消息的编辑存根 Status: implemented +Archived: 2026-09-01 [English](2026-07-31-drop-user-message-edit-stub.md) | 中文 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 d8a34eb622..633687f4cd 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: 00f4cf09139bdba4c0fffcdc476b698d098154bc -2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966 +2026-07-29-human-transcript-append-origin.md: 8e5f35312546d140234553498c00f1dc2ca175cb +2026-07-29-human-transcript-append-origin.zh.md: f0402d0f7ad8f1afab8babfde4aadc4902724737 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 00f4cf0913..8e5f353125 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 @@ -12,19 +12,19 @@ Nothing was lost from the log. `Session.snapshotEvents()` still returned every o ## Decision -Model and human projections are separate, and the event's own marker decides which one an event belongs to. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the durable source for a transcript; replacement copies stay model-only. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`. +Model and human projections are separate. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the default durable source for a transcript; replacement copies stay model-only unless a replacement `user/message` carries the explicit `conversationOp` introduced by [same-session user-message editing](../feature/2026-09-01-same-session-user-message-edit.md). That operation starts a new visible generation and hides its declared raw-event range without deleting it. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`. The terminal replays the transcript from append-origin surface events and keeps a shadowed step's tool cards paired through `transcriptToolCallIds`, which reads the append-origin `assistant/message` rather than surface membership. A landed compaction contributes one dim `… earlier context was compacted …` row at its own log position: the marker reports where the model stopped seeing that history instead of erasing it. The framed checkpoint payload never renders, and both paths classify a surface event by the same marker, so a compaction that arrives live and the same log replayed after resume produce the same transcript. Only replay re-derives `tool/call` pairing: a call event carries no marker of its own and inherits membership from the `assistant/message` that advertised it, which the live listener has necessarily just rendered. -A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactionEngine` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. +A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactionEngine` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Replacement events without `conversationOp` remain silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. -`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compaction/summary` event stays on the page of the replacement that cites it. +`session.history` counts append-origin messages and explicit conversation replacements toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compaction/summary` event stays on the page of the replacement that cites it and an edited generation can retain its hidden raw interval for lossless replay. -No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. +Compaction still changes no user-facing replacement state. Same-session Edit adds its own persisted `conversationOp` and RPC operation without changing compaction transactions. ## Deferred -The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page. +The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the default append-origin transcript in log order, applies explicit edited-generation ranges before assembly, and renders a compaction marker component. It also closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page. The terminal's [archived live compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction. @@ -38,7 +38,7 @@ The terminal's [archived live compaction progress decision](../../archived/featu **Derive the marker from the `compaction/*` bracket instead of the checkpoint.** Rejected for the transcript: the bracket is a pair of time-point markers around an operation, while the transcript needs the position where the surface actually changed. The bracket is the right source for progress and duration, which this change does not render. -**Classify events by re-folding the log, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: a fold answers a whole-log question, while a projection asks a per-event one that the event's own marker already answers in constant time. +**Classify every replacement through the model-surface fold, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: compaction transcript membership remains a per-event marker question. Same-session Edit instead carries a distinct `conversationOp` whose whole-log fold answers the different question of which visible generation is current. ## Consequences 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 631ba71d84..f0402d0f7a 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 @@ -12,19 +12,19 @@ Status: implemented ## 决策 -模型投影与人类投影是分开的,而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是 transcript 的持久来源,替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。 +模型投影与人类投影是分开的。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源事件是 transcript 的默认持久来源;除非替换型 `user/message` 携带[同会话用户消息编辑](../feature/2026-09-01-same-session-user-message-edit.zh.md)引入的显式 `conversationOp`,替换副本都只供模型使用。该操作开启一代新的可见对话,并隐藏其声明的原始事件区间而不删除它。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。 终端从追加来源的 surface 事件回放 transcript,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且两条路径都按同一个标记对 surface 事件分类,因此实时到达的压缩与恢复后回放同一份日志会产生相同的 transcript。只有回放会重新推导 `tool/call` 的配对关系:调用事件自身不携带标记,其归属继承自公布它的 `assistant/message`,而实时监听器必然刚刚渲染过后者。 -检查点通过压缩 seam 自身的约定来识别——`isCompactCheckpointSource`,即 `CompactionEngine` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。 +检查点通过压缩 seam 自身的约定来识别——`isCompactCheckpointSource`,即 `CompactionEngine` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。没有 `conversationOp` 的替换事件保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。 -`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compaction/summary` 事件会与引用它的替换留在同一页。 +`session.history` 把追加来源消息与显式对话替换计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compaction/summary` 事件会与引用它的替换留在同一页,编辑后的代次也可以保留其隐藏原始区间用于无损回放。 -持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移。 +压缩仍不改变用户可见替换状态。同会话 Edit 增加独立的持久 `conversationOp` 与 RPC 操作,不改变压缩事务。 ## 延后事项 -浏览器客户端在[Web transcript 投影笔记](2026-07-30-web-transcript-log-ordered-projection.zh.md)中单独修复:它按日志顺序投影同一份追加来源 transcript 并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。 +浏览器客户端在[Web transcript 投影笔记](2026-07-30-web-transcript-log-ordered-projection.zh.md)中单独修复:它按日志顺序投影默认的追加来源 transcript,在组装前应用显式编辑代次区间,并渲染一个压缩标记组件。它同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。 终端的[已归档实时压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。 @@ -38,7 +38,7 @@ Status: implemented **用 `compaction/*` 括号而不是检查点来推导标记。** 就 transcript 而言被否决:括号是围绕一次操作的一对时间点标记,而 transcript 需要的是 surface 真正发生变化的位置。括号适合作为进度与耗时的来源,而本次变更并不渲染这些。 -**像 `session-query` 为搜索所做的那样重新折叠日志来分类事件(`current`/`shadowed`/`log-only`)。** 被否决:折叠回答的是整份日志的问题,而投影问的是逐事件的问题,事件自身的标记已能以常数时间给出答案。 +**像 `session-query` 为搜索所做的那样,通过模型 surface 折叠分类每个替换(`current`/`shadowed`/`log-only`)。** 被否决:压缩的 transcript 成员关系仍是逐事件标记问题。同会话 Edit 改用独立的 `conversationOp`;其整日志折叠回答的是另一项问题——哪一代可见对话是当前代次。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index deb8efc160..9fba3b7c24 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a -2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9 +2026-07-30-web-transcript-log-ordered-projection.md: 77873cd9df32d1c7970731a8158284d1b4869a82 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 7ccc2733dab18ccb326558bbb939c152b21d7f2b diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index ed6fa4df3a..77873cd9df 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -12,7 +12,7 @@ Surface order made two further problems structural. It is not seq-ascending afte ## Decision -`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It 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. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends. +`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the retained raw window in log order: append-origin surface events (`isAppendSurfaceEvent`) at their own log positions, one `CompactionSummaryNode` marker per landed compaction checkpoint, and the explicit replacement `user/message` from [same-session user-message editing](../feature/2026-09-01-same-session-user-message-edit.md). Before assembly, that feature's `conversationOp` fold removes the prior visible generation it names. A landed compaction therefore keeps the conversation it shadowed on the model side, while an Edit deliberately replaces its old user-facing range. Other model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two remain separate on both frontends. Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged. @@ -69,7 +69,7 @@ Compaction no longer erases web history; a session compacted several times shows `ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor. -The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. +The incremental performance contract remains: one ordinary append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not recomputed — and unchanged nodes keep their object identity. A `conversationOp` append deliberately rebuilds the loaded window once so every target publishes one coherent generation. The raw window still grows with session length even when Edit hides older generations, which preserves lossless paging at the cost of scanning retained events during that rebuild. The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index cf8dc35099..7ccc2733da 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -12,7 +12,7 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 ## Decision -`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影原始窗口:每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话,标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者现在在两个前端上都已分离。 +`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影保留的原始窗口:append 来源的 surface 事件(`isAppendSurfaceEvent`)落在各自日志位置上,每次落地的压缩检查点对应一个 `CompactionSummaryNode` 标记,[同会话用户消息编辑](../feature/2026-09-01-same-session-user-message-edit.zh.md)则提供显式替换 `user/message`。组装前,该功能的 `conversationOp` 折叠会移除它所标识的先前可见代次。因此,一次落地的压缩会保留它在模型侧遮蔽的对话,而一次 Edit 会有意替换其旧用户可见区间。其他仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者在两个前端上继续分离。 节点顺序天然按 seq 单调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折叠成 `CommandNode`,按 seq 插入一个本已单调的数组——无锚点,无重排。`Session` 保留被打断的冻结节点的归属,用一次普通排序按其分数 seq 归并,而这现在恰好就是流顺序。检查点所引被遮蔽范围落在窗口之外的窗口没有范围需要解析,因此标记正常渲染且不打印任何日志。 @@ -69,7 +69,7 @@ const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' `ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。 -性能约定未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。 +增量性能约定保持不变:一次普通 append 物化一个 node;不改变 node 的 event 保留旧数组引用——因此 chunk 风暴不产生任何开销,也不会重新计算 `nodes()`——未变化的 node 仍保持对象 identity。一次 `conversationOp` append 会有意重建一次已加载窗口,使每个 target 只发布一份完整代次。即使 Edit 隐藏较旧代次,原始窗口仍随 Session 长度增长;这保留了无损分页,但替换重建时需要扫描保留事件。 Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.i18n.yaml similarity index 56% rename from .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.i18n.yaml index e23f925416..285a2abcb8 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.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/simplification/2026-07-31-drop-user-message-edit-stub.md -2026-07-31-drop-user-message-edit-stub.md: 5a34be1dd285f4aced9e6cfb2e324b20ec734bed -2026-07-31-drop-user-message-edit-stub.zh.md: 288f2bda5d41cf0a331f69f134c8cf1e61a801ef +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md +2026-09-01-same-session-user-message-edit.md: 15f6bb36606037ddc04126195fcfeb645a61a3b3 +2026-09-01-same-session-user-message-edit.zh.md: 16eb5e156016a27d075bb1eccbb0358273d0f758 diff --git a/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md new file mode 100644 index 0000000000..15f6bb3660 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md @@ -0,0 +1,49 @@ +# Agent Note: Same-session user-message editing + +Status: implemented + +English | [中文](2026-09-01-same-session-user-message-edit.zh.md) + +## Problem + +Correcting a sent message required creating a fork even when the user wanted to keep working in the same Session. The [earlier removal of the unbacked Edit control](../../archived/simplification/2026-07-31-drop-user-message-edit-stub.md) correctly withheld that affordance until a Host operation existed. Reusing model-surface replacement alone was insufficient: compaction intentionally removes old messages only from model context, while Edit must also replace the visible Chat, Trajectory, turn-navigation, title-input, and search generation without deleting audit data. A running turn, queued prompts, attachments, and session-reference context also make a same-session retry more than a UI text mutation. + +## Decision + +The ordinary-Session `session.edit` operation accepts only the latest human `user/message`, and that message must open a turn and remain in the current model surface. The request identifies the target event and carries the latest human-message seq observed when the editor opened. The Host validates both before any interruption and repeats validation while it owns idle maintenance; a later human message makes the request stale. Earlier messages, direct subagent Sessions, steering messages, compacted messages, and messages without editable text are rejected. + +If the Agent is running, Edit cancels the active turn with `keepInbox: true` and reserves maintenance synchronously at the idle transition. It inserts the replacement at the front of `next-turn`, so the edited rerun precedes existing Queue work without deleting it. The durable inbox admission carries the replacement's `SurfaceIntent` and any retained session-reference messages; AgentLoop expands those companions immediately after the edited prompt and follows the primary message by `MessageId` through `agent/pre-step` rewrites. The pre-step payload exposes claimed intents, and automatic compaction defers while a preplanned surface replacement is pending so its coordinates remain valid through commit. A restart after admission therefore preserves both replacement semantics and reference ordering. + +The replacement message keeps the target's non-text content, uses the submitted text and a new timestamp, and runs with the Session's current model selection. An image-bearing edit validates that model before interrupting active work. Existing session-reference recall messages are copied rather than regenerated, so the rerun uses the snapshot the original prompt cited. Edit does not rewind files, processes, background work, subagents, or any other external side effect. + +The committed replacement `user/message` carries two independent operations. Its `surfaceOp` replaces the current model-message suffix from the edited turn onward, and `sourceEventSeqs` names every removed surface node. Its `conversationOp` hides the inclusive raw-event range from that turn's `turn/start` through the pre-admission log tail from current user-facing projections. All old events remain in the append-only log. + +`ui-conversation` folds `conversationOp` ranges before every target assembles nodes and rebuilds the loaded window atomically when a replacement arrives. Chat therefore never publishes a mixed old/new generation. The turn outline removes hidden turns. A replacement cancels pending or active automatic title work without scheduling a new revision; a later ordinary title revision reads only the current conversation generation. Session Query classifies every searchable event in a hidden edit range as `shadowed`; model-facing search defaults to `current` plus `log-only` and does not expose `shadowed`, while exact reads and traces remain available for diagnostics. Lossless Session export and whole-log statistics retain both generations. + +Chat exposes Edit only on that latest eligible message. Editing uses a full Chat-width, composer-style input card with input-surface fill, border, elevation, and an internal cancel/save row. The textarea starts at 80px, grows with its content to 240px, and then scrolls internally. Enter submits, Shift+Enter inserts a newline, Escape cancels, and the normal composer remains enabled. Starting another submission closes the editor. Submission immediately replaces the selected suffix with a local echo; failure restores the durable view, while success hands off to the replacement event. The result has no edited badge or Undo action, and the existing Session title remains unchanged. + +The shipped scope is the Edit part of [issue #2351](https://github.com/deepseek-harness/deepseek-harness/issues/2351). Fork and Rewind remain separate behavior. + +## Alternatives considered + +**Always fork before rerunning.** Rejected because it changes Session identity and leaves the user to choose between correction and branching. Fork remains useful when both histories should stay independently navigable. + +**Mutate or delete the original events.** Rejected because Session persistence, replay, diagnostics, and lossless export depend on an append-only log. A replacement event records the new generation while retaining the prior facts. + +**Treat every `SurfaceOp` replacement as a human transcript replacement.** Rejected because compaction and result pruning intentionally change model context without erasing what the user already saw. `conversationOp` is explicit and independent. + +**Reuse the pending Queue editor.** Rejected because a queued message has not entered model history, while a historical edit must replace consumed context, interrupt current work safely, and rerun. + +**Clear Queue or apply it in the edited request.** Rejected because Queue entries remain user-owned future turns. The edited turn runs first and leaves a window in which the user may still remove queued work. + +**Provide Undo together with Edit.** Rejected because undoing after new model output requires another explicitly defined generation replacement and conflict policy. The retained raw events preserve the data without advertising an unsupported reversal. + +## Consequences + +One edit adds a new turn plus durable inbox and replacement metadata; storage, lossless export, and whole-log statistics grow with every generation. Current conversation views and default model search omit replaced generations, while diagnostic reads can still inspect them. + +Conversation-range lookup is logarithmic in the number of merged edit ranges for each event. The SQLite provider already rebuilds documents for a changed live Session; Edit adds classification work but no second text index or delete/reinsert protocol for historical rows. + +The unchanged prefix before the first replaced model message remains eligible for provider KV-cache reuse. The edited suffix starts a new request series and incurs normal request and response tokens. + +Unit coverage pins range validation and folding, durable inbox replay, pre-step intent retention, Queue priority, interruption, attachment and reference preservation, title and outline behavior, search classification, and optimistic UI handoff. The Web replay e2e pins edit, rerun, hidden old output, and refresh reconstruction through the real Host and browser. diff --git a/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md new file mode 100644 index 0000000000..16eb5e1560 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 同会话用户消息编辑 + +Status: implemented + +[English](2026-09-01-same-session-user-message-edit.md) | 中文 + +## 问题 + +即使用户希望留在同一个 Session 中继续工作,修正已发送消息也必须创建 fork。[此前移除无后端 Edit 控件的决策](../../archived/simplification/2026-07-31-drop-user-message-edit-stub.md)正确地在 Host 操作存在前隐藏了这项可供性。仅复用模型 surface 替换并不够:压缩有意只从模型上下文移除旧消息,而 Edit 还必须替换 Chat、Trajectory、轮次导航、标题输入和搜索中的可见对话代次,同时不能删除审计数据。运行中的轮次、已排队提示词、附件和会话引用上下文也使同会话重跑不只是一次 UI 文本修改。 + +## 决策 + +普通 Session 的 `session.edit` 操作只接受最新的人工 `user/message`,且该消息必须用于开启轮次并仍位于当前模型 surface。请求标识目标事件,并携带编辑器打开时观测到的最近人工消息 seq。Host 会在任何中断前进行校验,并在持有 idle maintenance 时再次校验;后续人工消息会使请求变为 stale。更早的消息、direct subagent Session、steering 消息、已压缩消息和没有可编辑文本的消息都会被拒绝。 + +如果 Agent 正在运行,Edit 会用 `keepInbox: true` 取消活跃轮次,并在其进入 idle 时同步取得 maintenance。它把替换消息插到 `next-turn` 前端,因此编辑重跑先于已有 Queue 工作执行,但不会删除 Queue。持久 inbox 准入会携带替换消息的 `SurfaceIntent` 与所有保留的会话引用消息;AgentLoop 在编辑提示词之后立即展开这些配套消息,并通过 `MessageId` 让非默认放置方式穿过 `agent/pre-step` 重写。pre-step payload 会公开已领取的 intent,主动压缩在预定 surface 替换待提交时推迟,因此替换坐标在提交前保持有效。即使准入后发生重启,替换语义和引用顺序仍会保留。 + +替换消息保留目标消息的非文本内容,使用提交的新文本和新时间戳,并采用 Session 当前的模型选择。带图片的编辑会在中断活跃工作前验证该模型。已有会话引用 recall 消息会被复制而不重新生成,因此重跑使用原提示词引用的快照。Edit 不会回退文件、进程、后台任务、subagent 或任何其他外部副作用。 + +已提交的替换 `user/message` 携带两个独立操作。其 `surfaceOp` 替换从编辑轮次开始的当前模型消息后缀,`sourceEventSeqs` 列出每个被移除的 surface 节点;其 `conversationOp` 从当前用户可见投影中隐藏自该轮 `turn/start` 到准入前日志末尾的原始事件闭区间。所有旧事件仍保留在仅追加日志中。 + +`ui-conversation` 在每个 target 组装 node 前折叠 `conversationOp` 区间,并在替换到达时原子重建已加载窗口,因此 Chat 不会发布新旧代次混合的中间状态。轮次大纲会移除被隐藏的轮次。替换消息会取消待执行或正在运行的自动标题工作,但不会安排新的 revision;后续普通标题 revision 只读取当前对话代次。Session Query 把编辑隐藏区间内每个可搜索事件分类为 `shadowed`;面向模型的搜索默认只查询 `current` 与 `log-only`,且不公开 `shadowed`,精确读取与追踪仍可用于诊断。无损 Session 导出与全日志统计保留两代内容。 + +Chat 只在该最新且符合条件的消息上公开「编辑消息」。编辑使用铺满 Chat 内容列的 composer 风格输入卡片,带输入表面背景、边框、阴影和卡片内部的取消/保存操作行。文本框初始高度为 80px,随内容增长至 240px 后改为内部滚动。Enter 提交,Shift+Enter 插入换行,Escape 取消,普通 composer 保持可用。开始另一条提交会关闭编辑器。提交会立刻用本地回显替换选中后缀;失败恢复持久视图,成功则交接给替换事件。结果不显示已编辑标记,也没有 Undo,已有 Session 标题保持不变。 + +已交付范围是 [issue #2351](https://github.com/deepseek-harness/deepseek-harness/issues/2351) 的 Edit 部分。Fork 与 Rewind 仍是独立行为。 + +## 曾考虑的替代方案 + +**每次重跑前都创建 fork。** 被否决,因为它会改变 Session 身份,并迫使用户在修正与分支之间做选择。需要让两份历史都可独立导航时,fork 仍然有用。 + +**修改或删除原事件。** 被否决,因为 Session 持久化、回放、诊断与无损导出都依赖仅追加日志。替换事件记录新代次,同时保留先前事实。 + +**把每个 `SurfaceOp` 替换都视为人类 transcript 替换。** 被否决,因为压缩与结果裁剪有意改变模型上下文,却不抹掉用户已经看到的内容。`conversationOp` 是显式且独立的机制。 + +**复用待处理 Queue 编辑器。** 被否决,因为已排队消息尚未进入模型历史,而历史编辑必须替换已消费上下文、安全中断当前工作并重新运行。 + +**清空 Queue,或把它应用到编辑请求中。** 被否决,因为 Queue 条目仍是用户拥有的未来轮次。编辑轮次先运行,并留出用户继续删除排队工作的时间窗口。 + +**让 Undo 与 Edit 同时提供。** 被否决,因为在新模型输出出现后撤销需要再定义一套显式代次替换和冲突策略。保留的原始事件保存了数据,但不会宣告尚不受支持的反转操作。 + +## 后果 + +每次编辑都会增加一个新轮次,以及持久 inbox 与替换元数据;存储、无损导出和全日志统计都会随每一代内容增长。当前对话视图与默认模型搜索省略被替换代次,诊断读取仍可检查它们。 + +对每个事件进行对话区间成员判断的复杂度,与合并后编辑区间数量呈对数关系。SQLite 提供方本就会为变化后的实时 Session 重建文档;Edit 增加分类工作,但不引入第二份文本索引,也不需要删除并重插历史行。 + +首条被替换模型消息之前未变化的前缀仍可复用提供方 KV Cache。编辑后的后缀会开启新的请求序列,并正常产生请求与响应 token。 + +单元测试固定区间校验与折叠、持久 inbox 回放、pre-step intent 保留、Queue 优先级、中断、附件与引用保留、标题与大纲行为、搜索分类和乐观 UI 交接。Web replay e2e 通过真实 Host 与浏览器固定编辑、重跑、隐藏旧输出和刷新重建。 diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 2fba524c5d..3247c10cce 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -10,6 +10,8 @@ - text: Render adjacent CJK strong emphasis. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3843143d1f..92d307148f 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -10,6 +10,8 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 2d2127cbb2..2f09de2539 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -10,6 +10,8 @@ - text: Show the local preview URL. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index fba05ffe71..73df5ca99b 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -10,6 +10,8 @@ - text: Render this mathematical proof. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar diff --git a/apps/web/tests/expected/message-edit/replay.override.json b/apps/web/tests/expected/message-edit/replay.override.json new file mode 100644 index 0000000000..cc28bb4c5e --- /dev/null +++ b/apps/web/tests/expected/message-edit/replay.override.json @@ -0,0 +1,12 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "EDITED_OK" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "EDITED_OK" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7cec2eaf08..324cf5f8c2 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -10,6 +10,8 @@ - text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Session recall Research notes": - img - text: Session recall Research notes diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 07be7102ff..1ee0e77114 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -16,6 +16,8 @@ - text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Thought for a while" [expanded]: - text: Thought for a while - img diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 7ddf071159..a0adedfb64 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -16,6 +16,8 @@ - text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Thought for a while": - text: Thought for a while - img diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index fc8039e282..e8a2d5f4f3 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -444,6 +444,8 @@ - text: 7/25 {{clock}} m28 7/25 {{clock}} - button "Copy": - img +- button "Edit message": + - img - paragraph: r28 - button "Copy": - img diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c3847df394..048390e038 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -16,6 +16,8 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index c29d9cf977..5535fd43b9 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -114,8 +114,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). Branch renders only under assistant answers — user - // bubbles carry none — and only a completed transcript tail enables it. + // hover/focus-within). Branch renders only under assistant answers, while + // only the latest current turn-opening user message exposes Edit. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() @@ -128,7 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await branchButtons.first().focus() await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) .toBe('Available only on the last message of a completed turn') - await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) + await expect.poll(() => page.getByRole('button', { name: 'Edit message' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { diff --git a/apps/web/tests/message-edit.e2e.ts b/apps/web/tests/message-edit.e2e.ts new file mode 100644 index 0000000000..a944915395 --- /dev/null +++ b/apps/web/tests/message-edit.e2e.ts @@ -0,0 +1,90 @@ +// Web e2e: same-session user-message Edit replaces the selected turn and +// reruns directly without creating a fork or exposing the old generation. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +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/web/message-edit', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url)) +const OVERRIDE = fileURLToPath(new URL('./expected/message-edit/replay.override.json', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SESSION_ID = 'message-edit-web-e2e' +const ORIGINAL = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const EDITED = 'Use the existing context and reply with exactly EDITED_OK.' + +describe('web e2e: edit a historical user message in the same Session', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + replayFixture: SEED, + replayOverride: OVERRIDE, + compareReplaySession: false, + }) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SESSION_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('replaces the old generation, reruns, and survives refresh', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-edit')) + await page.locator('[role="treeitem"]').first().click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.click() + await page.getByText(ORIGINAL, { exact: true }).waitFor({ timeout: 15_000 }) + + await page.getByRole('button', { name: 'Edit message' }).click() + const editor = page.getByRole('textbox', { name: 'Edit message' }) + await editor.fill(EDITED) + const settled = scaffold.whenTurnSettled(30_000) + await editor.press('Enter') + await settled + + await page.getByText(EDITED, { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByText('EDITED_OK', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByText(ORIGINAL, { exact: true }).count()).toBe(0) + expect(await page.getByText('DONE', { exact: true }).count()).toBe(0) + const live = scaffold.ctx.sessions.get(SessionId(SESSION_ID)) + const replacement = live?.snapshotEvents().findLast((event): event is SessionEvent<'user/message'> => + event.type === 'user/message' && event.conversationOp !== undefined) + expect(replacement).toMatchObject({ + data: { content: [{ type: 'text', text: EDITED }] }, + surfaceOp: { op: 'replace' }, + conversationOp: { op: 'replace' }, + }) + + await page.reload({ waitUntil: 'load' }) + await page.getByText(EDITED, { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByText('EDITED_OK', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByText(ORIGINAL, { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + + it.skipIf(MODE === 'record')('keeps the expected artifact inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index cd696c5411..20b34b50c4 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -16,6 +16,8 @@ - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 114c7ca082..37685410a3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/message-edit.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 14c3c8ad07..caac6208a9 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: 902fd7b53fe7493127da68f9a8f381a33b8edc18 -architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4 +architecture.md: cf337b12c3ec4050e85618931f8e26a5d8bcc0ec +architecture.zh.md: 3bac2917e22722bbcda7820d2d4673430dfe4e45 diff --git a/docs/architecture.md b/docs/architecture.md index 902fd7b53f..cf337b12c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,15 +94,15 @@ turn/end `turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. -Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. +Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. An inbox admission may retain non-default Session placement and ordered same-step companion messages with its primary `MessageId`, so replay and pre-step rewriting preserve the complete admitted batch. -`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. +`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Non-default Session placement follows a surviving message by identity, and the payload exposes those intents so a listener that mutates the Session surface can defer while a preplanned replacement is pending. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). ## Session log -The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream. +The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. `SurfaceOp` owns model-history replacement; an explicit `conversationOp` on a replacement `user/message` independently hides one raw-event range from current user-facing projections while the append-only log remains lossless. Fork, resume, transcripts, telemetry, and persistence all derive from this stream. **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log. @@ -135,6 +135,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` stops a turn | | Add model-facing context | call `agent.inject()`; it lands in the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Edit the latest human message | call the ordinary-Session `session.edit` RPC; when it remains the current turn-opening prompt, the RPC appends a replacement generation in the same Session | | Add a Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Generate session titles | register the sole `ctx.sessionTitle` provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2890b32b5d..3bac2917e2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -98,15 +98,15 @@ turn/end `turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 -输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 +输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。一次 inbox 准入可以随主消息的 `MessageId` 保留非默认 Session 放置方式和有序的同一步骤配套消息,因此回放与 pre-step 重写都能保留完整准入批次。 -`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 +`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。非默认 Session 放置方式按身份跟随仍被保留的消息,payload 也会公开这些 intent,使会修改 Session surface 的监听器能在预定替换待提交时推迟操作。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 ## 会话日志 -会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。 +会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。`SurfaceOp` 负责替换模型历史;替换型 `user/message` 上的显式 `conversationOp` 独立地从当前用户可见投影隐藏一段原始事件区间,而仅追加日志保持无损。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。 **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。 @@ -139,6 +139,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 会停止轮次 | | 添加模型可见上下文 | 调用 `agent.inject()`;它会落到下一次获准的请求中 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| 编辑最新的人工消息 | 调用普通 Session 的 `session.edit` RPC;它仍是当前轮次开场提示词时,RPC 会在同一 Session 中追加替换代次 | | 添加 Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 生成会话标题 | 注册唯一的 `ctx.sessionTitle` 提供方 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8ebe947f40..cea28cb232 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6a0f7f23885cd85ccb1ff20e9322f133767aed4a -config-catalog.zh.md: cae9fa49a6986130c8b7261cf41e04b4d6b4e0e1 +config-catalog.md: ced73f7a75dac2fb0c4daefe65a30c59346578a6 +config-catalog.zh.md: 9aa539d8fab02b56fd2a54d22e9233bbbbb74f9f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a0f7f2388..ced73f7a75 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -217,7 +217,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts) @@ -2012,7 +2012,7 @@ export interface Config { } ``` -Source: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:58`](../packages/session/session-title/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cae9fa49a6..9aa539d8fa 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -219,7 +219,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts) @@ -2000,7 +2000,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -需要:`sessions` +需要:`sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -2014,7 +2014,7 @@ export interface Config { } ``` -来源:[`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:58`](../packages/session/session-title/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 571af289d6..7c7d95b1b9 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: d04a37ee71756619b36de192011a0053e5ceccb8 -event-producer-consumer.zh.md: 226c368517d989cba2eeac515ee2c544151e8005 +event-producer-consumer.md: 57e15aae28a0eeb0cf7203fcfb2d054413a96624 +event-producer-consumer.zh.md: c0b3328e51467b23bc8945fa5f71f9fc607b2f67 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d04a37ee71..57e15aae28 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,23 +9,23 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:177`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:310`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:235`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:196`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:298`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -45,10 +45,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 226c368517..c0b3328e51 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,23 +11,23 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:177`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:310`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:235`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:196`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:298`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -47,10 +47,10 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6e2f8ac7f..1085669f1f 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c -persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a +persistence-catalog.md: b5ad3550d28b87dc76bdb7c71bf83c24f9843f05 +persistence-catalog.zh.md: a2823bd087165ac3853b0e9a7680daf6f6776880 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1c0c691998..b5ad3550d2 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -48,7 +48,8 @@ export type SurfaceOp = * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -86,11 +87,13 @@ export type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` -Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:449`](../packages/core/session/src/types.ts) ## Events @@ -111,11 +114,13 @@ Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src start: number removedCount?: number inserted: UserMessage[] + /** Optional per-message placement and same-step companions. */ + admissions?: InboxAdmission[] outcome?: 'canceled' } ``` -Source: [`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:71`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -673,7 +678,7 @@ Source: [`packages/core/session/src/types.ts:364`](../packages/core/session/src/ Types: [SessionTitleEventData](subsystems/session-title.md) -Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4cc8ba5b7f..a2823bd087 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -50,7 +50,8 @@ export type SurfaceOp = * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -88,11 +89,13 @@ export type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` -来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:449`](../packages/core/session/src/types.ts) ## 事件 @@ -113,11 +116,13 @@ export type SessionEvent = { start: number removedCount?: number inserted: UserMessage[] + /** Optional per-message placement and same-step companions. */ + admissions?: InboxAdmission[] outcome?: 'canceled' } ``` -来源:[`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:71`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -675,7 +680,7 @@ export type SessionEvent = { 类型:[SessionTitleEventData](subsystems/session-title.zh.md) -来源:[`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 68358c4f1e..f97b4994f2 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/conversation.md -conversation.md: df1476537b95690ae2055f367e8586653b99a9a9 -conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62 +conversation.md: 415bc46078c61710a7111e3f1951beda736d39e9 +conversation.zh.md: a178b49ea2d55c9f11cc0002da1fea19f8b2d748 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index df1476537b..415bc46078 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -8,7 +8,17 @@ This page defines the data model and the extension path for a business-owned Con ## Data model and ownership -The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target. +The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. Before assembly, `ConversationPresentationState` folds explicit `conversationOp` replacements, removes entries in hidden raw-event ranges from every current target, and computes loaded current-surface membership for target-owned actions. A replacement append rebuilds the loaded window atomically, so no target publishes an intermediate mix of old and new generations. Ordinary surface replacements without `conversationOp`, including compaction checkpoints, retain their established human-transcript behavior. + +`ui-conversation` passes each retained entry to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target. + +```ts type-equiv +/** Current model-surface membership accompanying one Conversation publication. */ +interface ConversationPresentation { + /** Loaded surface-event seqs that remain in the current model context. */ + readonly currentSurfaceSeqs: ReadonlySet +} +``` | Concept | Owner and purpose | |---|---| diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 784f52975c..a178b49ea2 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -8,7 +8,17 @@ Conversation 是 Client `SessionEventLikeEntry` window 与浏览器 view 之间 ## 数据模型与所有权 -Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。`ui-conversation` 把这些 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。 +Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。组装之前,`ConversationPresentationState` 会折叠显式 `conversationOp` 替换,从每个当前 target 中移除隐藏原始事件区间内的 entry,并为 target 自有动作计算已加载的当前 surface 成员关系。一次替换 append 会原子重建已加载窗口,因此任何 target 都不会发布新旧代次混合的中间状态。没有 `conversationOp` 的普通 surface 替换(包括压缩检查点)维持既有的人类 transcript 行为。 + +`ui-conversation` 把每个被保留的 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。 + +```ts type-equiv +/** Current model-surface membership accompanying one Conversation publication. */ +interface ConversationPresentation { + /** Loaded surface-event seqs that remain in the current model context. */ + readonly currentSurfaceSeqs: ReadonlySet +} +``` | 概念 | Owner 与用途 | |---|---| diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 3dac641914..9b4d66c6a2 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/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/subsystems/core.md -core.md: 170268351454643544d5ab20e3d4b6a8fd8c6c69 -core.zh.md: 048f7ed5f2136bb583c0308635e5cddde2b96785 +core.md: bf54bfb95ac41db665f67093568d340cb7438968 +core.zh.md: c7afa245445dab5ecacc5711f80ecb699f619558 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 1702683514..bf54bfb95a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -54,7 +54,19 @@ interface AgentHandle { `Agent` is the surface every plugin (UI, hooks, orchestrators) programs against; `ctx.agents.get(id)` returns it, and the [initiator scope](#initiating-agent) carries it. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. The unified `send` method exposes target and wakeup routing directly; `followup`, `steer`, and `inject` are fixed-preset aliases. -Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +```ts type-equiv +/** Optional routing and Session placement for one Agent inbox insertion. */ +interface AgentSendOptions { + /** Insert before existing work at the resolved target instead of after it. */ + position?: 'front' | 'back' + /** Non-default placement used when AgentLoop records this exact message. */ + surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the sent message in the same step. */ + followingMessages?: readonly UserMessage[] +} +``` + +Sources: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts), [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) ```ts type-equiv /** Public live-agent handle; the runtime face augments its live capabilities. */ @@ -110,8 +122,9 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @param options - optional queue position, Session placement, and same-step companions. */ - send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + send(message: UserMessage, target: InboxTarget, wakeup: boolean, options?: AgentSendOptions): void /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the @@ -177,7 +190,19 @@ The inbox is the delivery vocabulary — two ordered pending-message lists the a type InboxTarget = 'next-turn' | 'next-step' ``` -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 +/** Durable admission metadata attached to one inserted inbox message. */ +interface InboxAdmission { + /** Message identity the metadata follows through queue edits and claim. */ + readonly messageId: MessageId + /** Exact placement applied when AgentLoop records the claimed user message. */ + readonly surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the identified message in the same step. */ + readonly followingMessages?: readonly UserMessage[] +} +``` + +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. An insertion may carry `InboxAdmission` metadata: non-default Session placement for its primary message and ordered messages that follow it in the same claimed step. `replace(messageId, newMessage)` transfers that metadata to the new identity. 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, expands same-step companions, and emits per-message claimed notifications without treating the claim as a discard. 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. Cancellation: @@ -216,7 +241,7 @@ Pre-step decisions use the same identified `UserMessage` type as durable user-ro Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: +`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), its non-default Session placement keyed by message identity (`surfaceIntents`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. A listener that may replace the Session surface defers while the map contains a preplanned replacement. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: 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: @@ -952,16 +977,18 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * `next()` preserves the current messages. * @param payload.agent - the agent proposing the step. * @param payload.messages - messages removed from the inbox for this step. + * @param payload.surfaceIntents - non-default Session placement retained + * for claimed primary messages; the standard loop always supplies the map. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; surfaceIntents?: ReadonlyMap; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Scoped](scope.md) · [UserMessage](session.md) +Types: [MessageId](llm-streaming.md) · [Scoped](scope.md) · [SurfaceIntent](session.md) · [UserMessage](session.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 048f7ed5f2..c7afa24544 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -58,7 +58,19 @@ interface AgentHandle { `Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface;`ctx.agents.get(id)` 返回它,[发起者作用域](#initiating-agent)携带它。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。统一的 `send` 方法直接暴露 target 与 wakeup 路由;`followup`、`steer` 与 `inject` 是固定预设的别名方法。 -源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +```ts type-equiv +/** Optional routing and Session placement for one Agent inbox insertion. */ +interface AgentSendOptions { + /** Insert before existing work at the resolved target instead of after it. */ + position?: 'front' | 'back' + /** Non-default placement used when AgentLoop records this exact message. */ + surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the sent message in the same step. */ + followingMessages?: readonly UserMessage[] +} +``` + +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)、[`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) ```ts type-equiv /** Public live-agent handle; the runtime face augments its live capabilities. */ @@ -114,8 +126,9 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @param options - optional queue position, Session placement, and same-step companions. */ - send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + send(message: UserMessage, target: InboxTarget, wakeup: boolean, options?: AgentSendOptions): void /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the @@ -181,7 +194,19 @@ inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待 type InboxTarget = 'next-turn' | 'next-step' ``` -每个待处理入队项就是其 `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)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +```ts type-equiv +/** Durable admission metadata attached to one inserted inbox message. */ +interface InboxAdmission { + /** Message identity the metadata follows through queue edits and claim. */ + readonly messageId: MessageId + /** Exact placement applied when AgentLoop records the claimed user message. */ + readonly surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the identified message in the same step. */ + readonly followingMessages?: readonly UserMessage[] +} +``` + +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。一次插入可以携带 `InboxAdmission` 元数据:主消息的非默认 Session 放置方式,以及在同一个被领取步骤中紧随其后的有序消息。`replace(messageId, newMessage)` 会把这些元数据转移到新身份。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——展开同一步骤配套消息,并逐条发出 claimed 通知而不把领取视为 discarded。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: @@ -224,7 +249,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: +`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、按消息标识索引的非默认 Session 放置方式(`surfaceIntents`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。会替换 Session surface 的监听器在该映射包含预定替换时推迟操作。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: @@ -962,16 +987,18 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * `next()` preserves the current messages. * @param payload.agent - the agent proposing the step. * @param payload.messages - messages removed from the inbox for this step. + * @param payload.surfaceIntents - non-default Session placement retained + * for claimed primary messages; the standard loop always supplies the map. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; surfaceIntents?: ReadonlyMap; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md) +Types: [MessageId](llm-streaming.zh.md) · [Scoped](scope.zh.md) · [SurfaceIntent](session.zh.md) · [UserMessage](session.zh.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 5371ae0b34..de9d0c1c02 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-query.md -session-query.md: 1cf3dff325db76485a0afca4ca1a882a4ff175b8 -session-query.zh.md: 2b8504dfb9ec8ee25d925135dbc99fa7beaede98 +session-query.md: 6641f2b7320e7230bd4ed152f565a7661bc56d28 +session-query.zh.md: 793fc0f8db96f804ded616310a225da9af7e6e39 diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 1cf3dff325..6641f2b732 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -8,7 +8,7 @@ Source: [`packages/session-query/session-query/src/types.ts`](../../packages/ses ## Logical records -`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. +`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation and then marks every event in an explicit edited-conversation range as `shadowed`. The raw log remains readable through exact operations. ```ts type-equiv /** Whether an event is current model context, replaced context, or raw-log-only. */ @@ -99,7 +99,7 @@ interface SessionEventRecord { type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ time: number - /** Event placement in the folded session surface. */ + /** Event placement after model-surface and conversation-generation folding. */ surface: SessionEventSurface } ``` @@ -146,7 +146,7 @@ interface SessionEventSearchDocument extends SessionEventRecord { ## Full-text search pages -The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. +The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. Model-facing tools default both operations to `current` plus `log-only` and do not expose `shadowed`, so edited-away content is excluded without deleting its index records; programmatic callers may still request `shadowed` for diagnostics. ```ts type-equiv /** Provider-owned opaque continuation token returned by session search. */ diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index 2b8504dfb9..793fc0f8db 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -8,7 +8,7 @@ ## 逻辑记录 -`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换。 +`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换,随后把显式编辑对话区间内的每个事件标记为 `shadowed`。原始日志仍可通过精确操作读取。 ```ts type-equiv /** Whether an event is current model context, replaced context, or raw-log-only. */ @@ -99,7 +99,7 @@ interface SessionEventRecord { type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ time: number - /** Event placement in the folded session surface. */ + /** Event placement after model-surface and conversation-generation folding. */ surface: SessionEventSurface } ``` @@ -146,7 +146,7 @@ interface SessionEventSearchDocument extends SessionEventRecord { ## 全文搜索结果页 -整合后的 `ctx.sessionQuery` seam 提供两个全文搜索范围。`searchSessions()` 按匹配度最强的事件对语料库分组;`searchEvents()` 搜索单个会话。请求将不透明游标与规范化后的查询、元数据过滤器和结果数量上限绑定。提供方的元数据过滤器有意不包含事件文本扫描。 +整合后的 `ctx.sessionQuery` seam 提供两个全文搜索范围。`searchSessions()` 按匹配度最强的事件对语料库分组;`searchEvents()` 搜索单个会话。请求将不透明游标与规范化后的查询、元数据过滤器和结果数量上限绑定。提供方的元数据过滤器有意不包含事件文本扫描。面向模型的工具默认让两个操作只查询 `current` 与 `log-only`,且不公开 `shadowed`,因此被编辑替换的内容无需删除索引记录也会被排除;程序化调用方仍可显式请求 `shadowed` 进行诊断。 ```ts type-equiv /** Provider-owned opaque continuation token returned by session search. */ diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 63292c20f0..22b821af72 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-title.md -session-title.md: 20ed32b4a08127e7193433673d22f0dea3a90c46 -session-title.zh.md: c01d5d4bf177169069bbe0a39ef533ada0f7c36a +session-title.md: c346fef215db8d98c12d601a1bab0690df455429 +session-title.zh.md: a42a9e762686225a0007c50eab771faacea0d4ce diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 20ed32b4a0..c346fef215 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData { ## Provider input and output -The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind. +The service snapshots eligible messages through one revision. Only append-origin human messages schedule automatic work, so a same-session edit does not regenerate the current title; a later ordinary prompt builds its provider request from the then-current conversation generation and omits edited-away prompts. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind. ```ts type-equiv /** One eligible human text message exposed to title providers. */ @@ -108,7 +108,7 @@ type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts' interface SessionTitleProviderRequest { /** Live session being titled. */ readonly session: Session - /** All eligible human messages through this generation revision. */ + /** Current-generation eligible human messages through this title revision. */ readonly messages: readonly SessionTitleUserMessage[] /** Exact current logged main-request route, when one has been recorded. */ readonly route?: SessionTitleModelProvenance diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index c01d5d4bf1..a42a9e7626 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData { ## 提供方输入与输出 -服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。 +服务会对截至某一修订的合格消息创建快照。只有追加来源的人工消息会安排自动工作,因此同会话编辑不会重新生成当前标题;后续普通提示词会从当时的当前对话代次构建提供方请求,并省略被编辑隐藏的提示词。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。 ```ts type-equiv /** One eligible human text message exposed to title providers. */ @@ -108,7 +108,7 @@ type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts' interface SessionTitleProviderRequest { /** Live session being titled. */ readonly session: Session - /** All eligible human messages through this generation revision. */ + /** Current-generation eligible human messages through this title revision. */ readonly messages: readonly SessionTitleUserMessage[] /** Exact current logged main-request route, when one has been recorded. */ readonly route?: SessionTitleModelProvenance diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f9653cecea..4612dc18bb 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 -session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd +session.md: 11ad6a4d823322fe6fcbbe394be80b41dde5d0dd +session.zh.md: bc21494320fcacdf6e4d2180a67c36ecd938d444 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 48a80ccb92..11ad6a4d82 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -208,7 +208,8 @@ type OptionalSessionSeq = SessionSeq | null * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -246,6 +247,8 @@ type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` @@ -295,6 +298,48 @@ type SurfaceOp = `'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place. +### `ConversationOp` — current user-facing generation replacement + +```ts type-equiv +/** + * How one message starts a new user-facing conversation generation. + * + * The inclusive raw-event range remains in the append-only log but is omitted + * from current conversation projections. Unlike {@link SurfaceOp}, this range + * covers every event family rendered by Chat, Trajectory, search, and + * transcript exporters rather than only model-message surface nodes. + */ +type ConversationOp = { op: 'replace'; start: SessionSeq; end: SessionSeq } +``` + +Only a replacement `user/message` may carry `conversationOp`. Its inclusive range must precede the replacement event. `foldConversation()` merges overlapping or adjacent ranges, and membership checks use binary search over that normalized result. + +```ts type-equiv +/** One committed user-facing replacement and the event that committed it. */ +interface ConversationReplacement extends ConversationOp { + /** Seq of the replacement `user/message`. */ + readonly seq: SessionSeq +} +``` + +```ts type-equiv +/** A merged inclusive interval hidden from the current conversation. */ +interface ConversationHiddenRange { + readonly start: SessionSeq + readonly end: SessionSeq +} +``` + +```ts type-equiv +/** Complete result of folding conversation replacements from one event window. */ +interface ConversationFoldResult { + /** Replacement operations in event order. */ + readonly replacements: readonly ConversationReplacement[] + /** Sorted, non-overlapping raw-event ranges hidden by those replacements. */ + readonly hiddenRanges: readonly ConversationHiddenRange[] +} +``` + ### `SurfaceIntent` — the parameter to `session.append()` ```ts type-equiv @@ -311,10 +356,12 @@ interface SurfaceIntent { * Other surface events require a non-empty set when this field is present. */ sourceEventSeqs?: SessionSeq[] + /** Optional user-facing conversation replacement committed with this message. */ + conversationOp?: ConversationOp } ``` -Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session](../../packages/core/session/README.md)). Non-surface types reject it at compile time. +Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. Human-facing projections normally read append-origin events so compaction remains visible as history; an explicit `conversationOp` is the narrow exception that replaces one user-visible generation without deleting it from the log. Non-surface types reject surface metadata at compile time. Only `assistant/message` may carry a present empty `sourceEventSeqs`; when the field is absent, the event does not record which earlier events produced the message, and the provider may still have emitted chunks. @@ -488,10 +535,11 @@ declare class Session { * @param data - The event payload; must be JSON-serializable. * @param opts - Surface metadata: `surfaceOp` controls how the event enters * the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier - * events this one derives from. REQUIRED for - * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived model - * history) and + * events this one derives from; `conversationOp` lets a replacement user + * message hide one earlier raw-event range from current conversation + * views. REQUIRED for {@link SurfaceEventType} events (every + * message-producing event must declare how it joins the surface, the sole + * source of derived model history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of @@ -757,6 +805,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Replace the latest current turn-opening user message and rerun from that point. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation before the replacement is admitted. + * @returns acknowledgement after the replacement message commits. + */ +@Remote('edit') edit(request: SessionEditRequest, signal: AbortSignal): Promise + /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index df5cfd631a..bc21494320 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -208,7 +208,8 @@ type OptionalSessionSeq = SessionSeq | null * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -246,6 +247,8 @@ type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` @@ -297,6 +300,48 @@ type SurfaceOp = `'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 +### `ConversationOp`:当前用户可见对话代次替换 + +```ts type-equiv +/** + * How one message starts a new user-facing conversation generation. + * + * The inclusive raw-event range remains in the append-only log but is omitted + * from current conversation projections. Unlike {@link SurfaceOp}, this range + * covers every event family rendered by Chat, Trajectory, search, and + * transcript exporters rather than only model-message surface nodes. + */ +type ConversationOp = { op: 'replace'; start: SessionSeq; end: SessionSeq } +``` + +只有替换型 `user/message` 可以携带 `conversationOp`,且其闭区间必须位于替换事件之前。`foldConversation()` 会合并重叠或相邻区间,成员关系检查则在规范化结果上使用二分查找。 + +```ts type-equiv +/** One committed user-facing replacement and the event that committed it. */ +interface ConversationReplacement extends ConversationOp { + /** Seq of the replacement `user/message`. */ + readonly seq: SessionSeq +} +``` + +```ts type-equiv +/** A merged inclusive interval hidden from the current conversation. */ +interface ConversationHiddenRange { + readonly start: SessionSeq + readonly end: SessionSeq +} +``` + +```ts type-equiv +/** Complete result of folding conversation replacements from one event window. */ +interface ConversationFoldResult { + /** Replacement operations in event order. */ + readonly replacements: readonly ConversationReplacement[] + /** Sorted, non-overlapping raw-event ranges hidden by those replacements. */ + readonly hiddenRanges: readonly ConversationHiddenRange[] +} +``` + ### `SurfaceIntent`:`session.append()` 的参数 ```ts type-equiv @@ -313,10 +358,12 @@ interface SurfaceIntent { * Other surface events require a non-empty set when this field is present. */ sourceEventSeqs?: SessionSeq[] + /** Optional user-facing conversation replacement committed with this message. */ + conversationOp?: ConversationOp } ``` -对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的 transcript(文本记录)是另一个投影,读取的是日志中追加来源的事件,因为 surface 会有意遮蔽替换所概括的范围(见 [dsh-session](../../packages/core/session/README.zh.md) 的 `isAppendSurfaceEvent`)。非 surface 类型在编译期拒绝此参数。 +对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的投影通常读取追加来源事件,使压缩后仍保留历史;显式 `conversationOp` 是一个窄例外,它替换一代用户可见对话,但不从日志删除原事件。非 surface 类型在编译期拒绝 surface 元数据。 只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;字段不存在时,该事件没有记录这条消息由哪些早期事件产生,但提供方仍可能发出过分片。 @@ -490,10 +537,11 @@ declare class Session { * @param data - The event payload; must be JSON-serializable. * @param opts - Surface metadata: `surfaceOp` controls how the event enters * the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier - * events this one derives from. REQUIRED for - * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived model - * history) and + * events this one derives from; `conversationOp` lets a replacement user + * message hide one earlier raw-event range from current conversation + * views. REQUIRED for {@link SurfaceEventType} events (every + * message-producing event must declare how it joins the surface, the sole + * source of derived model history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of @@ -761,6 +809,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Replace the latest current turn-opening user message and rerun from that point. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation before the replacement is admitted. + * @returns acknowledgement after the replacement message commits. + */ +@Remote('edit') edit(request: SessionEditRequest, signal: AbortSignal): Promise + /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index a70f3d6e35..a4e9d71319 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b -tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d +tool-catalog.md: 9e38a9503ccfb91a18f0e27184138016b747d56d +tool-catalog.zh.md: c323ec2c8ee333715ea8d78bb1626e740d060afc diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 416585106d..9e38a9503c 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1375,12 +1375,11 @@ Search prior events in one authorized session; the current session excludes the }, "surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } @@ -1493,12 +1492,11 @@ Search prior sessions in the caller workspace and return the strongest matching }, "event_surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 89d2e46786..c323ec2c8e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1381,12 +1381,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } @@ -1499,12 +1498,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "event_surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 4ee411fe0e..f01e4fd467 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: cf78a247e27ec37ff51b10bad3f925348b9e01a2 -README.zh.md: 06d61851eec1ffdc99ed9348fac8b72cbc745b50 +README.md: 1747833eae24d0584c9ce309317e621db106482c +README.zh.md: 8c6348aac1bec69492c67f288eba4ee3a9921ae0 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index cf78a247e2..1747833eae 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,11 +25,13 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, edit, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. + +`session.edit` accepts only the latest human message when it is a current-surface, turn-opening prompt in an ordinary Session. The request carries that latest observed human-message seq as an optimistic revision; the Host revalidates it while holding idle maintenance, cancels a running turn with its inbox preserved, and places the edited rerun ahead of existing Queue work. A concurrent maintenance reservation reports `session/agent-busy`, a changed target reports `session/edit-stale`, and unexpected admission faults report `gateway/internal`. The replacement keeps non-text content, retained session-reference context, and the current model selection. Its new `user/message` commits both a model-surface replacement and a user-facing conversation replacement before the RPC acknowledges success. Edit does not create a Session, regenerate its title, or roll back external side effects from the hidden turns. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. `SessionSnapshot.pendingEdit` applies the same handoff rule to one optimistic replacement: Chat hides the selected suffix immediately, and the echo retires only after the replacement event is renderable or the operation fails. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -49,11 +51,11 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ## Model Experience -None, as invoked Agent commands own any model-visible effect. +None, as the controller delegates the replacement request and its model-visible history to the Agent and Session surface. #### KV Cache effect -No direct effect; model requests remain owned by the Agent and LLM packages. +No direct effect; the delegated surface replacement starts a new request series and preserves reuse only through the unchanged prefix before the edited turn. ## Known Limitations and Deferred Work diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 06d61851ee..8c6348aac1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,11 +25,13 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、edit 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 + +`session.edit` 只接受普通 Session 中最新的人工消息,且它必须是仍位于当前 surface 的轮次开场提示词。请求把该最新已观测人工消息的 seq 作为乐观 revision;Host 在持有 idle maintenance 时重新校验,使用保留 inbox 的方式取消运行中轮次,并把编辑后的重跑放到现有 Queue 工作之前。并发 maintenance 占用报告 `session/agent-busy`,目标发生变化报告 `session/edit-stale`,意外准入故障报告 `gateway/internal`。替换会保留非文本内容、已有的会话引用上下文与当前模型选择。新的 `user/message` 同时提交模型 surface 替换和用户可见对话替换,RPC 随后才确认成功。Edit 不会创建新 Session、重新生成标题,也不会回滚被隐藏轮次已经产生的外部副作用。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。`SessionSnapshot.pendingEdit` 对一次乐观替换应用相同的交接规则:Chat 会立刻隐藏选中消息起的后缀,只有替换事件已可渲染或操作失败后,回显才会退休。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -49,11 +51,11 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 ## 模型体验 -无,因为被调用的 Agent 命令拥有任何模型可见效果。 +无,因为 controller 会把替换请求及其模型可见历史委托给 Agent 与 Session surface。 #### KV Cache 影响 -无直接影响;模型请求仍由 Agent 和 LLM 包拥有。 +无直接影响;被委托的 surface 替换会开启新的请求序列,只有编辑轮次之前未变化的前缀仍可能复用。 ## 已知限制与延期工作 diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 43c0f2dfd6..24d0164c9a 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -86,6 +86,20 @@ export interface ISession { signal?: AbortSignal, requestId?: SessionRequestId, ): Promise> + /** + * Replace the latest current turn-opening human message and rerun from it. + * @param messageSeq - selected durable user-message event. + * @param expectedLastUserSeq - optimistic conversation revision captured by the editor. + * @param text - replacement text; retained non-text blocks remain Host-owned. + * @param signal - optional caller cancellation before admission. + * @returns the committed replacement event seq, or a business error. + */ + edit( + messageSeq: number, + expectedLastUserSeq: number, + text: string, + signal?: AbortSignal, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index ff08a2fd06..de8da19568 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -52,12 +52,26 @@ export interface PendingSubmission { readonly images: readonly PendingSubmissionImage[] } +/** One local same-session edit awaiting its durable replacement message. */ +export interface PendingEdit { + /** RPC identity echoed by the replacement `user/message`. */ + readonly requestId: SessionRequestId + /** Existing user-message event being replaced. */ + readonly targetSeq: number + /** Latest human message observed when edit mode opened. */ + readonly expectedLastUserSeq: number + /** Replacement text submitted by the inline editor. */ + readonly text: string + /** Client wall-clock ms used by the optimistic replacement bubble. */ + readonly time: number +} + /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' /** Send/stop failure surfaced by Session consumers. */ export interface PromptError { - readonly op: 'send' | 'stop' + readonly op: 'send' | 'stop' | 'edit' readonly error: RemoteFailure } @@ -67,6 +81,8 @@ export interface SessionSnapshot { readonly queue: readonly QueuedMessage[] /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ readonly pendingSubmissions: readonly PendingSubmission[] + /** Same-session edit waiting for its replacement user message. */ + readonly pendingEdit: PendingEdit | null readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index d9d58d41e2..d7ae469ee4 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -21,7 +21,7 @@ import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' import type { - OpenState, PendingSubmission, PromptError, SessionSnapshot, + OpenState, PendingEdit, PendingSubmission, PromptError, SessionSnapshot, } from '../contract/snapshot.ts' import { MutableSessionEventSource } from '../contract/events.ts' import type { @@ -113,6 +113,7 @@ export class Session implements SessionFace { private lastAgentError: string | null = null /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ private pendingSubmissions: readonly PendingSubmission[] = [] + private pendingEdit: PendingEdit | null = null /** Per-echo settlement state; `retiring` latches the first observation so a * queue frame and its durable event cannot both retire one echo. */ private readonly submissionSettlements = new Map> { + const requestId = randomUUID() as SessionRequestId + this.pendingEdit = { requestId, targetSeq: messageSeq, expectedLastUserSeq, text, time: Date.now() } + this.promptError = null + this.lastAgentError = null + this.notifier.markDirty() + let result: RemoteResult<{ accepted: true; messageSeq: number }> + try { + result = await this.remote.session.edit({ + requestId, + sessionId: this.sessionId, + messageSeq, + expectedLastUserSeq, + text, + clientTimeZone: resolvedClientTimeZone(), + }, signal) + } catch (error: unknown) { + const cleared = this.clearPendingEdit(requestId) + if (isRemoteFailure(error)) { + this.promptError = { op: 'edit', error } + this.notifier.markDirty() + return { ok: false, error } + } + if (cleared) this.notifier.markDirty() + throw error + } + if (!result.ok && this.clearPendingEdit(requestId)) { + this.promptError = { op: 'edit', error: result.error } + this.notifier.markDirty() + } + return result + } + /** * Resolve one image referenced by this session into browser-consumable bytes. * @param attachmentId - opaque id found in the folded session log. @@ -582,6 +622,12 @@ export class Session implements SessionFace { // ---- Private ---- + private clearPendingEdit(requestId: SessionRequestId): boolean { + if (this.pendingEdit === null || this.pendingEdit.requestId !== requestId) return false + this.pendingEdit = null + return true + } + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ private async doOpen(generation: number): Promise { this.openState = 'loading' @@ -664,6 +710,17 @@ export class Session implements SessionFace { /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { + if (event.type === 'user/message' && this.pendingEdit !== null) { + const source = (event.data as { readonly source?: { readonly rpcId?: unknown } } | undefined)?.source + if (source?.rpcId === this.pendingEdit.requestId) { + const requestId = this.pendingEdit.requestId + scheduleFrame(() => { + if (this.pendingEdit?.requestId !== requestId) return + this.pendingEdit = null + this.notifier.markDirty() + }) + } + } if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return // Structural read: window entries may be compact history records, so the // fields are narrowed rather than trusted (same posture as Conversation @@ -737,6 +794,7 @@ export class Session implements SessionFace { sessionId: this.sessionId, queue: this.queueMirror.snapshot(), pendingSubmissions: this.pendingSubmissions, + pendingEdit: this.pendingEdit, running: this.running, subagent: this.address === undefined ? null diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 0e8015f79c..ca175402a1 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -9,9 +9,9 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' @@ -34,6 +34,8 @@ import type { SessionCancelValue, SessionCreateRequest, SessionCreateValue, + SessionEditRequest, + SessionEditValue, SessionForkRequest, SessionForkValue, SessionPromptRequest, @@ -52,6 +54,35 @@ interface SessionReadState { readonly events: readonly SessionEvent[] } +/** One admitted edit and the exact replacement event it is waiting to commit. */ +interface EditAdmission { + readonly committed: Promise> +} + +/** Expected loss of the exact edit admission after it was queued. */ +class EditAdmissionStale extends Error {} + +/** Preserve the Agent maintenance refusal as a retryable Session-domain failure. */ +function editMaintenanceBusy(agent: Agent, error: unknown): RemoteError<'session/agent-busy'> { + return new RemoteError( + 'session/agent-busy', + `session "${agent.id}" is busy with another maintenance operation`, + { reason: String(error) }, + { cause: error }, + ) +} + +/** Validated replacement ranges and original content for one edit request. */ +interface EditTarget { + readonly event: SessionEvent<'user/message'> + readonly turnStartSeq: SessionSeq + readonly rawEndSeq: SessionSeq + readonly surfaceStart: SessionSeq + readonly surfaceEnd: SessionSeq + readonly shadowedSurfaceSeqs: SessionSeq[] + readonly preservedContexts: readonly SessionEvent<'user/message'>[] +} + /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { /** @@ -340,6 +371,93 @@ export class SessionCommandController { return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } + /** + * Replace the latest current turn-opening human message and prioritize its rerun + * ahead of already queued turns. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation observed before inbox admission. + * @returns acknowledgement after the replacement message enters the log. + */ + async edit(request: SessionEditRequest, signal: AbortSignal): Promise { + validateEditRequest(request) + signal.throwIfAborted() + const agent = await this.resolveAgent(request.sessionId) + const initialTarget = resolveEditTarget(agent.session, request) + replaceMessageText(initialTarget.event.data.content, request.text) + const hasImage = initialTarget.event.data.content.some(block => block.type === 'image') + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + 'session/invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + const admit = async (): Promise => { + const selection = this.agents.selectionFor(agent).current + if (!routeServed(this.ctx, selection.provider)) { + throw new RemoteError( + 'session/model-unavailable', + `no adapter serves provider "${selection.provider}"; select a model for this session`, + { provider: selection.provider, model: selection.model }, + ) + } + if (hasImage) { + const model = await this.ctx.llm.resolveModelInfo(selection.provider, selection.model) + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { + throw new RemoteError( + 'session/attachment-invalid', + `Model "${selection.model}" does not support image input.`, + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + ) + } + } + let committed: SessionEvent<'user/message'> + try { + let admission: EditAdmission + if (agent.status === 'running') { + admission = await this.reserveMaintenanceAfterCancel(agent, signal, () => { + signal.throwIfAborted() + return Promise.resolve(this.admitEdit(agent, request, clientTimeZone)) + }) + } else { + let maintenance: Promise + try { + maintenance = agent.runMaintenance((maintenanceSignal) => { + signal.throwIfAborted() + maintenanceSignal.throwIfAborted() + return Promise.resolve(this.admitEdit(agent, request, clientTimeZone)) + }) + } catch (error: unknown) { + throw editMaintenanceBusy(agent, error) + } + admission = await maintenance + } + committed = await admission.committed + } catch (error: unknown) { + if (remoteErrorOf(error) !== undefined || signal.aborted) throw error + if (error instanceof EditAdmissionStale) { + throw new RemoteError( + 'session/edit-stale', + `session "${request.sessionId}" changed before the edit could be admitted`, + { sessionId: request.sessionId, messageSeq: request.messageSeq }, + { cause: error }, + ) + } + throw new RemoteError( + 'gateway/internal', + `session "${request.sessionId}" edit admission failed: ${String(error)}`, + {}, + { cause: error }, + ) + } + return { accepted: true, messageSeq: committed.seq } + } + return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() + } + /** * Read one durable image after proving the Session log references it. * @param request - Session and attachment identities used for authorization. @@ -484,6 +602,92 @@ export class SessionCommandController { return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } } + /** Cancel a running turn and synchronously reserve maintenance at its idle transition. */ + private reserveMaintenanceAfterCancel( + agent: Agent, + signal: AbortSignal, + operation: (signal: AbortSignal) => Promise, + ): Promise { + signal.throwIfAborted() + return new Promise((resolve, reject) => { + let settled = false + const finish = (result: Promise): void => { + /* v8 ignore next -- every finishing path removes the other listeners before yielding. */ + if (settled) return + settled = true + dispose() + signal.removeEventListener('abort', abort) + result.then(resolve, reject) + } + const abort = (): void => { + const reason = signal.reason instanceof Error + ? signal.reason + : new Error('edit request aborted', { cause: signal.reason }) + finish(Promise.reject(reason)) + } + const dispose = this.ctx.on('agent/status', ({ agent: subject, status }) => { + if (subject !== agent || status !== 'idle') return + try { + finish(agent.runMaintenance(operation)) + } catch (error: unknown) { + finish(Promise.reject(editMaintenanceBusy(agent, error))) + } + }, { global: true }) + signal.addEventListener('abort', abort, { once: true }) + try { + agent.cancel({ kind: 'user' }, { keepInbox: true }) + } catch (error: unknown) { + finish(Promise.reject(error instanceof Error ? error : new Error('edit cancellation failed', { cause: error }))) + } + }) + } + + /** Validate and enqueue one edit while maintenance owns the idle Agent. */ + private admitEdit( + agent: Agent, + request: SessionEditRequest, + clientTimeZone: string | undefined, + ): EditAdmission { + const target = resolveEditTarget(agent.session, request) + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + } + const message = createUserMessage({ + content: replaceMessageText(target.event.data.content, request.text), + source, + }) + const followingMessages = target.preservedContexts.map(context => createUserMessage({ + content: [...context.data.content], + source: context.data.source, + })) + const waiter = waitForEditedMessage(this.ctx, agent, message.id) + try { + agent.send(message, 'next-turn', true, { + position: 'front', + followingMessages, + surfaceIntent: { + surfaceOp: { + op: 'replace', + start: target.surfaceStart, + end: target.surfaceEnd, + }, + sourceEventSeqs: target.shadowedSurfaceSeqs, + conversationOp: { + op: 'replace', + start: target.turnStartSeq, + end: target.rawEndSeq, + }, + }, + }) + } catch (error: unknown) { + waiter.dispose() + throw error + } + return { committed: waiter.committed } + } + private async forkWorkspace(source: SessionHeader): Promise { const workspaces = this.ctx.workspaceRegistry.list() const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) @@ -497,6 +701,166 @@ export class SessionCommandController { } } +/** Validate scalar fields before resolving or interrupting an Agent. */ +function validateEditRequest(request: SessionEditRequest): void { + if (!Number.isSafeInteger(request.messageSeq) || request.messageSeq < 0) { + throw new RemoteError('gateway/bad-request', 'messageSeq must be a non-negative safe integer', {}) + } + if (!Number.isSafeInteger(request.expectedLastUserSeq) || request.expectedLastUserSeq < 0) { + throw new RemoteError('gateway/bad-request', 'expectedLastUserSeq must be a non-negative safe integer', {}) + } +} + +/** Resolve the exact current turn and model-surface suffix replaced by an edit. */ +function resolveEditTarget(session: Session, request: SessionEditRequest): EditTarget { + const events = session.snapshotEvents() + const latestUser = events.findLast(event => + event.type === 'user/message' && event.data.source.kind === 'user') + if (latestUser?.seq !== request.expectedLastUserSeq) { + throw new RemoteError( + 'session/edit-stale', + `session "${request.sessionId}" changed after message ${String(request.messageSeq)} entered edit mode`, + { sessionId: request.sessionId, messageSeq: request.messageSeq }, + ) + } + const target = events[request.messageSeq] + if (target?.type !== 'user/message' + || target.data.source.kind !== 'user' + || !target.data.content.some(block => block.type === 'text')) { + throw editUnavailable(request, 'the selected event is not an editable human message') + } + if (target.seq !== latestUser.seq) { + throw editUnavailable(request, 'only the latest human message can be edited') + } + const currentSurface = session.surface.nodes + if (!currentSurface.includes(target.seq)) { + throw editUnavailable(request, 'the selected message is not in the current model context') + } + const turnStart = events.slice(0, target.seq + 1).findLast(event => event.type === 'turn/start') + if (turnStart?.type !== 'turn/start') { + throw editUnavailable(request, 'the selected message has no owning turn') + } + const firstStepStart = events.find(event => + event.seq > turnStart.seq + && event.type === 'step/start' + && event.data.turn === turnStart.data.turn) + if (firstStepStart?.type !== 'step/start' || firstStepStart.data.step !== 1) { + throw editUnavailable(request, 'the selected message has no first step') + } + const stepEndIndex = events.findIndex(event => + event.seq > firstStepStart.seq + && event.type === 'step/end' + && event.data.turn === turnStart.data.turn + && event.data.step === firstStepStart.data.step) + const firstStepEnd = stepEndIndex === -1 ? events.length : stepEndIndex + const openingRangeStart = target.seq < firstStepStart.seq ? turnStart.seq + 1 : firstStepStart.seq + 1 + const openingRangeEnd = target.seq < firstStepStart.seq ? firstStepStart.seq : firstStepEnd + const openingHuman = events.slice(openingRangeStart, openingRangeEnd).findLast(event => + event.type === 'user/message' && event.data.source.kind === 'user') + if (openingHuman?.seq !== target.seq) { + throw editUnavailable(request, 'the selected message is steering rather than the turn-opening prompt') + } + const firstSurfaceIndex = currentSurface.findIndex(seq => seq > turnStart.seq && seq <= target.seq) + const surfaceEnd = currentSurface.at(-1) + /* v8 ignore next -- target membership above guarantees one same-turn surface node and a non-empty surface. */ + if (firstSurfaceIndex < 0 || surfaceEnd === undefined) { + throw editUnavailable(request, 'the selected turn has no current model context') + } + const shadowedSurfaceSeqs = currentSurface.slice(firstSurfaceIndex) + return { + event: target, + turnStartSeq: turnStart.seq, + // The indexed target came from this snapshot, so the final event exists. + rawEndSeq: (events.at(-1) as SessionEvent).seq, + surfaceStart: shadowedSurfaceSeqs[0] as SessionSeq, + surfaceEnd, + shadowedSurfaceSeqs: [...shadowedSurfaceSeqs], + preservedContexts: events.slice(target.seq + 1, firstStepEnd).filter(isSessionReferenceContext), + } +} + +/** Whether one user-role context is the durable recall paired with a prompt. */ +function isSessionReferenceContext(event: SessionEvent): event is SessionEvent<'user/message'> { + if (event.type !== 'user/message') return false + const source = event.data.source as { readonly kind?: unknown; readonly form?: unknown } + return source.kind === 'session-reference' && source.form === 'recall' +} + +/** Replace all text blocks with one edited block while retaining non-text content in place. */ +function replaceMessageText(content: readonly ContentBlock[], text: string): ContentBlock[] { + const result: ContentBlock[] = [] + let foundText = false + for (const block of content) { + if (block.type !== 'text') { + result.push(block) + continue + } + if (foundText) continue + foundText = true + if (text !== '') result.push({ type: 'text', text }) + } + /* v8 ignore next -- resolveEditTarget requires a text block before calling this helper. */ + if (!foundText) throw new Error('editable message contains no text block') + if (result.length === 0) { + throw new RemoteError('gateway/bad-request', 'edited message cannot be empty', {}) + } + return result +} + +/** Build the stable rejection for a message that cannot be edited. */ +function editUnavailable(request: SessionEditRequest, reason: string): RemoteError<'session/edit-unavailable'> { + return new RemoteError( + 'session/edit-unavailable', + `session "${request.sessionId}" message ${String(request.messageSeq)} is not editable: ${reason}`, + { sessionId: request.sessionId, messageSeq: request.messageSeq }, + ) +} + +/** Wait until the exact edit message commits, or its owning turn closes without it. */ +function waitForEditedMessage( + ctx: Context, + agent: Agent, + messageId: MessageId, +): { readonly committed: Promise>; readonly dispose: () => void } { + let claimedTurn: number | undefined + const disposers: Array<() => void> = [] + const dispose = (): void => { + for (const disposeListener of disposers.splice(0)) disposeListener() + } + const promise = new Promise>((resolve, reject) => { + const finish = (outcome: { event: SessionEvent<'user/message'> } | { error: Error }): void => { + dispose() + if ('event' in outcome) resolve(outcome.event) + else reject(outcome.error) + } + disposers.push(ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => { + if (subject === agent && message.id === messageId) claimedTurn = turn + }, { global: true })) + disposers.push(ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => { + if (subject !== agent || message.id !== messageId) return + finish({ error: new EditAdmissionStale('edit message was discarded before it committed') }) + }, { global: true })) + disposers.push(ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'user/message' && event.data.id === messageId) { + finish({ event }) + return + } + if (claimedTurn !== undefined + && event.type === 'turn/end' + && event.data.turn === claimedTurn) { + finish({ error: new EditAdmissionStale('edit turn ended before the replacement message committed') }) + } + }, { global: true })) + disposers.push(ctx.on('agent/disposed', ({ agent: subject }) => { + if (subject === agent) { + finish({ error: new EditAdmissionStale('edit agent was disposed before the replacement message committed') }) + } + }, { global: true })) + }) + return { committed: promise.finally(dispose), dispose } +} + function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 9609da3fb5..fc0ad11d0d 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -4,9 +4,11 @@ import type { Context } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' import { isAppendSurfaceEvent, + isConversationReplacementEvent, SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' +import { foldConversation, isConversationSeqVisible } from '@deepseek-ai/dsh-session/conversation' import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionEvent, @@ -322,13 +324,18 @@ function paginate( throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1, ): { readonly events: SessionEvent[]; readonly hasMore: boolean } { const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)) + const conversation = foldConversation(events) let count = 0 let cut = SessionLogOffset(0) for (let index = end - 1; index >= 0; index--) { const event = events[index] as SessionEvent - if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue + if (!isConversationSeqVisible(event.seq, conversation.hiddenRanges)) continue + if (!MESSAGE_TYPES.has(event.type) + || (!isAppendSurfaceEvent(event) && !isConversationReplacementEvent(event))) continue count++ - const sources = event.sourceEventSeqs + const sources = isConversationReplacementEvent(event) + ? undefined + : event.sourceEventSeqs let groupStart = event.seq if (sources !== undefined) { for (const source of sources) { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index e982bf6f72..9bc7f2b672 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -34,6 +34,8 @@ import type { SessionControlFrame, SessionCreateRequest, SessionCreateValue, + SessionEditRequest, + SessionEditValue, SessionFollowFrame, SessionFollowRequest, SessionForkRequest, @@ -337,6 +339,18 @@ export class SessionController extends TypertRemoteService { return this.commands.prompt(request) } + /** + * Replace the latest current turn-opening user message and rerun from that point. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation before the replacement is admitted. + * @returns acknowledgement after the replacement message commits. + */ + @Remote('edit') + edit(request: SessionEditRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commands.edit(request, signal) + } + /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 2e2a0d7c46..83d298fb39 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -196,6 +196,8 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'session/steer-unavailable': { readonly itemId: MessageId } 'session/title-invalid': { readonly sessionId: SessionId } 'session/fork-unavailable': { readonly sessionId: SessionId } + 'session/edit-unavailable': { readonly sessionId: SessionId; readonly messageSeq: number } + 'session/edit-stale': { readonly sessionId: SessionId; readonly messageSeq: number } 'subagent/not-found': { readonly parentSessionId: SessionId readonly childSessionId: SessionId @@ -313,6 +315,26 @@ export interface SessionPromptValue { readonly accepted: true } +/** Replace the latest current turn-opening user message and rerun from that point. */ +export interface SessionEditRequest { + /** Client-minted identity persisted on the replacement user message. */ + readonly requestId: SessionRequestId + readonly sessionId: SessionId + /** Exact current user-message event selected for editing. */ + readonly messageSeq: number + /** Latest human user-message seq observed when the editor opened. */ + readonly expectedLastUserSeq: number + /** Replacement text; non-text content is retained from the selected message. */ + readonly text: string + readonly clientTimeZone?: string +} + +/** Receipt after the replacement user message commits to the Session log. */ +export interface SessionEditValue { + readonly accepted: true + readonly messageSeq: number +} + /** Durable image read request. */ export interface SessionAttachmentRequest { readonly sessionId: SessionId @@ -403,6 +425,13 @@ export type SessionWireSurfaceOp = | 'append' | { readonly op: 'replace'; readonly start: number; readonly end: number } +/** Browser wire form of one user-facing conversation replacement. */ +export type SessionWireConversationOp = { + readonly op: 'replace' + readonly start: number + readonly end: number +} + /** Event-shaped wire representation of one packed chunk row. */ export type ChunkRowEvent = { [Kind in ChunkRow['type']]: { @@ -431,6 +460,7 @@ export interface SessionWireEvent { readonly ignorable?: true readonly sourceEventSeqs?: number[] readonly surfaceOp?: SessionWireSurfaceOp + readonly conversationOp?: SessionWireConversationOp } /** One message-aligned backwards-history request. */ diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..943136da04 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -11,6 +11,8 @@ import type { SessionAddress, SessionControlBaseline, SessionControlFrame, + SessionEditRequest, + SessionEditValue, SessionFollowFrame, SessionFollowRequest, SessionPage, @@ -141,6 +143,8 @@ export class FakeApiClient { () => Promise.resolve(ok({ records: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onEdit: (payload: SessionEditRequest) => Promise> = () => + Promise.resolve(ok({ accepted: true as const, messageSeq: 0 })) onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -224,6 +228,7 @@ export class FakeApiClient { rename: payload => this.record('session.rename', payload, this.onRename(payload)), fork: payload => this.record('session.fork', payload, this.onFork(payload)), prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), + edit: payload => this.record('session.edit', payload, this.onEdit(payload)), attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), diff --git a/packages/api/session-controller/tests/session-edit.host.spec.ts b/packages/api/session-controller/tests/session-edit.host.spec.ts new file mode 100644 index 0000000000..b2c21cccc0 --- /dev/null +++ b/packages/api/session-controller/tests/session-edit.host.spec.ts @@ -0,0 +1,723 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SurfaceIntent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { describe, expect, it, vi } from 'vitest' +import { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { SessionEditRequest, SessionRequestId } from '../src/types.ts' +import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +function appendTurn(session: Session, turn: number, prompt: string, answer: string): SessionEvent<'user/message'> { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: prompt }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: answer }], + source: { provider: 'fixture', model: 'fixture-model' }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + return user +} + +async function harness( + running = false, + inputModalities: readonly ('text' | 'image')[] | null = ['text', 'image'], + providers: readonly string[] = ['fixture'], +): Promise<{ + ctx: Context + controller: SessionCommandController + agent: Agent + session: Session + sent: Array<{ + message: Parameters[0] + followingMessages: readonly Parameters[0][] + intent: SurfaceIntent | undefined + position: string | undefined + }> + cancel: ReturnType +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + ctx.provide('llm', { + listProviders: () => providers.map(id => ({ id })), + resolveModelInfo: () => Promise.resolve(inputModalities === null ? {} : { inputModalities }), + } as never) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + const session = ctx.sessions.create(SessionId('edit-session'), { meta: { cwd: '/workspace' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + let status: Agent['status'] = running ? 'running' : 'idle' + let nextTurn = 100 + const sent: Array<{ + message: Parameters[0] + followingMessages: readonly Parameters[0][] + intent: SurfaceIntent | undefined + position: string | undefined + }> = [] + const agent = { + id: session.id, + options: { provider: 'fixture', model: 'fixture-model' }, + session, + inbox, + get status() { return status }, + ctx, + send(message, _target, _wakeup, options) { + sent.push({ + message, + followingMessages: options?.followingMessages ?? [], + intent: options?.surfaceIntent, + position: options?.position, + }) + const turn = nextTurn + nextTurn += 1 + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', message, options?.surfaceIntent ?? { surfaceOp: 'append' }) + for (const following of options?.followingMessages ?? []) { + session.append('user/message', following, { surfaceOp: 'append' }) + } + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + }, + followup: vi.fn(), + steer: vi.fn(), + inject: vi.fn(), + cancel: vi.fn(() => { + status = 'idle' + ctx.emit('agent/status', { agent, status: 'idle' }) + }), + runMaintenance: async operation => operation(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } satisfies Agent + const cancel = vi.mocked(agent.cancel) + ctx.agents.register(agent) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + } as unknown as ApiSessionAgentController + return { + ctx, + controller: new SessionCommandController(ctx, agents, '/workspace'), + agent, + session, + sent, + cancel, + } +} + +function request( + messageSeq: number, + expectedLastUserSeq: number, + text = 'edited', + clientTimeZone: string | undefined = 'UTC', +): SessionEditRequest { + return { + requestId: 'edit-request' as SessionRequestId, + sessionId: SessionId('edit-session'), + messageSeq, + expectedLastUserSeq, + text, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + } +} + +describe('session.edit', () => { + it.each([false, true])('replaces the latest turn and prioritizes the rerun (running=%s)', async (running) => { + const fixture = await harness(running) + appendTurn(fixture.session, 1, 'first', 'first answer') + const second = appendTurn(fixture.session, 2, 'second', 'second answer') + fixture.agent.inbox.append('next-turn', createUserMessage({ + content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' }, + })) + + const result = await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal) + + expect(result).toMatchObject({ accepted: true }) + expect(fixture.sent).toHaveLength(1) + expect(fixture.sent[0]).toMatchObject({ + message: { content: [{ type: 'text', text: 'edited' }] }, + position: 'front', + intent: { + surfaceOp: { op: 'replace', start: second.seq }, + conversationOp: { op: 'replace', start: second.seq - 2 }, + }, + }) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'first answer' }], + [{ type: 'text', text: 'edited' }], + ]) + expect(fixture.cancel).toHaveBeenCalledTimes(running ? 1 : 0) + if (running) expect(fixture.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + expect(fixture.agent.inbox.nextTurn.map(message => message.content)).toEqual([ + [{ type: 'text', text: 'queued' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('preserves non-text blocks and permits unchanged text', async () => { + const fixture = await harness() + fixture.session.append('turn/start', { turn: 1 }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [ + { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'same' }, + { type: 'text', text: ' second block' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'referenced context' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [], + } as never, + }), { surfaceOp: 'append' }) + fixture.session.append('step/end', { turn: 1, step: 1 }) + fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + await fixture.controller.edit(request(prompt.seq, prompt.seq, 'same second block'), new AbortController().signal) + + expect(fixture.sent[0]?.message.content).toEqual([ + { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'same second block' }, + ]) + expect(fixture.sent[0]?.followingMessages).toEqual([ + expect.objectContaining({ content: [{ type: 'text', text: 'referenced context' }] }), + ]) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [ + { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'same second block' }, + ], + [{ type: 'text', text: 'referenced context' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('rejects a preserved image before interrupting a text-only model', async () => { + const fixture = await harness(true, ['text']) + fixture.session.append('turn/start', { turn: 1 }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [ + { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'describe' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + + await expect(fixture.controller.edit( + request(prompt.seq, prompt.seq, 'describe again'), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/attachment-invalid' }) + expect(fixture.cancel).not.toHaveBeenCalled() + expect(fixture.sent).toEqual([]) + await fixture.ctx.fiber.dispose() + }) + + it('accepts a preserved image when the current model declares no modality list', async () => { + const fixture = await harness(false, null) + fixture.session.append('turn/start', { turn: 1 }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [ + { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'describe' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + + await expect(fixture.controller.edit( + request(prompt.seq, prompt.seq, 'describe again'), + new AbortController().signal, + )).resolves.toMatchObject({ accepted: true }) + await fixture.ctx.fiber.dispose() + }) + + it('rejects invalid timezone and unavailable-model requests before interruption', async () => { + const invalidZone = await harness(true) + const zoneTarget = appendTurn(invalidZone.session, 1, 'prompt', 'answer') + await expect(invalidZone.controller.edit({ + ...request(zoneTarget.seq, zoneTarget.seq), + clientTimeZone: 'not/a-zone', + }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/invalid-time-zone' }) + expect(invalidZone.cancel).not.toHaveBeenCalled() + await invalidZone.ctx.fiber.dispose() + + const unavailable = await harness(true, ['text'], []) + const modelTarget = appendTurn(unavailable.session, 1, 'prompt', 'answer') + await expect(unavailable.controller.edit( + request(modelTarget.seq, modelTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/model-unavailable' }) + expect(unavailable.cancel).not.toHaveBeenCalled() + await unavailable.ctx.fiber.dispose() + }) + + it('honors cancellation before resolving the target Session', async () => { + const fixture = await harness() + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + const controller = new AbortController() + controller.abort(new Error('request stopped')) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + controller.signal, + )).rejects.toThrow('request stopped') + expect(fixture.sent).toEqual([]) + await fixture.ctx.fiber.dispose() + }) + + it('maps an admission race to stale and preserves an unexpected send failure as internal', async () => { + const raced = await harness() + const racedTarget = appendTurn(raced.session, 1, 'prompt', 'answer') + vi.spyOn(raced.agent, 'runMaintenance').mockImplementation(async (operation) => { + appendTurn(raced.session, 2, 'newer prompt', 'newer answer') + return operation(new AbortController().signal) + }) + await expect(raced.controller.edit( + request(racedTarget.seq, racedTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await raced.ctx.fiber.dispose() + + const failedSend = await harness() + const sendTarget = appendTurn(failedSend.session, 1, 'prompt', 'answer') + const failure = new Error('send failed') + vi.spyOn(failedSend.agent, 'send').mockImplementation(() => { throw failure }) + await expect(failedSend.controller.edit( + request(sendTarget.seq, sendTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'gateway/internal', cause: failure }) + await failedSend.ctx.fiber.dispose() + }) + + it.each([new Error('cancel failed'), 'cancel failed'])('preserves a thrown cancellation (%s) as an internal failure', async (reason) => { + const fixture = await harness(true) + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => { throw reason }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'gateway/internal' }) + await fixture.ctx.fiber.dispose() + }) + + it.each([false, true])('reports an occupied maintenance slot as busy (running=%s)', async (running) => { + const fixture = await harness(running) + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + vi.spyOn(fixture.agent, 'runMaintenance').mockImplementation(() => { throw new Error('maintenance busy') }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ + code: 'session/agent-busy', + details: { reason: 'Error: maintenance busy' }, + }) + await fixture.ctx.fiber.dispose() + }) + + it.each([new Error('request stopped'), 'request stopped'])('honors caller cancellation while waiting for idle (%s)', async (reason) => { + const fixture = await harness(true) + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + const cancel = vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => {}) + const controller = new AbortController() + const editing = fixture.controller.edit(request(target.seq, target.seq), controller.signal) + + await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() }) + fixture.ctx.emit('agent/status', { agent: {} as Agent, status: 'idle' }) + fixture.ctx.emit('agent/status', { agent: fixture.agent, status: 'running' }) + controller.abort(reason) + + await expect(editing).rejects.toThrow(reason instanceof Error ? reason.message : 'edit request aborted') + await fixture.ctx.fiber.dispose() + }) + + it('rejects when the edit message is discarded or its claimed turn closes before commit', async () => { + const discarded = await harness() + const discardedTarget = appendTurn(discarded.session, 1, 'prompt', 'answer') + vi.spyOn(discarded.agent, 'send').mockImplementation((message) => { + discarded.ctx.emit('agent/inbox/claimed', { agent: {} as Agent, message, turn: 8 }) + discarded.ctx.emit('agent/inbox/claimed', { + agent: discarded.agent, + message: createUserMessage({ content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }), + turn: 8, + }) + discarded.ctx.emit('agent/inbox/discarded', { agent: {} as Agent, message }) + discarded.ctx.emit('agent/inbox/discarded', { agent: discarded.agent, message }) + }) + await expect(discarded.controller.edit( + request(discardedTarget.seq, discardedTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await discarded.ctx.fiber.dispose() + + const closed = await harness() + const closedTarget = appendTurn(closed.session, 1, 'prompt', 'answer') + const unrelatedSession = closed.ctx.sessions.create(SessionId('unrelated-edit-session')) + vi.spyOn(closed.agent, 'send').mockImplementation((message) => { + closed.ctx.emit('agent/inbox/claimed', { agent: closed.agent, message, turn: 9 }) + closed.ctx.emit('session/event', unrelatedSession, { + type: 'turn/end', seq: SessionSeq(0), time: 0, data: { turn: 9, reason: { kind: 'blocked' } }, + }) + closed.session.append('turn/end', { turn: 9, reason: { kind: 'blocked' } }) + }) + await expect(closed.controller.edit( + request(closedTarget.seq, closedTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await closed.ctx.fiber.dispose() + }) + + it('rejects when the Agent is disposed before the replacement commits', async () => { + const fixture = await harness() + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + vi.spyOn(fixture.agent, 'send').mockImplementation(() => { + fixture.ctx.emit('agent/disposed', { agent: {} as Agent }) + fixture.ctx.emit('agent/disposed', { agent: fixture.agent }) + }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await fixture.ctx.fiber.dispose() + }) + + it('allows the current replacement message to be edited again', async () => { + const fixture = await harness() + const original = appendTurn(fixture.session, 1, 'original', 'answer') + const first = await fixture.controller.edit( + request(original.seq, original.seq, 'first edit'), + new AbortController().signal, + ) + + const second = await fixture.controller.edit( + request(first.messageSeq, first.messageSeq, 'second edit'), + new AbortController().signal, + ) + + expect(second.messageSeq).toBeGreaterThan(first.messageSeq) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'second edit' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('retains a later compaction checkpoint positioned before the edited turn', async () => { + const fixture = await harness() + const first = appendTurn(fixture.session, 1, 'first', 'first answer') + const second = appendTurn(fixture.session, 2, 'second', 'second answer') + const firstAnswer = fixture.session.snapshotEvents().find(event => + event.type === 'assistant/message' && event.data.turn === 1) + if (firstAnswer?.type !== 'assistant/message') throw new Error('missing first answer') + const checkpoint = fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: firstAnswer.seq }, + sourceEventSeqs: [first.seq, firstAnswer.seq], + }) + + await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal) + + expect(fixture.sent[0]?.intent?.surfaceOp).toMatchObject({ + op: 'replace', + start: second.seq, + }) + expect(fixture.session.surface.nodes[0]).toBe(checkpoint.seq) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'summary' }], + [{ type: 'text', text: 'edited' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('accepts legacy turns whose opening user message precedes step/start', async () => { + const fixture = await harness() + fixture.session.append('turn/start', { turn: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'legacy prompt' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + fixture.session.append('step/end', { turn: 1, step: 1 }) + fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + await expect(fixture.controller.edit( + request(prompt.seq, prompt.seq, 'edited legacy prompt', undefined), + new AbortController().signal, + )).resolves.toMatchObject({ accepted: true }) + expect(fixture.sent[0]?.message.content).toEqual([{ type: 'text', text: 'edited legacy prompt' }]) + await fixture.ctx.fiber.dispose() + }) + + it('rejects stale, shadowed, steering, empty, and malformed requests before admission', async () => { + const fixture = await harness() + const first = appendTurn(fixture.session, 1, 'first', 'first answer') + const second = appendTurn(fixture.session, 2, 'second', 'second answer') + const expectCode = async (value: Promise, code: string): Promise => { + await expect(value).rejects.toMatchObject({ code }) + } + + await expectCode( + fixture.controller.edit(request(first.seq, first.seq), new AbortController().signal), + 'session/edit-stale', + ) + await expectCode( + fixture.controller.edit(request(first.seq, second.seq), new AbortController().signal), + 'session/edit-unavailable', + ) + await expectCode( + fixture.controller.edit(request(-1, second.seq), new AbortController().signal), + 'gateway/bad-request', + ) + await expectCode( + fixture.controller.edit(request(first.seq, -1), new AbortController().signal), + 'gateway/bad-request', + ) + await expectCode( + fixture.controller.edit(request(second.seq, second.seq, ''), new AbortController().signal), + 'gateway/bad-request', + ) + + const rewritten = fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: SessionSeq(second.seq + 1) }, + sourceEventSeqs: [ + first.seq, + SessionSeq(first.seq + 1), + second.seq, + SessionSeq(second.seq + 1), + ], + conversationOp: { + op: 'replace', + start: SessionSeq(first.seq - 2), + end: SessionSeq(fixture.session.seq - 1), + }, + }) + await expectCode( + fixture.controller.edit(request(first.seq, rewritten.seq), new AbortController().signal), + 'session/edit-unavailable', + ) + await fixture.ctx.fiber.dispose() + }) + + it('rejects the latest human message after compaction removes it from the current surface', async () => { + const fixture = await harness() + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + const answer = fixture.session.snapshotEvents().find(event => + event.type === 'assistant/message' && event.data.turn === 1) + if (answer?.type !== 'assistant/message') throw new Error('missing answer') + fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: target.seq, end: answer.seq }, + sourceEventSeqs: [target.seq, answer.seq], + }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-unavailable' }) + await fixture.ctx.fiber.dispose() + }) + + it('rejects non-human, textless, turnless, steered, and step-less targets', async () => { + const expectCode = async (value: Promise): Promise => { + await expect(value).rejects.toMatchObject({ code: 'session/edit-unavailable' }) + } + + const nonHuman = await harness() + nonHuman.session.append('turn/start', { turn: 1 }) + nonHuman.session.append('step/start', { turn: 1, step: 1 }) + const context = nonHuman.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' }, + }), { surfaceOp: 'append' }) + const human = nonHuman.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'human' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(nonHuman.controller.edit(request(context.seq, human.seq), new AbortController().signal)) + await nonHuman.ctx.fiber.dispose() + + const textless = await harness() + textless.session.append('turn/start', { turn: 1 }) + textless.session.append('step/start', { turn: 1, step: 1 }) + const image = textless.session.append('user/message', createUserMessage({ + content: [{ type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(textless.controller.edit(request(image.seq, image.seq), new AbortController().signal)) + await textless.ctx.fiber.dispose() + + const turnless = await harness() + const lone = turnless.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'lone' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(turnless.controller.edit(request(lone.seq, lone.seq), new AbortController().signal)) + await turnless.ctx.fiber.dispose() + + const stepLess = await harness() + stepLess.session.append('turn/start', { turn: 1 }) + const noStep = stepLess.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'no step' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(stepLess.controller.edit(request(noStep.seq, noStep.seq), new AbortController().signal)) + await stepLess.ctx.fiber.dispose() + + const steered = await harness() + const opening = appendTurn(steered.session, 1, 'opening', 'answer') + steered.session.append('turn/start', { turn: 2 }) + steered.session.append('step/start', { turn: 2, step: 1 }) + const current = steered.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'current' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + steered.session.append('step/end', { turn: 2, step: 1 }) + steered.session.append('step/start', { turn: 2, step: 2 }) + const steering = steered.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'steering' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(steered.controller.edit(request(steering.seq, steering.seq), new AbortController().signal)) + expect(opening.seq).toBeLessThan(current.seq) + await steered.ctx.fiber.dispose() + }) +}) + +describe('session.edit with AgentLoop', () => { + it('runs the edited turn before preserved Queue work through the real loop', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const adapter = new MockAdapter([ + textResponse('old answer'), + textResponse('edited answer'), + textResponse('queued answer'), + ]) + ctx.llm.registerAdapter(['fixture'], adapter) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/workspace', + }) + const agent = await ctx.agentLoop.create(SessionId('real-edit'), { + provider: 'fixture', model: 'fixture-model', + }) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, + })) + await agent.whenIdle() + const target = agent.session.snapshotEvents().find(event => event.type === 'user/message') + if (target?.type !== 'user/message') throw new Error('missing original prompt') + agent.inbox.append('next-turn', createUserMessage({ + content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' }, + })) + + const result = await remote.edit({ + requestId: 'real-edit-request' as SessionRequestId, + sessionId: agent.id, + messageSeq: target.seq, + expectedLastUserSeq: target.seq, + text: 'edited', + clientTimeZone: 'UTC', + }) + expect(result).toMatchObject({ ok: true, value: { accepted: true } }) + await agent.whenIdle() + + const requests = adapter.requests.map(request => JSON.stringify(request.messages)) + expect(requests).toHaveLength(3) + expect(requests[1]).toContain('edited') + expect(requests[1]).not.toContain('original') + expect(requests[1]).not.toContain('queued') + expect(requests[2]).toContain('queued') + expect(agent.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'edited' }], + [{ type: 'text', text: 'edited answer' }], + [{ type: 'text', text: 'queued' }], + [{ type: 'text', text: 'queued answer' }], + ]) + await ctx.fiber.dispose() + }) + + it('interrupts a running turn before admitting the edited rerun', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const adapter = new MockAdapter(['hang', textResponse('edited answer')]) + ctx.llm.registerAdapter(['fixture'], adapter) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/workspace', + }) + const agent = await ctx.agentLoop.create(SessionId('running-edit'), { + provider: 'fixture', model: 'fixture-model', + }) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, + })) + await vi.waitFor(() => { + expect(adapter.requests).toHaveLength(1) + }) + const target = agent.session.snapshotEvents().find(event => event.type === 'user/message') + if (target?.type !== 'user/message') throw new Error('missing running prompt') + + const result = await remote.edit({ + requestId: 'running-edit-request' as SessionRequestId, + sessionId: agent.id, + messageSeq: target.seq, + expectedLastUserSeq: target.seq, + text: 'edited while running', + }) + expect(result).toMatchObject({ ok: true }) + await agent.whenIdle() + + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end').map(event => + event.type === 'turn/end' ? event.data.reason.kind : '')).toEqual(['aborted', 'completed']) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('edited while running') + expect(JSON.stringify(adapter.requests[1]?.messages)).not.toContain('original') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index 4dcd66d8da..caeba44028 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -254,6 +254,51 @@ describe('Session history raw journal', () => { expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index)) }) + it('counts an edit replacement as the current message without pulling its hidden tail into the page', async () => { + const { ctx } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const firstStart = session.append('turn/start', { turn: 1 }) + const first = appendUserText(session, 'first prompt') + appendAssistantText(session, 'first reply', 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + const second = appendUserText(session, 'second prompt') + const secondAnswer = appendAssistantText(session, 'second reply', 2) + const secondEnd = session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + const shadowed = session.surface.nodes.slice(session.surface.nodes.indexOf(first.seq)) + session.append('turn/start', { turn: 3 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'edited prompt' }], source: { kind: 'user' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: secondAnswer.seq }, + sourceEventSeqs: [...shadowed], + conversationOp: { op: 'replace', start: firstStart.seq, end: secondEnd.seq }, + }) + const editedAnswer = appendAssistantText(session, 'edited reply', 3) + + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + maxMessages: 2, + }) + if (!response.ok) throw new Error('unreachable') + const messages = pageEvents(response.value) + .filter(event => event.type === 'user/message' || event.type === 'assistant/message') + expect(messages.map(event => event.seq)).toEqual([editedAnswer.seq - 1, editedAnswer.seq]) + expect(messages.some(event => event.seq === second.seq)).toBe(false) + expect(response.value.hasMore).toBe(true) + + const expanded = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + maxMessages: 3, + }) + if (!expanded.ok) throw new Error('unreachable') + expect(pageEvents(expanded.value).some(event => event.seq === second.seq)).toBe(true) + expect(expanded.value.hasMore).toBe(false) + }) + it('paginates a message with many provenance sources without variadic argument expansion', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index c0bad5dabd..134c72e1bb 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -4,11 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' -import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { historyValue } from './event-script.client.ts' const SID = 'fk-s1' as SessionId @@ -254,6 +254,63 @@ describe('observed retirement', () => { }) }) +describe('pending edit', () => { + it('publishes optimistic state immediately and retires it after the durable replacement', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const response = deferred>() + api.onEdit = () => response.promise + + const editing = session.edit(10, 12, 'edited') + const pending = session.getSnapshot().pendingEdit + expect(pending).toMatchObject({ targetSeq: 10, expectedLastUserSeq: 12, text: 'edited' }) + const request = api.callsOf('session.edit')[0] as { requestId: SessionRequestId } + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), request.requestId) as never }) + await settleFrames() + expect(session.getSnapshot().pendingEdit).toBeNull() + + response.resolve(ok({ accepted: true, messageSeq: 0 })) + await expect(editing).resolves.toEqual(ok({ accepted: true, messageSeq: 0 })) + }) + + it('restores the durable view and exposes promptError when admission fails', async () => { + const { api, session } = makeSession() + api.onEdit = () => Promise.resolve(err(new RemoteError( + 'session/edit-stale', + 'stale', + { sessionId: SID, messageSeq: 10 }, + ))) + + const result = await session.edit(10, 12, 'edited') + + expect(result.ok).toBe(false) + expect(session.getSnapshot().pendingEdit).toBeNull() + expect(session.getSnapshot().promptError).toMatchObject({ op: 'edit' }) + }) + + it('normalizes a thrown Remote failure and rethrows an unexpected Client fault', async () => { + const remoteFailure = new RemoteError('gateway/internal', 'connection failed', {}) + const failed = makeSession() + failed.api.onEdit = () => Promise.reject(remoteFailure) + + await expect(failed.session.edit(10, 12, 'edited')).resolves.toEqual({ + ok: false, + error: remoteFailure, + }) + expect(failed.session.getSnapshot()).toMatchObject({ + pendingEdit: null, + promptError: { op: 'edit', error: remoteFailure }, + }) + + const faulted = makeSession() + const fault = new Error('client assembly failed') + faulted.api.onEdit = () => Promise.reject(fault) + await expect(faulted.session.edit(10, 12, 'edited')).rejects.toBe(fault) + expect(faulted.session.getSnapshot()).toMatchObject({ pendingEdit: null, promptError: null }) + }) +}) + describe('disposal', () => { it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { const { api, session } = makeSession() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 1b5859b509..1a85f0c1e0 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -34,6 +34,8 @@ import type { SessionControlFrame, SessionCreateRequest, SessionCreateValue, + SessionEditRequest, + SessionEditValue, SessionForkRequest, SessionForkValue, SessionFollowFrame, @@ -67,6 +69,7 @@ export interface TestSessionRemote { rename(request: SessionRenameRequest): Promise> fork(request: SessionForkRequest): Promise> prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> + edit(request: SessionEditRequest, signal?: AbortSignal): Promise> attachment(request: SessionAttachmentRequest): Promise> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> @@ -316,6 +319,10 @@ export function createSessionTestRemote( () => direct.prompt(request, signal), signal, ), + edit: (request, signal = new AbortController().signal) => remoteResult( + () => direct.edit(request, signal), + signal, + ), attachment: request => remoteResult(() => direct.attachment(request)), updateQueue: request => remoteResult(() => direct.updateQueue(request)), cancel: request => remoteResult(() => direct.cancel(request)), diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 8183f95981..df1d4efad1 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde -README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc +README.md: 00943283c1f0d52b684c67240bb8f7b8d861f091 +README.zh.md: 542563eb63b6fe2dd7a945268a7d834ee50652fe diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 79214bf2fe..00943283c1 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,11 +8,12 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the destination selected when submission begins: transcript echoes render at the flow tail, steering echoes carry the pending marker, and queued echoes stay out of Chat. Each echo disappears in the same render that exposes its durable user/steering node or Queue occurrence. `SessionSnapshot.pendingEdit` similarly replaces the selected suffix until the durable edited generation is renderable. ## Table of Contents - [System prompt row](#system-prompt-row) +- [Message editing](#message-editing) - [Turn token usage](#turn-token-usage) - [Turn Process Folding](#turn-process-folding) - [Scroll ownership](#scroll-ownership) @@ -29,6 +30,15 @@ Chat shows a collapsed `System prompt` row for each non-empty initial or resumed ----- + +## Message editing + +Only the latest human message exposes Edit, and only when it is a current-surface, turn-opening prompt in an ordinary Session. Earlier messages, compacted messages, steering, injected context, and direct subagent conversations do not. Edit replaces the bubble with a composer-style input card that fills the Chat content width: input-surface fill, border and elevation, plus an internal cancel/save action row. Its textarea starts at 80px, grows with its content up to 240px, and then scrolls internally. Enter submits, Shift+Enter inserts a newline, and Escape cancels. The normal composer remains enabled; starting another submission closes the inline editor. + +Submission moves the edited message to the transcript tail immediately and hides every later row while the Host validates and commits the replacement. A failure restores the durable conversation and appears through the normal prompt-error channel. A successful replacement has a new timestamp and no edited badge or Undo action; its retained attachments and session-reference labels remain on the replacement. The existing Session title is unchanged. + +----- + ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 674959e197..542563eb63 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,11 +8,12 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的目标:transcript 回显位于消息流末尾,steering 回显带 pending 标记,queued 回显不进入 Chat。每条回显都在同一次渲染中由其持久 user/steering 节点或 Queue occurrence 接替。`SessionSnapshot.pendingEdit` 同样会在持久编辑代次可渲染前替换选中的后缀。 ## 目录 - [系统提示词行](#system-prompt-row) +- [消息编辑](#message-editing) - [轮次 token 用量](#turn-token-usage) - [轮次过程折叠](#turn-process-folding) - [滚动归属](#scroll-ownership) @@ -29,6 +30,15 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真 ----- + +## 消息编辑 + +只有最新的人工消息会显示「编辑消息」,且它必须是普通 Session 中仍位于当前 surface 的轮次开场提示词。更早的消息、被压缩的消息、steering、注入上下文和 direct subagent 对话都不显示该动作。编辑会把气泡替换为铺满 Chat 内容列的 composer 风格输入卡片:使用输入表面背景、边框和阴影,并在卡片内部放置取消/保存操作行。文本框初始高度为 80px,随内容增长至 240px 后改为内部滚动。Enter 提交,Shift+Enter 插入换行,Escape 取消。普通 composer 保持可用;开始另一条提交会关闭行内编辑器。 + +提交后,编辑消息会立即移到 transcript 末尾,并在 Host 校验和提交替换期间隐藏其后的所有行。失败会恢复持久对话,并通过常规 prompt 错误通道展示。成功的替换使用新时间戳,不显示已编辑标记,也不提供 Undo;保留的附件和会话引用标签继续显示在替换消息上。已有 Session 标题保持不变。 + +----- + ## 轮次 token 用量 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 0c7d87f344..108cb74818 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -146,6 +146,10 @@ export function apply(ctx: Context): void { // Fork or child-title failure leaves the source view unchanged. }) }, + editMessage: async (seq, expectedLastUserSeq, text) => { + const result = await session.edit(seq, expectedLastUserSeq, text) + if (!result.ok) throw new Error(`message edit failed: ${result.error.code}: ${result.error.message}`) + }, } }, }, ChatView) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index e342836fe5..bd14b7fd21 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -37,7 +37,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript, - selectedCallId, cwd, openFile, inspectCall, forkAt, + selectedCallId, cwd, openFile, inspectCall, forkAt, messageEdit, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChatNode(nodeKey) @@ -109,12 +109,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ inspectCall, forkAt, loadImage, + messageEdit, renderMessageImages, fileMentions, turnProcess, }, [ node, selectedCallId, cwd, openFile, inspectCall, forkAt, - loadImage, renderMessageImages, fileMentions, turnProcess, + loadImage, messageEdit, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null const turnData = turnDataOf(routedNode) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index a8dc57d321..42585cd195 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,9 +7,10 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, MessageEditController } from '../contract/slots.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' -import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' +import { PendingEditBubble, PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts' @@ -217,10 +218,17 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod export function ChatView({ useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions, - useTranscriptView, useProjection, t, + editMessage, useTranscriptView, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) + const latestUserSeq = useChat((snapshot) => { + for (let index = snapshot.order.length - 1; index >= 0; index -= 1) { + const node = snapshot.nodes.get(snapshot.order[index] ?? '') as ChatNode | undefined + if (node?.kind === 'user' || node?.kind === 'steering') return node.data.seq + } + return -1 + }) // The rail's items are accumulated in the Chat snapshot, so this selector is // both the data and its change signal: the array identity moves only when a // Turn enters, leaves, or changes its preview. @@ -234,6 +242,9 @@ export function ChatView({ ) const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + const pendingEdit = useSession(s => s.pendingEdit) + const ordinarySession = useSession(s => s.subagent === null) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) @@ -248,6 +259,53 @@ export function ChatView({ }, [openView]) const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) + const [editingMessage, setEditingMessage] = useState<{ + seq: number + text: string + expectedLastUserSeq: number + } | null>(null) + const previousSubmissionIds = useRef( + new Set(pendingSubmissions.map(submission => submission.requestId)), + ) + useEffect(() => { + const previous = previousSubmissionIds.current + const next = new Set(pendingSubmissions.map(submission => submission.requestId)) + previousSubmissionIds.current = next + if (pendingSubmissions.some(submission => !previous.has(submission.requestId))) { + setEditingMessage(null) + } + }, [pendingSubmissions]) + const messageEdit = useMemo(() => ({ + latestUserSeq, + current: editingMessage, + begin: (seq, text, expectedLastUserSeq) => { + setEditingMessage({ seq, text, expectedLastUserSeq }) + }, + change: (text) => { setEditingMessage(current => current === null ? null : { ...current, text }) }, + submit: () => { + const current = editingMessage + if (current === null || editMessage === undefined) return + setEditingMessage(null) + void editMessage(current.seq, current.expectedLastUserSeq, current.text).catch((error: unknown) => { + // Session business and carrier failures render through promptError; only an unexpected Client fault reaches here. + console.error('[ui-chat] message edit failed:', error) + }) + }, + cancel: () => { setEditingMessage(null) }, + }), [editMessage, editingMessage, latestUserSeq]) + const messageEditController = editMessage === undefined || !ordinarySession || pendingEdit !== null + ? undefined + : messageEdit + const pendingEditTarget = pendingEdit === null + ? undefined + : nodeStore.values().find((node): node is ChatNode<'user'> => { + const candidate = node as ChatNode + return candidate.kind === 'user' && candidate.data.seq === pendingEdit.targetSeq + }) + const pendingEditIndex = pendingEditTarget === undefined + ? -1 + : order.indexOf(pendingEditTarget.key) + const displayedOrder = pendingEditIndex < 0 ? order : order.slice(0, pendingEditIndex) // Close/retry must ignore a settlement that started before the latest // gesture; otherwise a cancelled in-flight refusal reopens the dialog. const fileOpenRequest = useRef(0) @@ -285,7 +343,6 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) - const pendingSubmissions = useSession(s => s.pendingSubmissions) // Submission echoes still awaiting their durable counterpart. `order` is the // recompute trigger: durable user material always arrives as an append, and // every append replaces the order array. @@ -775,7 +832,7 @@ export function ChatView({ )} + {pendingEdit !== null && pendingEditTarget !== undefined && ( + + )} {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index 5476f1a5c0..10e48b6963 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { - IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, + IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, IconEditOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -19,6 +19,8 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined + /** Edit the latest eligible human message in the current Session. */ + onEdit?: (() => void) | undefined /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ branchUnavailable?: boolean | undefined /** Parent layout class composed onto the actions row. */ @@ -38,12 +40,12 @@ export interface MessageIconActionsProps { } /** - * Copy / branch (/ clock) IconActions row shared by user and assistant chrome. - * @param props - Copy text, event time, clock side, branch callback, className. + * Copy, optional edit/branch, and clock actions shared by message chrome. + * @param props - Copy text, event time, clock side, optional actions, and styling. * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, branchUnavailable = false, className, + text, time, clock, onBranch, onEdit, branchUnavailable = false, className, extraActions, usageAction, t, }: MessageIconActionsProps) { const day = useCalendarDay() @@ -87,6 +89,13 @@ export function MessageIconActions({ {copied ? : } + {onEdit !== undefined && ( + + + + )} {extraActions} {onBranch !== undefined && ( diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 7c616f4a0e..6ee7836750 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -40,6 +40,94 @@ word-break: break-word; } +.editBubble { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 10px 10px 10px 16px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-elevation-soft); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.editStack { + width: 100%; + max-width: 100%; +} + +.editInput { + box-sizing: border-box; + display: block; + width: 100%; + min-height: 80px; + max-height: 240px; + padding: 0 6px 0 0; + resize: none; + border: none; + outline: none; + background: transparent; + color: inherit; + caret-color: var(--dsw-alias-state-business-primary); + font: inherit; + line-height: inherit; + white-space: pre-wrap; +} + +.editToolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.actions { + display: flex; + align-items: center; + gap: 6px; +} + +.editAction { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 999px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.editAction:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.editAction:focus-visible { + outline: 2px solid var(--dsw-alias-label-tertiary); + outline-offset: -2px; +} + +.editSaveAction { + background: var(--dsw-alias-button-info-fill); + color: #fff; +} + +.editSaveAction:hover { + background: var(--dsw-alias-button-info-hover); +} + +.editSaveAction:focus-visible { + outline-color: var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + .referenceSummary { color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 07e9fb32a0..5dcfea124c 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,10 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCheckOutline16, IconCloseOutline16, JsonBlock, projectUserText, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -12,6 +14,18 @@ import css from './MessageItem.module.css' type UserImage = Extract +const MESSAGE_EDIT_MAX_HEIGHT = 240 +const MESSAGE_EDIT_INITIAL_HEIGHT = 80 + +const NO_MESSAGE_EDIT: NonNullable = { + latestUserSeq: -1, + current: null, + begin: () => {}, + change: () => {}, + submit: () => {}, + cancel: () => {}, +} + function contentParts(content: readonly unknown[]): { text: string images: { attachment: UserImage['attachment'] }[] @@ -31,6 +45,22 @@ function contentParts(content: readonly unknown[]): { return { text: texts.join(''), images, rest } } +function replaceTextContent(content: readonly unknown[], text: string): unknown[] { + let replaced = false + const result: unknown[] = [] + for (const block of content) { + const candidate = block as { type?: unknown } + if (candidate.type !== 'text') { + result.push(block) + continue + } + if (replaced) continue + replaced = true + if (text !== '') result.push({ type: 'text', text }) + } + return result +} + function retrySeconds(milliseconds: number): number { return Math.max(1, Math.ceil(milliseconds / 1_000)) } @@ -148,7 +178,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -162,6 +192,8 @@ function UserStyleBubble({ referenceLabels?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ previewImages?: readonly MessageImageSource[] + /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown. */ + reveal?: 'always' | 'hover' t: ChatViewSlotProps['t'] }): ReactNode { const { text, images: contentImages, rest } = contentParts(content) @@ -173,6 +205,7 @@ function UserStyleBubble({ className={css.userRow} data-pending-steering={pending || undefined} data-submission-echo={echo || undefined} + data-actions-reveal={reveal} >
{renderMessageImages({ images, align: 'end' })} @@ -269,16 +302,149 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: ) } +/** Optimistic replacement shown from Edit submit until its durable message lands. */ +export function PendingEditBubble({ content, text, time, referenceLabels = [], renderMessageImages, t }: { + content: readonly unknown[] + text: string + time: number + referenceLabels?: readonly string[] + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const replacement = useMemo(() => replaceTextContent(content, text), [content, text]) + return ( + ( + + )} + /> + ) +} + +function EditingUserBubble({ content, text, referenceLabels = [], renderMessageImages, change, submit, cancel, t }: { + content: readonly unknown[] + text: string + referenceLabels?: readonly string[] + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + change: (text: string) => void + submit: () => void + cancel: () => void + t: ChatViewSlotProps['t'] +}): ReactNode { + const { images, rest } = contentParts(content) + const inputRef = useRef(null) + useLayoutEffect(() => { + const input = inputRef.current + if (input === null) return + input.style.height = '0px' + const height = Math.min(MESSAGE_EDIT_MAX_HEIGHT, Math.max(input.scrollHeight, MESSAGE_EDIT_INITIAL_HEIGHT)) + input.style.height = `${String(height)}px` + input.style.overflowY = input.scrollHeight > MESSAGE_EDIT_MAX_HEIGHT ? 'auto' : 'hidden' + }, [text]) + return ( +
+
+ {renderMessageImages({ images, align: 'end' })} +
+