From 4b0af96838e2eaa335b415f22c68f74cb57e47ae Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 13:50:41 +0800 Subject: [PATCH 01/88] refactor(agent): back Inbox with a durable projection --- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 4 +- ...-07-31-claimed-pre-step-inbox-lifecycle.md | 8 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 8 +- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 22 +- ...7-session-projection-and-command-log.zh.md | 22 +- apps/cli/composition.md | 3 + apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 5 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 325 +++++++++--------- docs/module-graph.zh.md | 325 +++++++++--------- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 19 +- docs/subsystems/core.zh.md | 19 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 8 +- docs/subsystems/session-projection.zh.md | 8 +- .../headless-agent/tests/code-mode.e2e.ts | 6 + .../tests/fixtures/e2b/e2b/bin.ts | 4 +- packages/bundle/base/cordis.patch.yml | 3 + .../bundle/headless/tests/headless.spec.ts | 9 +- .../context/agent-instructions/package.json | 1 + .../tests/agent-instructions.e2e.ts | 15 +- .../tests/agent-instructions.spec.ts | 29 +- .../time-context/tests/time-context.spec.ts | 6 +- .../tmux-context/tests/tmux-context.spec.ts | 6 +- packages/core/agent-loop/package.json | 1 + packages/core/agent-loop/src/agent.ts | 9 +- packages/core/agent-loop/src/index.ts | 2 +- .../agent-loop/tests/agent-initiator.spec.ts | 8 + packages/core/agent-loop/tests/agent.spec.ts | 4 + packages/core/agent-loop/tests/cancel.spec.ts | 6 + .../tests/config-session-id.spec.ts | 16 + .../tests/contract-regressions.spec.ts | 18 + .../agent-loop/tests/coverage-edges.spec.ts | 4 + .../agent-loop/tests/interception.spec.ts | 4 + packages/core/agent-loop/tests/loop.spec.ts | 24 ++ .../core/agent-loop/tests/properties.spec.ts | 4 + .../agent-loop/tests/request-cache.e2e.ts | 4 + .../agent-loop/tests/request-error.spec.ts | 4 + .../tests/request-reconstruction.spec.ts | 8 + packages/core/agent-loop/tests/resume.spec.ts | 22 ++ .../agent-loop/tests/scope-lifecycle.spec.ts | 4 + .../core/agent-loop/tests/settings.spec.ts | 4 + .../core/agent-loop/tests/tool-calls.spec.ts | 10 + .../core/agent-loop/tests/tool-order.spec.ts | 4 + packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 4 +- packages/core/agent/README.zh.md | 4 +- packages/core/agent/package.json | 13 + packages/core/agent/src/inbox-projection.ts | 20 ++ packages/core/agent/src/inbox.ts | 180 +++++----- packages/core/agent/src/types.ts | 4 +- packages/core/agent/tests/agent.spec.ts | 136 ++++++-- packages/core/agent/tsconfig.json | 3 + packages/e2b/e2b/tests/composition.e2e.ts | 3 +- .../examples/agent-spine-demo/package.json | 2 + .../examples/agent-spine-demo/src/index.ts | 4 + .../extensions/tool-cordis/src/api-catalog.ts | 21 +- .../tests/command-feedback.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../tests/tools.spec.ts | 3 +- .../command-goal/tests/command-goal.spec.ts | 6 +- packages/goal/goal/tests/goal.spec.ts | 69 +++- packages/goal/goal/tests/projection.spec.ts | 19 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 33 +- packages/host/apiproxy/src/api-proxy.ts | 16 +- .../apiproxy/tests/api-proxy-jobs.spec.ts | 14 +- .../tests/api-proxy-projections.spec.ts | 100 +++++- .../tests/api-proxy-workspace.spec.ts | 6 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 3 +- packages/llm/llm-retry/package.json | 1 + .../tests/loader-composition.spec.ts | 6 + packages/llm/llm-retry/tests/retry.spec.ts | 15 +- .../plan/plan-mode/tests/integration.spec.ts | 4 + packages/preset/agent-presets/package.json | 1 + .../agent-presets/tests/invariant.spec.ts | 4 + .../preset/agent-presets/tests/mount.spec.ts | 8 + .../agent-presets/tests/settings.spec.ts | 4 + .../schedule/schedule/tests/runtime.spec.ts | 4 +- .../schedule/schedule/tests/tools.spec.ts | 7 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 12 +- .../session/session-projection/README.zh.md | 12 +- .../session/session-projection/src/index.ts | 26 +- .../tests/loader-composition.spec.ts | 3 +- .../tool-bash-persistent/tests/tools.spec.ts | 3 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 12 +- .../subagent/tests/list-children.spec.ts | 23 +- .../tests/list-agents.spec.ts | 2 - .../tests/tool-subagent-control.spec.ts | 2 - .../terminal-bash/tests/index.spec.ts | 12 +- .../terminal-bash/tests/local.spec.ts | 6 +- .../terminal/terminal/tests/service.spec.ts | 3 +- .../tests/loader-composition.spec.ts | 3 +- .../tool-terminal/tests/tools.spec.ts | 3 +- .../agent-loop-testkit/package.json | 2 + .../agent-loop-testkit/src/index.ts | 4 + .../tests/loader-composition.spec.ts | 3 +- .../tests/workflow-worker-thread.e2e.ts | 13 +- pnpm-lock.yaml | 25 ++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 8 + tsconfig.base.json | 2 + 116 files changed, 1294 insertions(+), 703 deletions(-) create mode 100644 packages/core/agent/src/inbox-projection.ts diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 9b41e2e0cf..3c9a697dd3 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: 73768e1eee8957f8976d40812b0a31a2961f0825 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 343816abaf394b8f64924cf36b753c6b1b2e34ca +2026-07-31-claimed-pre-step-inbox-lifecycle.md: c46c22cde3e96fb774233d9621acf1abf4ba0c33 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 51ada096a105a5c97cffd70c2d40782a71beddf7 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index 73768e1eee..c46c22cde3 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -12,17 +12,17 @@ Occurrence-local inbox wrappers also duplicated the identity already carried by ## Decision -Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`. +Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome, emits `agent/inbox/claimed { message, turn }` once per claimed message, and returns the exclusive batch for the loop's waterfall with `{ turn, step, signal }`. `PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` extension point. -The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields. +The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from Inbox itself. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `InboxService` registers the standard `inbox` projection over the durable `agent/inbox/spliced` stream for whole-state consumers and live restoration; UI edits and removals route through an Inbox mutation method so the same projection records every change. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. -The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection. +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while `InboxService` registers `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index 343816abaf..51ada096a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -12,17 +12,17 @@ Status: implemented ## 决策 -每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 +每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`,针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并把独占批次返回给循环,由后者用 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 `PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` 扩展点。 -持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 +持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 Inbox 自行发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除通过 `Inbox.splice()` 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`InboxService` 在持久 `agent/inbox/spliced` 流上注册标准 `inbox` 投影,供整体状态消费方与 live 恢复使用;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 -已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。 +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而 `InboxService` 把 `inbox` 注册为持久 splice 上的标准会话投影。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 ## 曾考虑的替代方案 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 2adf6c62ff..a1a3b48434 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: aa83ae4d0e96bc1681aec22da854ba258b9e19a9 -2026-07-27-session-projection-and-command-log.zh.md: 00349ac46dc300c60f725b257e0fff9bcdf3a586 +2026-07-27-session-projection-and-command-log.md: 779c7e7549fcbbae95b1a8da1ed1562c976f83ac +2026-07-27-session-projection-and-command-log.zh.md: 8da3788cc019a2dd01a1b5e939f24b5e30b7f974 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index aa83ae4d0e..779c7e7549 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -18,9 +18,9 @@ The underlying gap is architectural: the client has no seam for a plugin to obse Four infrastructure pieces, then the domains become pure contributors. -### Whole-value event rule +### Whole-value projection rule -A state-carrying log event MUST carry the complete post-change state, never a bare delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one. +Every served projection value is complete and self-describing on the wire, so consumers can use the latest seq-qualified value as final. Source events need not repeat that aggregate: a domain may use whole-value events such as `todo/write` and `plan/mode`, or replayable operations such as `agent/inbox/spliced`. The unit's deterministic `apply`, versioned checkpoint state, and forward tail replay must produce the same whole value. ### Host projection registry (`dsh-session-projection`, new package) @@ -50,15 +50,17 @@ declare module 'cordis' { ``` - Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's. -- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. +- **The host is the only place a projection is computed.** The framework drives every registered unit forward: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. - **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). -- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +- Registration is an effect (disposer with the fiber): the last unload removes the key from subsequent responses and the client reads it as capability absence. Same-key, same-version registrants share the unit. A domain that requires projection state declares the service dependency; optional contributors register under `ctx.inject(['sessionProjections'], …)`. - The package owns `./invariant` (every served key has a live registration). -### Shipped consumer: the subagent identity unit +### Shipped consumers -The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision. +The registry's two read faces serve [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md): a `subagent` unit folds durable mode/label identity last-wins from `subagent/descriptor`, and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child and `restore({}, events, 0)` over one persistence inspection for a cold one. An absent value remains that consumer's signal, and how absence renders is its own decision. + +Inbox's owning service registers its operation-folding unit. Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner. ### Wire: projections block on the history tail page @@ -153,7 +155,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **Bounded reverse scan over the log tail (absorber declarations)** — rejected for now: nothing supports it today, it only serves domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row + forward tail replay — the same recipe as the client's baseline + catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve. -**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear. +**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: replayable operations already fold directly through `apply`. Refetching would reintroduce a second owner, a coalescing loop, and a stale-read fence instead of letting the domain's one deterministic transition produce the current whole value. **Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package. @@ -169,7 +171,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a ## Acceptance criteria -- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files. +- A domain plugin ships per-session log-derived state to React by writing only: its source event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files. - The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent. - A stale baseline cannot overwrite a newer `session/projection` frame, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths). - A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone. @@ -178,10 +180,10 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a ## Risks -- **Whole-value rule is load-bearing**: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition. +- **Whole projection values are load-bearing**: every pushed or snapshotted value must stand alone at its seq. Source events may be whole values or replayable operations, but `apply`, checkpoint state, and tail replay must converge on that complete value. - **Synchronous unit discipline**: `init`/`apply`/`view` that await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest. - **Live registry churn is not pushed**: loading or unloading a domain plugin mid-session changes the key set, but no session event fires and no frame is pushed; open clients hold the stale key until the next tail pull (reconnect, gap repair, open). Accepted as a dev-only (HMR) staleness window — a registry-change push can be added to the change feed later without contract impact. -- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change. +- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per event, non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change. - **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model. - **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume. - **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first. diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 00349ac46d..8da3788cc0 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -18,9 +18,9 @@ Status: proposed 先立四件基础设施,之后各领域都退化为纯贡献方。 -### 全量值事件规则 +### 全量投影值规则 -携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈:漏掉的更新会被下一次更新纠正。 +每个对外投影值都完整且在线路上自描述,因此消费方可以把 seq 最新的值作为最终结果。源事件无需重复该聚合:领域可以使用 `todo/write`、`plan/mode` 等全量值事件,也可以使用 `agent/inbox/spliced` 等可回放操作。单元的确定性 `apply`、带版本 checkpoint state 与前向 tail replay 必须产生同一个完整值。 ### host 侧投影注册表(`dsh-session-projection`,新包) @@ -50,15 +50,17 @@ declare module 'cordis' { ``` - 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。 -- **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 +- **host 是投影唯一的计算地点。** 框架驱动每个已注册单元:每个已提交会话事件都会经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 - **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 -- 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +- 注册是 effect(disposer 随 fiber 走):最后一个注册方卸载后,key 会从后续响应中消失,客户端将其读作能力缺失。key 和版本相同的注册方共享该单元。必须使用投影状态的领域声明服务依赖;可选贡献方在 `ctx.inject(['sessionProjections'], …)` 下注册。 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。 -### 已交付的消费方:subagent 身份单元 +### 已交付的消费方 -注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` 按 last-wins 折叠出的持久化 mode/label 身份——`SubagentRuntime.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0)` 读取。注册表约定不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。 +注册表的两处读取接口服务于[通过投影单元读取 subagent 列表身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md):`subagent` 单元从 `subagent/descriptor` 按 last-wins 规则折叠持久化的 mode/label 身份,`SubagentRuntime.listChildren` 对 live child 通过 `snapshot()` 读取,对 cold child 则通过一次持久化检查结果调用 `restore({}, events, 0)` 读取。值缺席仍是该消费方的信号,如何呈现缺席仍由消费方自行决定。 + +Inbox 的所属服务注册负责折叠操作的单元。它与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。 ### 协议层:历史尾页上的 projections 块 @@ -153,7 +155,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **对日志尾部的有界反向扫描(absorber 声明)**——暂不采纳:今天没有任何东西支持它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 -**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。 +**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:可回放操作已经可以直接经过 `apply` 折叠。重新抓取只会重新引入第二个所有者、合并循环与陈旧读栅栏,而不是让领域唯一的确定性 transition 产出当前完整值。 **把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。 @@ -169,7 +171,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 ## 验收标准 -- 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。 +- 领域插件把按会话的日志派生状态送达 React,只需写:源事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。 - 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seq;loadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。 - 陈旧的基线不能覆盖更新的 `session/projection` 帧,重放的帧也不能让值仓倒退(两条路径都做 seq 高者胜测试)。 - 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。 @@ -178,10 +180,10 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 ## 风险 -- **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元约定让完整状态在每次转移处都是显式的。 +- **全量投影值是承重结构**:每个推送或快照值都必须能在自己的 seq 上独立成立。源事件可以是全量值,也可以是可回放操作,但 `apply`、checkpoint state 与 tail replay 必须收敛到该完整值。 - **单元的同步纪律**:`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。 - **注册表的实时增删不做推送**:会话中途加载或卸载领域插件会改变键集,但不会触发任何会话事件、也不会推任何帧;开着的客户端持有陈旧的 key 直到下次尾页拉取(重连、缺口修补、打开)。接受为仅开发期(HMR)的陈旧时窗——日后可以在变更流上加一个注册表变更推送,约定不受影响。 -- **忙碌会话上的主动驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,约定不变。 +- **忙碌会话上的 eager 驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。单元的逐事件开销很低,不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,约定不变。 - **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key,模型本身不用改。 - **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。 - **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 4d37b9186a..08e5d4db39 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -30,6 +30,8 @@ flowchart LR cfg --> plugin_dsh_base_user_questions plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent + plugin_dsh_base_agent_inbox["agent-inbox
@deepseek-ai/dsh-agent/inbox"] + cfg --> plugin_dsh_base_agent_inbox plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] @@ -179,6 +181,7 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | | `agent` | `@deepseek-ai/dsh-agent` | +| `agent-inbox` | `@deepseek-ai/dsh-agent/inbox` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index cdc4dbb6d3..87254a8d2d 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' -import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' @@ -24,7 +24,7 @@ try { id: agentId, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', send: () => {}, followup: () => {}, @@ -34,6 +34,7 @@ try { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: new AbortController().signal }, diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index e12756c849..1042393b52 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 710c399510b6b123123e0a9586d86bfc3a96dff9 -capability-seams.zh.md: e0559d464ba1ecd2160eaba40c24bfd829e4a0d6 +capability-seams.md: 41ec9859d2dbf4c2c237059458803a5651bb9504 +capability-seams.zh.md: 100486445e55bb411f062637ec6cff636806cb97 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 710c399510..41ec9859d2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -31,6 +31,7 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + svc_inboxes["ctx.inboxes
Durable pending-input facade"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -198,6 +199,7 @@ flowchart LR svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval pkg_agent --> svc_agents + pkg_agent --> svc_inboxes pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets @@ -321,6 +323,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fs --> pkg_tool_fs + svc_inboxes --> pkg_agent_loop svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope @@ -421,6 +424,7 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.inboxes` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop) | - | Registers the standard Inbox projection and creates command facades over its sole live state. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index e0559d464b..100486445e 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -33,6 +33,7 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + svc_inboxes["ctx.inboxes
Durable pending-input facade"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -200,6 +201,7 @@ flowchart LR svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval pkg_agent --> svc_agents + pkg_agent --> svc_inboxes pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets @@ -323,6 +325,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fs --> pkg_tool_fs + svc_inboxes --> pkg_agent_loop svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope @@ -423,6 +426,7 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.inboxes` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop) | - | 注册标准 Inbox 投影,并在其唯一 live 状态上创建命令 facade。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index fa7dcdafdc..7f32d0a130 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: 54b966fb19de14637d8d6ba89b50d9e1e0e00762 -config-catalog.zh.md: 53c43c616a1d3fbb6b855bf5ebc69263023413ff +config-catalog.md: 9c4d4fd1c4e198973940bf93eb5e66d9e7c47d04 +config-catalog.zh.md: 9a62d1d62703bba3ceb5a9a21752d86a3a72f2d6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 54b966fb19..9c4d4fd1c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -136,7 +136,7 @@ Source: [`packages/context/agent-instructions/src/config.ts:18`](../packages/con ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `inboxes` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -292,7 +292,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:94`](../packages/examples/agent-spine-demo/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 53c43c616a..9a62d1d627 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -138,7 +138,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-loop` -需要:`agents` · `sessions` · `llm` · `tools` · `systemPrompt` +需要:`agents` · `inboxes` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -294,7 +294,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:94`](../packages/examples/agent-spine-demo/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index d933130f59..c72e3f9fdd 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: c906ee6329fac66e7391c266213dd150dd5b8e09 -event-producer-consumer.zh.md: 77bf401b7215bd263c0d84f04e0eabe6b28b7915 +event-producer-consumer.md: e5d09f9ac963a401d267ac924e1a8aa681cc186b +event-producer-consumer.zh.md: daf8690cff14771182cc903759c5137577607220 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c906ee6329..e5d09f9ac9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,9 +12,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 77bf401b72..daf8690cff 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -14,9 +14,9 @@ | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1b0e8fad9c..ac07a9f002 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc -module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200 +module-graph.md: af584780da1a00ea515fb7ade824270cb660e231 +module-graph.zh.md: 0ccbf94e41d301793fb6e99b14883ce472f31674 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bd5f80534..af584780da 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -436,12 +436,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -478,6 +472,60 @@ flowchart TD pkg_session_projection --> pkg_session pkg_acp_snapshot --> pkg_invariants pkg_acp_snapshot --> pkg_session + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox + pkg_spill_local --> pkg_invariants + pkg_spill_local --> pkg_spill + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_typert_protocol + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session + pkg_session_persistence_jsonl --> pkg_invariants + pkg_session_persistence_jsonl --> pkg_session + pkg_session_persistence_jsonl --> pkg_session_persistence + pkg_session_persistence_sqlite --> pkg_invariants + pkg_session_persistence_sqlite --> pkg_session + pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_projection_cache --> pkg_invariants + pkg_session_projection_cache --> pkg_session + pkg_session_projection_cache --> pkg_session_persistence + pkg_session_projection_cache --> pkg_session_projection + pkg_session_projection_cache --> pkg_storage_domain + pkg_session_stats --> pkg_invariants + pkg_session_stats --> pkg_llm + pkg_session_stats --> pkg_session + pkg_session_stats --> pkg_session_projection + pkg_session_title --> pkg_brand + pkg_session_title --> pkg_invariants + pkg_session_title --> pkg_llm + pkg_session_title --> pkg_session + pkg_session_title --> pkg_session_projection + pkg_shell --> pkg_invariants + pkg_shell --> pkg_sandbox + pkg_shell --> pkg_settings + pkg_shell --> pkg_subprocess + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -496,10 +544,14 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_invariants @@ -507,18 +559,25 @@ flowchart TD pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_settings pkg_web_search_deepseek --> pkg_web - pkg_spill_local --> pkg_invariants - pkg_spill_local --> pkg_spill + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_session_query --> pkg_brand + pkg_session_query --> pkg_invariants + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_title pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_invariants - pkg_message_feedback --> pkg_llm - pkg_message_feedback --> pkg_session - pkg_message_feedback --> pkg_session_persistence - pkg_message_feedback --> pkg_storage_domain - pkg_message_feedback --> pkg_typert_protocol + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_attachment pkg_commands --> pkg_brand @@ -541,6 +600,13 @@ flowchart TD pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout pkg_agent_presets --> pkg_agent pkg_agent_presets --> pkg_atomic_write pkg_agent_presets --> pkg_home_paths @@ -549,42 +615,29 @@ flowchart TD pkg_agent_presets --> pkg_session pkg_agent_presets --> pkg_settings pkg_agent_presets --> pkg_system_prompt - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox - pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_sandbox_policy --> pkg_system_prompt - pkg_session_persistence_jsonl --> pkg_invariants - pkg_session_persistence_jsonl --> pkg_session - pkg_session_persistence_jsonl --> pkg_session_persistence - pkg_session_persistence_sqlite --> pkg_invariants - pkg_session_persistence_sqlite --> pkg_session - pkg_session_persistence_sqlite --> pkg_session_persistence - pkg_session_projection_cache --> pkg_invariants - pkg_session_projection_cache --> pkg_session - pkg_session_projection_cache --> pkg_session_persistence - pkg_session_projection_cache --> pkg_session_projection - pkg_session_projection_cache --> pkg_storage_domain - pkg_session_stats --> pkg_invariants - pkg_session_stats --> pkg_llm - pkg_session_stats --> pkg_session - pkg_session_stats --> pkg_session_projection pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session - pkg_session_title --> pkg_brand - pkg_session_title --> pkg_invariants - pkg_session_title --> pkg_llm - pkg_session_title --> pkg_session - pkg_session_title --> pkg_session_projection - pkg_shell --> pkg_invariants - pkg_shell --> pkg_sandbox - pkg_shell --> pkg_settings - pkg_shell --> pkg_subprocess + pkg_session_title_llm --> pkg_invariants + pkg_session_title_llm --> pkg_llm + pkg_session_title_llm --> pkg_session + pkg_session_title_llm --> pkg_session_title + pkg_session_title_llm --> pkg_timeout + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants @@ -597,12 +650,6 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session - pkg_workspace --> pkg_brand - pkg_workspace --> pkg_invariants - pkg_workspace --> pkg_session - pkg_workspace --> pkg_session_persistence - pkg_workspace --> pkg_storage - pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -620,23 +667,15 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell - pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants - pkg_session_query --> pkg_llm - pkg_session_query --> pkg_session - pkg_session_query --> pkg_session_persistence - pkg_session_query --> pkg_session_title + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy + pkg_session_query_sqlite --> pkg_invariants + pkg_session_query_sqlite --> pkg_session + pkg_session_query_sqlite --> pkg_session_persistence + pkg_session_query_sqlite --> pkg_session_query pkg_acp --> pkg_agent pkg_acp --> pkg_attachment pkg_acp --> pkg_invariants @@ -653,13 +692,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -679,28 +711,26 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout - pkg_session_title_llm --> pkg_invariants - pkg_session_title_llm --> pkg_llm - pkg_session_title_llm --> pkg_session - pkg_session_title_llm --> pkg_session_title - pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_session_title_all_prompts_llm --> pkg_invariants + pkg_session_title_all_prompts_llm --> pkg_llm + pkg_session_title_all_prompts_llm --> pkg_session + pkg_session_title_all_prompts_llm --> pkg_session_title + pkg_session_title_all_prompts_llm --> pkg_session_title_llm + pkg_session_title_first_prompt_llm --> pkg_invariants + pkg_session_title_first_prompt_llm --> pkg_llm + pkg_session_title_first_prompt_llm --> pkg_session + pkg_session_title_first_prompt_llm --> pkg_session_title + pkg_session_title_first_prompt_llm --> pkg_session_title_llm + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox @@ -731,11 +761,6 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -812,10 +837,6 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools - pkg_session_query_sqlite --> pkg_invariants - pkg_session_query_sqlite --> pkg_session - pkg_session_query_sqlite --> pkg_session_persistence - pkg_session_query_sqlite --> pkg_session_query pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session @@ -897,26 +918,6 @@ flowchart TD pkg_session_telemetry_otel --> pkg_llm pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry - pkg_session_title_all_prompts_llm --> pkg_invariants - pkg_session_title_all_prompts_llm --> pkg_llm - pkg_session_title_all_prompts_llm --> pkg_session - pkg_session_title_all_prompts_llm --> pkg_session_title - pkg_session_title_all_prompts_llm --> pkg_session_title_llm - pkg_session_title_first_prompt_llm --> pkg_invariants - pkg_session_title_first_prompt_llm --> pkg_llm - pkg_session_title_first_prompt_llm --> pkg_session - pkg_session_title_first_prompt_llm --> pkg_session_title - pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -939,6 +940,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_session_projection pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction @@ -1106,6 +1108,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_llm_retry pkg_agent_spine_demo --> pkg_scope pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_projection pkg_agent_spine_demo --> pkg_session_title pkg_agent_spine_demo --> pkg_shell_env pkg_agent_spine_demo --> pkg_skill @@ -1470,7 +1473,6 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1483,58 +1485,64 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | +| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1545,7 +1553,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -1560,14 +1567,10 @@ flowchart TD | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1592,7 +1595,7 @@ flowchart TD | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`tool-team`](../packages/experimental/tool-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`team`](../packages/experimental/team), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6239520b7d..0ccbf94e41 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -438,12 +438,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -480,6 +474,60 @@ flowchart TD pkg_session_projection --> pkg_session pkg_acp_snapshot --> pkg_invariants pkg_acp_snapshot --> pkg_session + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox + pkg_spill_local --> pkg_invariants + pkg_spill_local --> pkg_spill + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_typert_protocol + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session + pkg_session_persistence_jsonl --> pkg_invariants + pkg_session_persistence_jsonl --> pkg_session + pkg_session_persistence_jsonl --> pkg_session_persistence + pkg_session_persistence_sqlite --> pkg_invariants + pkg_session_persistence_sqlite --> pkg_session + pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_projection_cache --> pkg_invariants + pkg_session_projection_cache --> pkg_session + pkg_session_projection_cache --> pkg_session_persistence + pkg_session_projection_cache --> pkg_session_projection + pkg_session_projection_cache --> pkg_storage_domain + pkg_session_stats --> pkg_invariants + pkg_session_stats --> pkg_llm + pkg_session_stats --> pkg_session + pkg_session_stats --> pkg_session_projection + pkg_session_title --> pkg_brand + pkg_session_title --> pkg_invariants + pkg_session_title --> pkg_llm + pkg_session_title --> pkg_session + pkg_session_title --> pkg_session_projection + pkg_shell --> pkg_invariants + pkg_shell --> pkg_sandbox + pkg_shell --> pkg_settings + pkg_shell --> pkg_subprocess + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants @@ -498,10 +546,14 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_invariants @@ -509,18 +561,25 @@ flowchart TD pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_settings pkg_web_search_deepseek --> pkg_web - pkg_spill_local --> pkg_invariants - pkg_spill_local --> pkg_spill + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_session_query --> pkg_brand + pkg_session_query --> pkg_invariants + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_title pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_invariants - pkg_message_feedback --> pkg_llm - pkg_message_feedback --> pkg_session - pkg_message_feedback --> pkg_session_persistence - pkg_message_feedback --> pkg_storage_domain - pkg_message_feedback --> pkg_typert_protocol + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_attachment pkg_commands --> pkg_brand @@ -543,6 +602,13 @@ flowchart TD pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout pkg_agent_presets --> pkg_agent pkg_agent_presets --> pkg_atomic_write pkg_agent_presets --> pkg_home_paths @@ -551,42 +617,29 @@ flowchart TD pkg_agent_presets --> pkg_session pkg_agent_presets --> pkg_settings pkg_agent_presets --> pkg_system_prompt - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox - pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_sandbox_policy --> pkg_system_prompt - pkg_session_persistence_jsonl --> pkg_invariants - pkg_session_persistence_jsonl --> pkg_session - pkg_session_persistence_jsonl --> pkg_session_persistence - pkg_session_persistence_sqlite --> pkg_invariants - pkg_session_persistence_sqlite --> pkg_session - pkg_session_persistence_sqlite --> pkg_session_persistence - pkg_session_projection_cache --> pkg_invariants - pkg_session_projection_cache --> pkg_session - pkg_session_projection_cache --> pkg_session_persistence - pkg_session_projection_cache --> pkg_session_projection - pkg_session_projection_cache --> pkg_storage_domain - pkg_session_stats --> pkg_invariants - pkg_session_stats --> pkg_llm - pkg_session_stats --> pkg_session - pkg_session_stats --> pkg_session_projection pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session - pkg_session_title --> pkg_brand - pkg_session_title --> pkg_invariants - pkg_session_title --> pkg_llm - pkg_session_title --> pkg_session - pkg_session_title --> pkg_session_projection - pkg_shell --> pkg_invariants - pkg_shell --> pkg_sandbox - pkg_shell --> pkg_settings - pkg_shell --> pkg_subprocess + pkg_session_title_llm --> pkg_invariants + pkg_session_title_llm --> pkg_llm + pkg_session_title_llm --> pkg_session + pkg_session_title_llm --> pkg_session_title + pkg_session_title_llm --> pkg_timeout + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants @@ -599,12 +652,6 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session - pkg_workspace --> pkg_brand - pkg_workspace --> pkg_invariants - pkg_workspace --> pkg_session - pkg_workspace --> pkg_session_persistence - pkg_workspace --> pkg_storage - pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -622,23 +669,15 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell - pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants - pkg_session_query --> pkg_llm - pkg_session_query --> pkg_session - pkg_session_query --> pkg_session_persistence - pkg_session_query --> pkg_session_title + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy + pkg_session_query_sqlite --> pkg_invariants + pkg_session_query_sqlite --> pkg_session + pkg_session_query_sqlite --> pkg_session_persistence + pkg_session_query_sqlite --> pkg_session_query pkg_acp --> pkg_agent pkg_acp --> pkg_attachment pkg_acp --> pkg_invariants @@ -655,13 +694,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -681,28 +713,26 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout - pkg_session_title_llm --> pkg_invariants - pkg_session_title_llm --> pkg_llm - pkg_session_title_llm --> pkg_session - pkg_session_title_llm --> pkg_session_title - pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_session_title_all_prompts_llm --> pkg_invariants + pkg_session_title_all_prompts_llm --> pkg_llm + pkg_session_title_all_prompts_llm --> pkg_session + pkg_session_title_all_prompts_llm --> pkg_session_title + pkg_session_title_all_prompts_llm --> pkg_session_title_llm + pkg_session_title_first_prompt_llm --> pkg_invariants + pkg_session_title_first_prompt_llm --> pkg_llm + pkg_session_title_first_prompt_llm --> pkg_session + pkg_session_title_first_prompt_llm --> pkg_session_title + pkg_session_title_first_prompt_llm --> pkg_session_title_llm + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox @@ -733,11 +763,6 @@ flowchart TD pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -814,10 +839,6 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools - pkg_session_query_sqlite --> pkg_invariants - pkg_session_query_sqlite --> pkg_session - pkg_session_query_sqlite --> pkg_session_persistence - pkg_session_query_sqlite --> pkg_session_query pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session @@ -899,26 +920,6 @@ flowchart TD pkg_session_telemetry_otel --> pkg_llm pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry - pkg_session_title_all_prompts_llm --> pkg_invariants - pkg_session_title_all_prompts_llm --> pkg_llm - pkg_session_title_all_prompts_llm --> pkg_session - pkg_session_title_all_prompts_llm --> pkg_session_title - pkg_session_title_all_prompts_llm --> pkg_session_title_llm - pkg_session_title_first_prompt_llm --> pkg_invariants - pkg_session_title_first_prompt_llm --> pkg_llm - pkg_session_title_first_prompt_llm --> pkg_session - pkg_session_title_first_prompt_llm --> pkg_session_title - pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -941,6 +942,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_session_projection pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction @@ -1108,6 +1110,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_llm_retry pkg_agent_spine_demo --> pkg_scope pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_projection pkg_agent_spine_demo --> pkg_session_title pkg_agent_spine_demo --> pkg_shell_env pkg_agent_spine_demo --> pkg_skill @@ -1472,7 +1475,6 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1485,58 +1487,64 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | -| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | +| [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | +| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1547,7 +1555,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -1562,14 +1569,10 @@ flowchart TD | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1594,7 +1597,7 @@ flowchart TD | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`tool-team`](../packages/experimental/tool-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`team`](../packages/experimental/team), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 295a69a8a8..4e74c063fb 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: b680bccf22f7840663e5268eb3feeb6b16f7fd42 -persistence-catalog.zh.md: 4b50582fa55dbdc672d8c45debe108b97a55f0e2 +persistence-catalog.md: eb035b2b2529c0b36e7d2ad55dbe7990995d9093 +persistence-catalog.zh.md: ad4c70b0e1d36beac173e19a309dadefb0a6c349 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b680bccf22..eb035b2b25 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -103,8 +103,8 @@ Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src ```ts persistence-catalog /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4b50582fa5..ad4c70b0e1 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -105,8 +105,8 @@ export type SessionEvent = { ```ts persistence-catalog /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 1265b726fe..022720323d 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: d14ad52e57572d5b0b110a0b16ff734e3499bdf5 -core.zh.md: 9ace28731f2532f571b66f2e7f6a3ea4a2c4e345 +core.md: 6e919d02ac1c23cf4f49e92e5ce50a8983ac449d +core.zh.md: 853d9500cd7e51b02ec2f9595bd7f294f7b1d66f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index d14ad52e57..6e919d02ac 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -175,7 +175,7 @@ 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. +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, then Inbox emits per-message claimed notifications. `InboxService` registers the standard `inbox` projection; its registry cell is the sole live state and the same fold serves cold consumers. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: @@ -722,6 +722,23 @@ roots(): Agent[] Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) + + +### `ctx.inboxes` — `InboxService` + +Root Inbox service: creates live inboxes and owns their durable projection. + +```ts cordis-catalog +/** + * Restore one live Inbox for an agent and publish its committed mutations. + * @param agent - agent that owns the durable session and live Inbox events. + * @returns the restored Inbox. + */ +create(agent: Agent): Inbox +``` + +Source: [`packages/core/agent/src/inbox.ts:25`](../../packages/core/agent/src/inbox.ts) + ### `agent/*` events diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9ace28731f..853d9500cd 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -179,7 +179,7 @@ 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` 通知。 +每个待处理入队项就是其 `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 通知,随后由 Inbox 逐条发出 claimed 通知。`InboxService` 注册标准 `inbox` 投影;其注册表 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: @@ -730,6 +730,23 @@ roots(): Agent[] Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) + + +### `ctx.inboxes` — `InboxService` + +Root Inbox service: creates live inboxes and owns their durable projection. + +```ts cordis-catalog +/** + * Restore one live Inbox for an agent and publish its committed mutations. + * @param agent - agent that owns the durable session and live Inbox events. + * @returns the restored Inbox. + */ +create(agent: Agent): Inbox +``` + +Source: [`packages/core/agent/src/inbox.ts:25`](../../packages/core/agent/src/inbox.ts) + ### `agent/*` events diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 2b0653542d..2ff707d6fc 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-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 docs/subsystems/session-projection.md -session-projection.md: 281e7630eed6480de07a66bf7050798c83396f77 -session-projection.zh.md: bc1448e8baa0f38b3a9ffed1c6005e8905c93bcd +session-projection.md: 59668a4026b90d869d73771bf9dadfc15bfaea5e +session-projection.zh.md: 70960278e886c1a6f6b093f0c0a9f426f516e6a6 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 281e7630ee..59668a4026 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -54,7 +54,7 @@ interface ProjectionDefinition { } ``` -The whole-value event rule is load-bearing: a state-carrying log event carries the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). +Every served projection value is a complete read model. A source event may carry a whole value or a domain-owned operation; the unit's deterministic `apply` owns replay, and checkpoint plus forward tail replay reconstructs the same state. ## The snapshot and the change feed @@ -90,7 +90,7 @@ type ProjectionChangeListener = ( ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive through one `session/event` subscription and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; duplicate keys throw. Domain plugins declare `sessionProjections` as a dependency so assemblies without the registry stay unaffected. @@ -154,7 +154,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the full in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A domain that requires this capability declares a Cordis service dependency; an optional contributor may register under `ctx.inject(['sessionProjections'], …)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:167`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index bc1448e8ba..70960278e8 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -54,7 +54,7 @@ interface ProjectionDefinition { } ``` -全量值事件规则是承重结构:携带状态的日志事件携带的是变更后的完整状态,绝不是裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 +每个对外投影值都是完整读模型。源事件可以携带完整值,也可以携带领域拥有的操作;单元的确定性 `apply` 负责回放,checkpoint 加前向 tail replay 会重建出同一状态。 ## 快照与变更流 @@ -90,7 +90,7 @@ type ProjectionChangeListener = ( ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))通过一份 `session/event` 订阅以及每会话每单元的水位线(watermark)cell 拥有驱动权。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 重复直接 throw。领域插件把 `sessionProjections` 声明为依赖,因此不含注册表的组装不受影响。 @@ -154,7 +154,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the full in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A domain that requires this capability declares a Cordis service dependency; an optional contributor may register under `ctx.inject(['sessionProjections'], …)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:167`](../../packages/session/session-projection/src/index.ts) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index cca7641575..64413599d6 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -6,10 +6,12 @@ import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -53,6 +55,8 @@ async function codeModeHarness(cwd: string): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) + await harness.plugin(SessionProjectionRegistry) + await harness.plugin(InboxService) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) @@ -70,6 +74,8 @@ async function workspaceCodeModeHarness(): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) + await harness.plugin(SessionProjectionRegistry) + await harness.plugin(InboxService) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts index 4e4ffc120e..9446fcb7b2 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts @@ -1,7 +1,6 @@ import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { boot } from '@deepseek-ai/dsh-app-boot' -import { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-fs-e2b' @@ -20,7 +19,7 @@ const owner: Agent = { id: ownerId, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: ownerFiber.ctx, send() {}, @@ -31,6 +30,7 @@ const owner: Agent = { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } +Object.assign(owner, { inbox: ctx.inboxes.create(owner) }) const unregisterOwner = ctx.agents.register(owner) let terminalId: Awaited>['sessionId'] | undefined try { diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e9567d9206..6e1656e3f3 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -58,6 +58,9 @@ - id: agent name: '@deepseek-ai/dsh-agent' + - id: agent-inbox + name: '@deepseek-ai/dsh-agent/inbox' + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index ffe564870b..6e69d8c5d3 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -2,11 +2,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' import { createAssistantMessage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { apply, Config, internals } from '../src/index.ts' @@ -54,6 +56,8 @@ async function bench(script: Script): Promise<{ }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' }) ctx.agents.setFactory({ @@ -68,7 +72,7 @@ async function bench(script: Script): Promise<{ id: session.id, options: options.agentOptions ?? {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: agentCtx, cancel: () => {}, @@ -82,6 +86,7 @@ async function bench(script: Script): Promise<{ inject: () => {}, whenIdle: () => idle, } satisfies Partial) + Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) await options.setup?.(agentCtx) script.before?.(session) ctx.agents.register(agent) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index d7b3cdf81d..ce38dc5f2a 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 392936b4c2..8d8552182b 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -4,13 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions' import { candidateScopeKey } from '../src/render.ts' @@ -37,11 +34,9 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { await mkdir(join(workdir, '.git'), { recursive: true }) await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`) ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: 'Answer the user exactly and concisely.' }, + }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index fe37fef48a..c84ed08986 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -6,9 +6,11 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -47,6 +49,11 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName) const testToolSignal = new AbortController().signal +const isolatedInboxCtx = new Context() +await isolatedInboxCtx.plugin(SessionStore) +await isolatedInboxCtx.plugin(SessionProjectionRegistry) +await isolatedInboxCtx.plugin(InboxService) +let nextStubSession = 1 async function tempRepo(): Promise { return mkdtemp(join(tmpdir(), 'dsh-workspace-context-')) @@ -181,14 +188,18 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { - const id = SessionId('s1') - const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) - return { - ctx: new Context(), + const id = SessionId(`agent-instructions-${String(nextStubSession++)}`) + const agentCtx = isolatedInboxCtx + const session = agentCtx.sessions.create(id, { + seed, + ...cwd === undefined ? {} : { meta: { createdAt: 0, cwd } }, + }) + const agent: Agent = { + ctx: agentCtx, id: SessionId('a1'), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', send: () => {}, followup: () => {}, @@ -198,6 +209,8 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: agentCtx.inboxes.create(agent) }) + return agent } function stubToolExecution( @@ -2509,6 +2522,8 @@ describe('dynamic nested workspace context injection', () => { ]) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 833f7e4b9c..581bf0ca8c 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -36,11 +36,11 @@ async function mount(config: Config = {}) { } function sessionAgent(session: Session, id = 'agent'): Agent { - return { + const agent: Agent = { id: SessionId(id), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'running', ctx: new Context(), send: () => {}, @@ -51,6 +51,8 @@ function sessionAgent(session: Session, id = 'agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void { diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 94b058b2d1..6f02f08e74 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -92,11 +92,11 @@ async function mount( } function sessionAgent(session: Session, id = 'agent'): Agent { - return { + const agent: Agent = { id: SessionId(id), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'running', ctx: new Context(), send: () => {}, @@ -107,6 +107,8 @@ function sessionAgent(session: Session, id = 'agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } function openMessageTurn(session: Session, turn: number): void { diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index b922d98f59..0c2a66963e 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 668ef65826..ca98c94dab 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -11,11 +11,12 @@ import type { AgentOptions, AgentStatus, CancelOptions, + Inbox, InboxTarget, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, @@ -84,11 +85,7 @@ export class ReactLoopAgent implements Agent { public readonly session: Session, ) { this.dispatch = agentEvents(loopCtx, this) - this.inbox = new Inbox(session, { - inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) }, - discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, - claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, - }) + this.inbox = loopCtx.inboxes.create(this) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 371154a7c9..79bb469f25 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -294,7 +294,7 @@ function validateConfiguredAgents(agents: Config['agents']): void { /** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { - static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] + static inject = ['agents', 'inboxes', 'sessions', 'llm', 'tools', 'systemPrompt'] /** Runtime schema for declarative agents. */ static Config = z.object({ diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 0cd3ac39e4..a5ca4cd36f 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -21,6 +23,8 @@ async function harness(adapter: LlmAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agentsFiber = await ctx.plugin(AgentRegistry) @@ -121,6 +125,8 @@ describe('AgentLoop initiator scope', () => { const adapter = new OverlapAdapter(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -380,6 +386,8 @@ describe('AgentLoop initiator scope', () => { const adapter = new ReloadAdapter() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 29073353e3..ea933ff374 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -13,6 +15,8 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 28423bb430..647bf81175 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears @@ -25,6 +27,8 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -514,6 +518,8 @@ describe('Agent.cancel()', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index b08c076dad..5b043f6277 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -29,6 +31,8 @@ async function makeCoreContext(): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -327,6 +331,8 @@ describe('config-driven session id', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -352,6 +358,8 @@ describe('config-driven session id', () => { const ctx1 = new Context() await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) + await ctx1.plugin(SessionProjectionRegistry) + await ctx1.plugin(InboxService) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -371,6 +379,8 @@ describe('config-driven session id', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -395,6 +405,8 @@ describe('config-driven session id', () => { const ctx1 = new Context() await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) + await ctx1.plugin(SessionProjectionRegistry) + await ctx1.plugin(InboxService) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -411,6 +423,8 @@ describe('config-driven session id', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -436,6 +450,8 @@ describe('config-driven session id', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index c085dc7de0..a2b93a5fc0 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -30,6 +32,8 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -525,6 +529,8 @@ describe('turn numbering continues across seeded sessions', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -676,6 +682,8 @@ describe('turn and step boundary recovery', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1108,6 +1116,8 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1158,6 +1168,8 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1208,6 +1220,8 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1254,6 +1268,8 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1302,6 +1318,8 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 21506c9a0b..85cc720fed 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm' @@ -18,6 +20,8 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 72e3165f78..60a3be7d01 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' @@ -31,6 +33,8 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2105b86f42..7515e88c91 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -17,6 +19,8 @@ async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -132,6 +136,22 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) }) + it('rejects overlapping maintenance work', async () => { + const ctx = await harness(new MockAdapter([])) + const agent = ctx.agentLoop.create(SessionId('overlapping-maintenance'), { + provider: 'mock', + model: 'mock', + }) + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(() => finish.promise) + + expect(() => agent.runMaintenance(async () => undefined)) + .toThrow('agent "overlapping-maintenance" already has active work') + + finish.resolve(undefined) + await maintenance + }) + it('suppresses the replay when a latched maintenance wake is removed', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) @@ -1433,6 +1453,8 @@ describe('agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1457,6 +1479,8 @@ describe('agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 2757a633be..90e733c19a 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * Property-based tests for the agent loop's inbox/turn scheduling (the * property-testing Agent Note). Deterministic by construction: schedules are driven @@ -39,6 +41,8 @@ async function harness() { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index d47fbccbb1..a14c5f4bae 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -6,9 +6,11 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * With-key proof that log-derived requests translate into real provider cache hits: a @@ -41,6 +43,8 @@ async function loopHarness(): Promise { const created = new Context() await created.plugin(LlmRuntime) await created.plugin(SessionStore) + await created.plugin(SessionProjectionRegistry) + await created.plugin(InboxService) await created.plugin(SystemPrompt, { persona: SYSTEM }) await created.plugin(ToolRuntime) await created.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 9d1c2a42c3..776280765b 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -13,6 +15,8 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 287e73303c..1b178a127f 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * Loop-level reconstructability: every request the loop sends is a pure function of the * session log — messages derive at the step/start boundary and the header is the latest @@ -28,6 +30,8 @@ async function harnessRoutes( const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -255,6 +259,8 @@ describe('request stability across the loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -373,6 +379,8 @@ describe('request stability across the loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 1c140e1b2e..d1278f77cf 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -28,6 +30,8 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -244,6 +248,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -272,6 +278,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -522,6 +530,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -585,6 +595,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -617,6 +629,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -653,6 +667,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) + await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -686,6 +702,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -875,6 +893,8 @@ describe('configured-start failure edges', () => { const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) + await configured.plugin(SessionProjectionRegistry) + await configured.plugin(InboxService) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) @@ -920,6 +940,8 @@ describe('configured-start failure edges', () => { const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) + await configured.plugin(SessionProjectionRegistry) + await configured.plugin(InboxService) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index abe3730fd1..9e7e441665 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis' @@ -17,6 +19,8 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts index 4b45baab44..5f0f98d5a9 100644 --- a/packages/core/agent-loop/tests/settings.spec.ts +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** The `agent-loop` settings section layered over the composition entry. */ import { describe, expect, it } from 'vitest' @@ -34,6 +36,8 @@ async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; loopFiber: const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 87f69cc7d0..276838654e 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * Exercises scheduler ordering and cancellation with deterministic gated tools. * ACP expected outputs own transcript-facing coverage. @@ -20,6 +22,8 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -278,6 +282,8 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -346,6 +352,8 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -704,6 +712,8 @@ describe('code-mode native-tool denial through the agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime, { mode: 'code' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index a9af9f56bb..da9d59d6e0 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,3 +1,5 @@ +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** * Loop-level tool-order determinism: the request/header event — and therefore the frozen @@ -23,6 +25,8 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 7b36d3c58e..07b8e54cc2 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: f6b698e93b254c97786155e7d2c7e81f07c0d981 -README.zh.md: 787c4f353909a1e2dbc60c33abb3c101e677e011 +README.md: 6af7e57bdb0ae710a6d1e6e58ca2ac187a45eeb8 +README.zh.md: 1533f918e037c7dc7d131c7046d25fc259d33580 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f6b698e93b..6af7e57bdb 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Most interception points are cooperative waterfalls. `agent/pre-step` receives a `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. -Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope. +`InboxService` owns the standard `inbox` session projection. The projection registry folds durable `agent/inbox/spliced` events once and remains the sole owner of the live `{ 'next-turn', 'next-step' }` state; Inbox is a command facade that reads the registry snapshot rather than replaying or copying the fold. Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. Inbox emits them as it commits the corresponding mutation, without adding another lifecycle envelope. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -64,7 +64,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending. +- `agent.inbox` — the live face of the standard durable `inbox` projection. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` removes the next proposed batch with pure deletion splices and emits `agent/inbox/claimed` for each claimed message. `MessageId` is the only occurrence identity and must remain unique while pending. - `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`. - `agent.steer(message)` — queue waking `next-step` input. An idle agent starts a turn synchronously; a running driver consumes later steering at its next step boundary. - `agent.inject(message)` — queue non-waking `next-step` context. A running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 787c4f3539..1533f918e0 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -54,7 +54,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 -inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。 +`InboxService` 拥有标准 `inbox` 会话投影。投影注册表只折叠一次持久 `agent/inbox/spliced` 事件,并继续作为 live `{ 'next-turn', 'next-step' }` 状态的唯一所有者;Inbox 是读取注册表快照的命令 facade,不会重新回放或复制折叠结果。Inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。Inbox 在提交对应变更时自行发出这些通知,不引入另一层生命周期封套。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 @@ -64,7 +64,7 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte 每个插件面向的 handle: -- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 用于变更队列;`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 移除下一个候选批次,随后由循环发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。 +- `agent.inbox`:标准持久 `inbox` 投影的 live 接口。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 用于变更队列;`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 移除下一个候选批次,并为每条已认领消息发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。 - `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`。 - `agent.steer(message)`:将会唤醒的 `next-step` steering(中途引导)输入排队。agent 空闲时会同步启动一个轮次;驱动器运行期间收到的后续 steering 会在下一个步骤边界被消费。 - `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。运行中的驱动器会在最近的后续 pre-step 边界领取它;idle 驱动器则会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 6144f8679a..c40b00c0f5 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -18,6 +18,14 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./inbox": { + "types": "./lib/types/inbox.d.ts", + "default": "./lib/types/inbox.js" + }, + "./inbox-projection": { + "types": "./lib/types/inbox-projection.d.ts", + "default": "./lib/types/inbox-projection.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -41,15 +49,20 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/core/agent/src/inbox-projection.ts b/packages/core/agent/src/inbox-projection.ts new file mode 100644 index 0000000000..24602d17a7 --- /dev/null +++ b/packages/core/agent/src/inbox-projection.ts @@ -0,0 +1,20 @@ +/** Inbox projection schema and its inferred wire value. */ + +import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import { z } from 'zod' + +/** Wire validation for pending agent input reconstructed from durable inbox splices. */ +export const inboxProjectionSchema = z.object({ + 'next-turn': z.array(z.custom()).readonly(), + 'next-step': z.array(z.custom()).readonly(), +}).readonly() + +/** Complete pending Inbox value reconstructed from durable splices. */ +export type InboxState = z.infer + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Pending agent input reconstructed from durable inbox splices. */ + inbox: InboxState + } +} diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index c6b9204c92..8f25da7f3a 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -4,54 +4,82 @@ * @module @deepseek-ai/dsh-agent/inbox */ +import { Context, Service } from '@deepseek-ai/cordis' import type { MessageId } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' +import type { SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { agentEvents } from './dispatch.ts' +import type { AgentEventDispatch } from './dispatch.ts' +import { inboxProjectionSchema } from './inbox-projection.ts' +import type { InboxState } from './inbox-projection.ts' +import type { Agent } from './runtime-types.ts' import type { InboxTarget } from './types.ts' -/** Mutable state privately owned by an {@link Inbox}. */ -type InboxState = Record - -/** Live notifications committed by inbox mutations. */ -export interface InboxNotifications { - /** Publish one inserted message. */ - inserted(message: UserMessage): void - /** Publish one discarded message. */ - discarded(message: UserMessage): void - /** Publish one claimed message inside its owning turn. */ - claimed(message: UserMessage, turn: number): void +declare module '@deepseek-ai/cordis' { + interface Context { + inboxes: InboxService + } } -/** A replay-once projection that incrementally consumes later inbox splices. */ -export class Inbox { - private readonly state: InboxState = { 'next-turn': [], 'next-step': [] } +/** Root Inbox service: creates live inboxes and owns their durable projection. */ +export class InboxService extends Service { + static inject = ['sessionProjections'] - constructor( - private readonly session: Session, - private readonly notifications: InboxNotifications, - ) { - for (const event of session.events.slice(session.header.seedLength ?? 0)) { - if (event.type !== 'agent/inbox/spliced') continue - try { - this.apply(event.data) - } catch (error: unknown) { - throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error }) - } - } + constructor(ctx: Context) { + super(ctx, 'inboxes') + ctx.sessionProjections.register({ + key: 'inbox', + schema: inboxProjectionSchema, + init: () => ({ 'next-turn': [], 'next-step': [] }), + apply(state, event) { + if (event.type !== 'agent/inbox/spliced') return state + const splice = event.data + const next = state[splice.target].toSpliced( + splice.start, + splice.removedCount ?? 0, + ...splice.inserted, + ) + return splice.target === 'next-turn' + ? { 'next-turn': next, 'next-step': state['next-step'] } + : { 'next-turn': state['next-turn'], 'next-step': next } + }, + view: state => state, + stateVersion: 1, + } satisfies ProjectionDefinition<'inbox', InboxState>) + } + + /** + * Restore one live Inbox for an agent and publish its committed mutations. + * @param agent - agent that owns the durable session and live Inbox events. + * @returns the restored Inbox. + */ + create(agent: Agent): Inbox { + return new Inbox(this.ctx, agent) + } +} + +/** Agent-owned command facade over the standard durable Inbox projection. */ +export class Inbox { + private readonly dispatch: AgentEventDispatch + + constructor(private readonly ctx: Context, private readonly agent: Agent) { + this.dispatch = agentEvents(ctx, agent) } /** Prompts awaiting individual turns. */ get nextTurn(): readonly UserMessage[] { - return this.state['next-turn'] + return this.current()['next-turn'] } /** Input awaiting the next step boundary. */ get nextStep(): readonly UserMessage[] { - return this.state['next-step'] + return this.current()['next-step'] } /** Whether either pending-message list contains work. */ get hasPending(): boolean { - return this.nextTurn.length > 0 || this.nextStep.length > 0 + const state = this.current() + return state['next-turn'].length > 0 || state['next-step'].length > 0 } /** Durably cancel all pending input, clearing next-step before next-turn. */ @@ -61,50 +89,41 @@ export class Inbox { } /** - * Remove and return the complete batch proposed for one step, publishing - * each claimed message. The durable splices are pure deletions. + * Remove and return the complete batch proposed for one step. * @param target - whether this boundary also consumes one queued turn. * @param turn - turn that will own the claimed batch. * @returns next-step input followed by the queued turn, when requested. - * @internal - The agent loop's step-boundary operation, not a plugin extension point. */ claim(target: InboxTarget, turn: number): UserMessage[] { const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) - if (target === 'next-turn') { - claimed.push(...this.mutate('next-turn', 0, 1, [], false)) - } - for (const message of claimed) this.notifications.claimed(message, turn) + if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false)) + for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn }) return claimed } /** - * Append one message to a pending list and durably record the insertion. + * Append one message to a pending list. * @param target - pending list to extend. * @param message - message to append. - * @throws if the message identity is already pending. */ append(target: InboxTarget, message: UserMessage): void { - this.splice(target, this.state[target].length, 0, [message]) + this.splice(target, this.current()[target].length, 0, [message]) } /** - * Prepend one message to a pending list and durably record the insertion. + * Prepend one message to a pending list. * @param target - pending list to extend. * @param message - message to prepend. - * @throws if the message identity is already pending. */ prepend(target: InboxTarget, message: UserMessage): void { this.splice(target, 0, 0, [message]) } /** - * Replace one pending message in place, possibly changing its identity. A - * successful replacement publishes the old message as discarded and the new - * message as inserted. + * Replace one pending message in place. * @param messageId - identity of the pending message to replace. * @param newMessage - replacement message. * @returns whether the message was still pending. - * @throws if the replacement duplicates another pending message identity. */ replace(messageId: MessageId, newMessage: UserMessage): boolean { const location = this.locate(messageId) @@ -114,7 +133,7 @@ export class Inbox { } /** - * Remove one pending message and durably record its cancellation. + * Remove one pending message. * @param messageId - identity of the pending message to remove. * @returns whether the message was still pending. */ @@ -127,9 +146,6 @@ export class Inbox { /** * Apply standard splice semantics and durably record the normalized result. - * The durable event commits before the live projection mutates, so synchronous - * `session/event` observers see the pre-splice lists and can reconstruct the - * removed messages from the normalized coordinates. * @param target - pending list to mutate. * @param start - splice position. * @param deleteCount - maximum number of messages to remove. @@ -147,14 +163,22 @@ export class Inbox { /** Locate one pending identity across both owned lists. */ private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { + const state = this.current() for (const target of ['next-turn', 'next-step'] as const) { - const index = this.state[target].findIndex(message => message.id === messageId) + const index = state[target].findIndex(message => message.id === messageId) if (index >= 0) return { target, index } } return undefined } - /** Commit one normalized mutation and publish its live notifications. */ + /** Read the current durable projection value. */ + private current(): InboxState { + // InboxService registers this required projection before creating an Inbox. + // oxlint-disable-next-line typescript/no-non-null-assertion + return this.ctx.sessionProjections.snapshot(this.agent.session).values.inbox! + } + + /** Commit one normalized mutation and publish its live events. */ private mutate( target: InboxTarget, start: number, @@ -162,7 +186,8 @@ export class Inbox { inserted: UserMessage[], discardRemoved: boolean, ): UserMessage[] { - const inbox = this.state[target] + const state = this.current() + const inbox = state[target] const truncatedStart = Math.trunc(start) const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart const actualStart = offset < 0 @@ -174,47 +199,32 @@ export class Inbox { inbox.length - actualStart, ) if (actualDeleteCount === 0 && inserted.length === 0) return [] + const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted) + const ids = new Set() + for (const message of target === 'next-turn' + ? [...candidate, ...state['next-step']] + : [...state['next-turn'], ...candidate]) { + if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) + ids.add(message.id) + } const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined - const splice = { + const splice: SessionEventMap['agent/inbox/spliced'] = { target, start: actualStart, ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }), inserted, ...(outcome === undefined ? {} : { outcome }), } - this.validate(splice) - const event = this.session.append('agent/inbox/spliced', splice) - const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted) + const removed = inbox.slice(actualStart, actualStart + actualDeleteCount) + const event = this.agent.session.append('agent/inbox/spliced', splice) if (discardRemoved) { - for (const message of removed) this.notifications.discarded(message) + for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message }) + } + for (const message of event.data.inserted) { + this.dispatch.emit('agent/inbox/inserted', { message }) } - for (const message of event.data.inserted) this.notifications.inserted(message) return removed } - - /** Apply one normalized durable splice to the projection. */ - private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] { - this.validate(splice) - const inbox = this.state[splice.target] - return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) - } - - /** Validate one normalized splice against the current projection. */ - private validate(splice: SessionEventMap['agent/inbox/spliced']): void { - const inbox = this.state[splice.target] - const removedCount = splice.removedCount ?? 0 - if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length - || !Number.isSafeInteger(removedCount) || removedCount < 0 - || splice.start + removedCount > inbox.length) { - throw new Error('invalid inbox splice') - } - const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted) - const ids = new Set() - for (const message of splice.target === 'next-turn' - ? [...candidate, ...this.nextStep] - : [...this.nextTurn, ...candidate]) { - if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) - ids.add(message.id) - } - } } + +export default InboxService diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b54e56ea8f..2430ee21ce 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -13,8 +13,8 @@ declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 4876e38809..af25e7e870 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,11 +1,13 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' -import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, - Inbox, + InboxService, } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-agent/inbox-projection' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { @@ -19,14 +21,17 @@ import type { function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) - const session = Session.create(id) + const session = overrides.session ?? Session.create(id) + const ctx = overrides.ctx ?? new Context() const agent: Agent = { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: { + nextTurn: [], nextStep: [], hasPending: false, + } as never, status: 'idle', - ctx: new Context(), + ctx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -34,32 +39,52 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), + ...overrides, } - return Object.assign(agent, overrides) + return agent +} + +async function inboxAgent(rawId: string): Promise<{ ctx: Context; session: Session; agent: Agent }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) + const session = ctx.sessions.create(SessionId(rawId)) + const agent = stubAgent(rawId, { ctx, session }) + Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + return { ctx, session, agent } } describe('Inbox', () => { - it('rejects an invalid durable splice during reconstruction', () => { - const session = Session.create(SessionId('invalid-inbox-replay')) - session.append('agent/inbox/spliced', { - target: 'next-turn', - start: 1, - inserted: [], + it('projects inherited Inbox events in a forked session', async () => { + const { ctx, session: parent, agent: parentAgent } = await inboxAgent('inbox-fork-parent') + const inherited = createUserMessage({ + content: [{ type: 'text', text: 'parent pending' }], + source: { kind: 'user' }, }) + parentAgent.inbox.append('next-turn', inherited) + const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child')) + const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) + Object.assign(childAgent, { inbox: ctx.inboxes.create(childAgent) }) - expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })) - .toThrow('invalid persisted inbox splice at session seq 0') + expect(child.header.seedLength).toBe(parent.events.length) + expect(childAgent.inbox.nextTurn).toEqual([inherited]) + expect(childAgent.inbox.nextStep).toEqual([]) + const own = createUserMessage({ + content: [{ type: 'text', text: 'child pending' }], + source: { kind: 'user' }, + }) + childAgent.inbox.append('next-turn', own) + expect(childAgent.inbox.nextTurn).toEqual([inherited, own]) }) - it('replaces a pending message by identity across both lists', () => { - const session = Session.create(SessionId('replace-inbox')) + it('replaces a pending message by identity across both lists', async () => { + const { ctx, agent } = await inboxAgent('replace-inbox') const inserted: UserMessage[] = [] const discarded: UserMessage[] = [] - const inbox = new Inbox(session, { - claimed: () => {}, - inserted: message => void inserted.push(message), - discarded: message => void discarded.push(message), - }) + ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message)) + ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message)) + const { inbox } = agent const original = createUserMessage({ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, @@ -93,9 +118,9 @@ describe('Inbox', () => { .toThrow(`message "${replacement.id}" is already pending`) }) - it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => { - const session = Session.create(SessionId('splice-inbox')) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => { + const { agent } = await inboxAgent('splice-inbox') + const { inbox } = agent const first = createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' }, @@ -104,22 +129,25 @@ describe('Inbox', () => { content: [{ type: 'text', text: 'second' }], source: { kind: 'user' }, }) + const prefixed = createUserMessage({ + content: [{ type: 'text', text: 'prefixed' }], + source: { kind: 'user' }, + }) inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second]) expect(inbox.nextTurn).toEqual([first, second]) expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second]) + inbox.prepend('next-turn', prefixed) + expect(inbox.nextTurn).toEqual([prefixed, first]) expect(inbox.remove(second.id)).toBe(false) expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`) }) - it('clears both pending lists as durable cancellations', () => { - const session = Session.create(SessionId('clear-inbox')) + it('clears both pending lists as durable cancellations', async () => { + const { ctx, session, agent } = await inboxAgent('clear-inbox') const discarded: UserMessage[] = [] - const inbox = new Inbox(session, { - claimed: () => {}, - inserted: () => {}, - discarded: message => void discarded.push(message), - }) + ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message)) + const { inbox } = agent const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } }) const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) inbox.append('next-turn', nextTurn) @@ -140,6 +168,52 @@ describe('Inbox', () => { inbox.clear() expect(session.events).toHaveLength(beforeClear + 2) }) + + it('registers the durable Inbox projection from the Inbox service', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const inboxFiber = ctx.plugin(InboxService) + await inboxFiber + const session = ctx.sessions.create(SessionId('inbox-projection')) + const agent = stubAgent('inbox-projection', { ctx, session }) + Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + const pending = createUserMessage({ + content: [{ type: 'text', text: 'pending' }], + source: { kind: 'user' }, + }) + + agent.inbox.append('next-turn', pending) + + expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({ + 'next-turn': [pending], + 'next-step': [], + }) + await inboxFiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + + it('uses the projection cell as the sole live state after direct durable appends', async () => { + const { ctx, session, agent } = await inboxAgent('inbox-live-projection') + const pending = createUserMessage({ + content: [{ type: 'text', text: 'direct' }], + source: { kind: 'user' }, + }) + let observed: readonly UserMessage[] | undefined + ctx.on('session/event', (_session, event) => { + if (event.type === 'agent/inbox/spliced') observed = agent.inbox.nextTurn + }) + + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, inserted: [pending], + }) + + expect(observed).toEqual([pending]) + expect(agent.inbox.nextTurn).toEqual([pending]) + expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({ + 'next-turn': [pending], 'next-step': [], + }) + }) }) describe('AgentRegistry', () => { diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index dfa66f16ff..068c1e6b8f 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/system-prompt" }, diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 952da5320d..9793105dbc 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -85,7 +85,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { id: ownerId, options: {}, session: ownerSession, - inbox: new Inbox(ownerSession, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx, send() {}, @@ -96,6 +96,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(owner, { inbox: new Inbox(owner.ctx, owner) }) const backend = new BashTerminalBackend(ctx, { backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 9f872e6dd6..4099235e25 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", @@ -78,6 +79,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 87098c38da..4cf542540b 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -13,12 +13,14 @@ import Timer from '@deepseek-ai/cordis-plugin-timer' import z from '@deepseek-ai/schemastery' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import SkillRegistry, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' import * as goalSession from '@deepseek-ai/dsh-goal-round-driver' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' @@ -220,6 +222,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(Timer) ctx.plugin(LlmRuntime) ctx.plugin(SessionStore) + ctx.plugin(SessionProjectionRegistry) ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG) // Owner schemas resolve defaults; forward toolOrder only when explicitly set. ctx.plugin(SystemPrompt, { @@ -235,6 +238,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SkillFileSystem, Object.assign({}, config.skills?.filesystem, { dshHome })) } ctx.plugin(AgentRegistry) + ctx.plugin(InboxService) ctx.plugin(llmRetry) if (config.goals !== undefined && config.goals !== false) { ctx.plugin(GoalService, config.goals.domain ?? {}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f6f998a383..19d6299f74 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -715,6 +715,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'inboxes', + summary: 'Root Inbox service: creates live inboxes and owns their durable projection.', + description: 'Root Inbox service: creates live inboxes and owns their durable projection.', + methods: [ + { + signature: 'create(agent: Agent): Inbox', + description: 'Restore one live Inbox for an agent and publish its committed mutations.', + parameters: [{ name: 'agent', description: 'agent that owns the durable session and live Inbox events.' }], + returns: 'the restored Inbox.', + }, + ], + }, { key: 'invariants', summary: 'Package-owned invariant registry with global and regex-based selection.', @@ -1110,7 +1123,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the full in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A domain that requires this capability declares a Cordis service dependency; an optional contributor may register under `ctx.inject([\'sessionProjections\'], …)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register(definition: ProjectionDefinition): () => void', @@ -3241,11 +3254,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Inbox', - declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', - }, - { - name: 'InboxNotifications', - declaration: 'export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n}', + declaration: 'export class Inbox {\n constructor(private readonly ctx: Context, private readonly agent: Agent);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', }, { name: 'InboxTarget', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 3c567df332..4f58bfbdc7 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -43,13 +43,12 @@ class FakeTelemetry extends SessionTelemetryBackend { /** Build a live idle agent over a store-owned session, as an app's spine does. */ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { const session = ctx.sessions.create(SessionId(id)) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) let status: AgentStatus = 'idle' const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: undefined as never, ctx: new Context(), get status() { return status }, send: () => {}, @@ -60,6 +59,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) return { agent, session } } diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index d133d86830..4f01449b15 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -29,13 +29,12 @@ function agent(ctx: Context): Agent { const scope = ctx.plugin(() => {}) const id = SessionId('feedback-loader-agent') const session = ctx.sessions.create(id) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) let status: AgentStatus = 'idle' const value: Agent = { id, options: {}, session, - inbox, + inbox: undefined as never, ctx: scope.ctx, get status() { return status }, send: () => {}, @@ -46,6 +45,7 @@ function agent(ctx: Context): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(value, { inbox: new Inbox(value.ctx, value) }) ctx.agents.register(value) return value } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index e363666606..69d931673a 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -33,7 +33,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: scope.ctx, send: () => {}, @@ -44,6 +44,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(value, { inbox: new Inbox(value.ctx, value) }) ctx.agents.register(value) return value } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 2127844646..d03054b717 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -20,23 +20,23 @@ interface Harness { function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { // Store-created: the command executor durably logs lifecycle events on it. const session = ctx.sessions.create(SessionId(id)) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) let status: AgentStatus = 'idle' const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: undefined as never, ctx: new Context(), get status() { return status }, send: () => {}, followup: () => {}, steer: () => {}, - inject(input) { inbox.append('next-step', input) }, + inject(input) { this.inbox.append('next-step', input) }, cancel() { status = 'idle' }, runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) return { agent, session } } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index e94e0754a0..8d664dbe9c 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import GoalService, { GoalError, GoalId, @@ -17,6 +19,12 @@ interface StubAgent { session: Session } +const isolatedInboxCtx = new Context() +await isolatedInboxCtx.plugin(SessionStore) +await isolatedInboxCtx.plugin(SessionProjectionRegistry) +await isolatedInboxCtx.plugin(InboxService) +const sessionStubs = new WeakMap() + /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 @@ -24,41 +32,59 @@ function nextTurn(session: Session): number { /** Mirror the public Agent.inject contract for domain tests. */ function appendInjection(session: Session, input: UserMessage): void { - new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input) + stubAgentForSession(session).agent.inbox.append('next-step', input) } /** Build a registry-compatible agent around one concrete session. */ -function stubAgentForSession(session: Session): StubAgent { +function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent { + const existing = sessionStubs.get(session) + if (existing !== undefined) return existing const id = session.id - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const agentCtx = suppliedCtx ?? isolatedInboxCtx + if (suppliedCtx === undefined) { + agentCtx.sessions.enter(session) + } const agent: Agent = { id, options: {}, session, - inbox, - ctx: new Context(), + inbox: undefined as never, + ctx: agentCtx, status: 'idle', send: () => {}, followup: () => {}, steer: () => {}, - inject(input) { inbox.append('next-step', input) }, + inject(input) { this.inbox.append('next-step', input) }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - return { + Object.assign(agent, { inbox: agentCtx.inboxes.create(agent) }) + const stub = { agent, session, } + sessionStubs.set(session, stub) + return stub } /** Build a registry-compatible agent around a fresh session. */ -function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent { - return stubAgentForSession(Session.create(SessionId(rawId), seed)) +function stubAgent( + rawId: string, + seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[], + ctx?: Context, +): StubAgent { + const session = ctx === undefined + ? Session.create(SessionId(rawId), seed) + : ctx.sessions.create(SessionId(rawId), { ...(seed === undefined ? {} : { seed }) }) + return stubAgentForSession(session, ctx) } async function harness(config: { defaultMaxGoalRounds?: number } = {}) { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService, config) const stub = stubAgent(`goal-test-${Math.random()}`) @@ -168,14 +194,16 @@ describe('GoalService creation and replay', () => { it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) - const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) + const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx) ctx.agents.register(parent.agent) const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 }) appendRound(parent.session, goal, 1) - const child = stubAgentForSession(ctx.sessions.fork(parent.session)) + const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx) ctx.agents.register(child.agent) expect(ctx.goals.get(child.agent)).toMatchObject({ id: goal.id, @@ -235,7 +263,7 @@ describe('GoalService creation and replay', () => { const { ctx, agent } = await harness() // A same-id agent backed by a different session object — the live-instance // check must reject it even though the ids match. - const impostor = stubAgentForSession(Session.create(agent.id)).agent + const impostor = { ...agent, session: Session.create(agent.id) } as Agent expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE', @@ -422,9 +450,11 @@ describe('GoalService mutations', () => { it('publishes a mutation consistently to a reentrant session observer', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) - const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) + const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx) ctx.agents.register(stub.agent) let observed: ReturnType ctx.on('session/event', (session, event) => { @@ -481,7 +511,7 @@ describe('GoalService mutations', () => { }) }) - it('reports the same corrupt unseen event after committing its valid prefix', async () => { + it('rejects a corrupt append while preserving the valid prefix', async () => { const { ctx, agent, session } = await harness() expect(ctx.goals.get(agent)).toBeUndefined() const change: GoalSnapshotChangeMeta = { @@ -500,10 +530,11 @@ describe('GoalService mutations', () => { updatedAt: 12, } session.append('goal/change', change) - session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) + expect(() => { + session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) + }).toThrow('snapshot change must have exactly') - expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') - expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') + expect(ctx.goals.get(agent)).toMatchObject({ id: change.goal.id, objective: 'valid prefix' }) }) }) @@ -570,7 +601,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'unrelated pending context' }], source: { kind: 'plugin', plugin: 'test' }, }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = stubAgentForSession(session).agent.inbox inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 6282a6f739..0286e93103 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -31,19 +31,18 @@ interface Bench { /** Register a minimal registry-compatible live agent over a store session. */ function liveAgent(ctx: Context, session: Session): Agent { const status: AgentStatus = 'idle' - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: { nextTurn: [], nextStep: [], hasPending: false } as never, ctx, get status() { return status }, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject(input: UserMessage) { - inbox.append('next-step', input) + this.inbox.append('next-step', input) }, cancel() {}, runMaintenance: task => task(new AbortController().signal), @@ -130,10 +129,14 @@ describe('goal projection unit', () => { const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' }) bench.ctx.goals.clear(bench.agent, created) - bench.agent.inbox.prepend('next-step', createUserMessage({ - content: [{ type: 'text', text: 'unrelated pending context' }], - source: { kind: 'plugin', plugin: 'test' }, - })) + bench.session.append('agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [createUserMessage({ + content: [{ type: 'text', text: 'unrelated pending context' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) expect(bench.tailValues().goal).toBeNull() expect(foldGoal(bench.session.events).goal).toBeUndefined() diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index cb5a2a9874..7e25c3f5b7 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } 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 type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -21,17 +23,28 @@ interface StubAgent { setStatus(status: AgentStatus): void } +const isolatedInboxCtx = new Context() +await isolatedInboxCtx.plugin(SessionStore) +await isolatedInboxCtx.plugin(SessionProjectionRegistry) +await isolatedInboxCtx.plugin(InboxService) + /** Build one registry-compatible live agent whose injections enter the durable inbox. */ -function stubAgent(rawId: string, supplied?: Session): StubAgent { - const session = supplied ?? Session.create(SessionId(rawId)) +function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): StubAgent { + const agentCtx = suppliedCtx ?? isolatedInboxCtx + const session = supplied ?? (suppliedCtx === undefined + ? agentCtx.sessions.create(SessionId(rawId)) + : suppliedCtx.sessions.create(SessionId(rawId))) + if (suppliedCtx === undefined) { + if (agentCtx.sessions.get(session.id) !== session) agentCtx.sessions.enter(session) + } let status: AgentStatus = 'running' const agent: Agent = { id: session.id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, get status() { return status }, - ctx: new Context(), + ctx: agentCtx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -42,6 +55,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } + Object.assign(agent, { inbox: agentCtx.inboxes.create(agent) }) return { agent, session, setStatus(value) { status = value } } } @@ -71,12 +85,15 @@ function closeTurn(stub: StubAgent, turn: number): void { async function harness(config: toolGoal.Config = {}) { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) const fiber = await ctx.plugin(toolGoal, config) - const root = stubAgent(`goal-tool-root-${Math.random()}`) + const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) ctx.agents.register(root.agent) return { ctx, fiber, root } } @@ -243,7 +260,7 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'user' }) // A distinct agent object over root's exact session: same id, not the live // registered instance, so the executor must reject it. - const stale = stubAgent('goal-tool-stale', root.agent.session).agent + const stale = stubAgent('goal-tool-stale', root.agent.session, ctx).agent const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index ab8f6aa127..11d633fa28 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -16,7 +16,7 @@ import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' -import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' @@ -1267,16 +1267,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) }) - /** Project both durable inbox lists, optionally including the splice currently being emitted. */ - const queueItems = ( - agent: Agent, - splice?: SessionEventMap['agent/inbox/spliced'], - ): QueuedInboxItem[] => { + /** Project both durable inbox lists from their committed state. */ + const queueItems = (agent: Agent): QueuedInboxItem[] => { const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { - const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep - return splice?.target === target - ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) - : messages + return target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep } return [ ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })), @@ -1295,7 +1289,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (event.type !== 'agent/inbox/spliced') return const agent = ctx.agents.get(session.id) if (agent?.session !== session) return - broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) }) + broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }) }) /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */ diff --git a/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts b/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts index d61e618a96..7e61b1eff7 100644 --- a/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -56,11 +56,19 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: const session = ctx.sessions.create() const agent = { id: session.id, + options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: { nextTurn: [], nextStep: [], hasPending: false } as never, status: 'idle', ctx, - } as Agent + send() {}, + followup() {}, + steer() {}, + inject() {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } satisfies Agent ctx.agents.register(agent) return { ctx, session, agent } } diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index 0fb88c766d..fb6af25efb 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -10,7 +10,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' +import type {} from '@deepseek-ai/dsh-agent/inbox-projection' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -52,10 +54,28 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: await ctx.plugin(SessionStore) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) - if (withRegistry) await ctx.plugin(SessionProjectionRegistry) + if (withRegistry) { + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) + } const session = ctx.sessions.create() - // The gateway reads both the session and durable inbox baseline. - ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent) + const agent = { + id: session.id, + options: {}, + session, + inbox: { nextTurn: [], nextStep: [], hasPending: false } as never, + status: 'idle', + ctx, + send() {}, + followup() {}, + steer() {}, + inject() {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } satisfies Agent + if (withRegistry) Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + ctx.agents.register(agent) return { ctx, session } } @@ -87,6 +107,78 @@ describe('session.history projections block', () => { expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq) }) + it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('cold-persisted-queue') + const meta = { version: 0 as const, id: coldId, createdAt: 1, cwd: '/tmp' } + const message = createUserMessage({ + content: [{ type: 'text', text: 'survive process restart' }], + source: { kind: 'user' }, + }) + const events = [{ + type: 'agent/inbox/spliced', + seq: 0, + time: 2, + data: { target: 'next-turn', start: 0, inserted: [message] }, + }] as const + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + } as never) + const response = await api(ctx).sessions.history(request({ sessionId: coldId })) + if (!response.result.ok) throw new Error('history failed') + + expect(response.result.value.projections?.values.inbox).toEqual({ + 'next-turn': [message], + 'next-step': [], + }) + expect(ctx.agents.get(coldId)).toBeUndefined() + expect(ctx.sessions.get(coldId)).toBeUndefined() + }) + + it('removes claimed steering from the pending Inbox projection immediately', async () => { + const { ctx, session } = await harness(true) + const proxy = api(ctx) + const message = createUserMessage({ + content: [{ type: 'text', text: 'apply this now' }], + source: { kind: 'user' }, + }) + const agent = ctx.agents.get(session.id) + if (agent === undefined) throw new Error('missing Agent') + agent.inbox.append('next-step', message) + agent.inbox.claim('next-step', 1) + + const during = await proxy.sessions.history(request({ sessionId: session.id })) + if (!during.result.ok) throw new Error('history failed') + expect(during.result.value.projections?.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + + session.append('user/message', message, { surfaceOp: 'append' }) + const settled = await proxy.sessions.history(request({ sessionId: session.id })) + if (!settled.result.ok) throw new Error('history failed') + expect(settled.result.value.projections?.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + + const rejected = createUserMessage({ + content: [{ type: 'text', text: 'reject this pre-step' }], + source: { kind: 'user' }, + }) + session.append('turn/start', { turn: 1 }) + agent.inbox.append('next-step', rejected) + agent.inbox.claim('next-step', 1) + session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } }) + const closed = await proxy.sessions.history(request({ sessionId: session.id })) + if (!closed.result.ok) throw new Error('history failed') + expect(closed.result.value.projections?.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + }) + it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => { const { ctx, session } = await harness(true) const limits = { diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 54bdb015e4..0bb9eb55b9 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -40,11 +40,11 @@ async function nextHostFrame( } function stubAgent(session: Session): Agent { - return { + const agent: Agent = { id: session.id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: new Context(), send: () => {}, @@ -55,6 +55,8 @@ function stubAgent(session: Session): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 33ea29ba68..b333b87222 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle' as const, ctx: agentCtx, send: () => {}, @@ -45,6 +45,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), whenIdle() { return Promise.resolve() }, } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) return agent } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 44386ce78b..cb57cc7b41 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 367e8f46f4..7d869af764 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -7,10 +7,12 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmRuntime, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } 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 * as retry from '../src/index.ts' @@ -60,9 +62,11 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-agent/inbox', InboxService], ['@deepseek-ai/dsh-llm-retry', retry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ]) @@ -89,9 +93,11 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-agent/inbox'", "- name: '@deepseek-ai/dsh-llm-retry'", "- name: '@deepseek-ai/dsh-agent-loop'", ]) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index c2643a6d45..236355cd01 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AlwaysRetryPolicyConfig, BackoffConfig, @@ -11,14 +11,13 @@ import type { RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable @@ -105,11 +104,7 @@ async function harness( internals: retry.RetryInternals = {}, ): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> { const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) beforeRetry?.(ctx) adapter.configureRetryPolicies(policies) const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 77efc3beb3..959b36f2ac 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import LlmRuntime, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -23,6 +25,8 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a8f374d765..78c45d8d8e 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index dda3644f55..f1e26a79d7 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -1,10 +1,12 @@ import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } 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 AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' @@ -27,6 +29,8 @@ async function harness(roster: Partial = {}): Promise { ctx.loader.builtins.include = Include await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 824308e009..23a56112e7 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -3,10 +3,12 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } 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 AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' @@ -45,6 +47,8 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc ctx.loader.builtins.include = Include await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -414,6 +418,8 @@ describe('the preset file is an input, never a persistence target', () => { scoped.loader.builtins.include = Include await scoped.plugin(LlmRuntime) await scoped.plugin(SessionStore) + await scoped.plugin(SessionProjectionRegistry) + await scoped.plugin(InboxService) await scoped.plugin(SystemPrompt, { persona: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) @@ -579,6 +585,8 @@ describe('replacing a composition', () => { scoped.loader.builtins.include = Include await scoped.plugin(LlmRuntime) await scoped.plugin(SessionStore) + await scoped.plugin(SessionProjectionRegistry) + await scoped.plugin(InboxService) await scoped.plugin(SystemPrompt, { persona: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index e3af9e1ec1..4b09b1b663 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -16,7 +16,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { describe, expect, it } from 'vitest' @@ -43,6 +45,8 @@ async function harness( ctx.loader.builtins.include = Include await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index d49f33b715..106725c8ad 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -57,12 +57,11 @@ async function harness(): Promise { onFollowup: undefined as (() => void) | undefined, idle: Promise.withResolvers(), } - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: undefined as never, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, @@ -97,6 +96,7 @@ async function harness(): Promise { steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) const disposeAgent = ctx.agents.register(agent) ctx.on('session/event', (_session, event) => { if (event.type === 'schedule/change' && event.data.operation === 'dispatch') order.push('dispatch') diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index 63f080a678..7b387c7ef5 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -24,12 +24,11 @@ interface ToolHarness { function stubAgent(ctx: Context, id: string): Agent { const session = ctx.sessions.create(SessionId(id)) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - return { + const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: undefined as never, status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, @@ -40,6 +39,8 @@ function stubAgent(ctx: Context, id: string): Agent { steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } async function harness(withPersistence = true): Promise { diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index fb26401df9..c6448d62ed 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62 -README.zh.md: 2a3af5620f84ff9697268101a6cfb894232b68b7 +README.md: 5adefbd34cb753d97559bee307f7e52e4864911c +README.zh.md: d8f41d8f24ede743514a54c48ee7ea73c44f1ee4 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 9018b133bb..5adefbd34c 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Session-projection Service Definition and drive registry. It owns `ctx.sessionProjections`, the registry that drives every registered projection unit over committed session events and serves finished whole values to carriers, currently the api-proxy history tail page and `session/projection` push frame. A domain registers pure mathematics; the framework owns the drive. The [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) records the design rationale. +Session-projection Service Definition and drive registry. It owns `ctx.sessionProjections`, the registry that folds committed session events through every registered projection and serves finished whole values to carriers, currently the api-proxy history tail page and `session/projection` push frame. A domain registers pure mathematics; the framework owns the drive. The [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) records the design rationale. ## Service: `SessionProjectionRegistry` (ctx key: `sessionProjections`) ### Public API -- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence. +- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Registrants with the same key and `stateVersion` share its cells; an incompatible version or invalid `stateVersion` throws. The registration is an effect on the calling fiber, so the last unload removes the key and its cached cells from subsequent drives and snapshots. - `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`. - `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). @@ -19,13 +19,13 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr ## Contract -- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch. +- **The framework drives, the domain computes.** The registry subscribes to `session/event` once and runs every unit's `apply` over each committed event. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch. - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. -- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). +- **Whole-value projection rule.** Every served projection value is a complete read model. Source events may carry whole values or domain-owned operations; the unit's deterministic `apply` owns replay, while checkpoint plus forward tail replay produces the same state without repeating aggregates in the log. - **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly. - **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. -- **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. +- **Dependency follows use.** A domain that requires projection state declares `sessionProjections` as a Cordis service dependency; optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. ## Role @@ -43,6 +43,6 @@ None; projections never assemble or send provider requests. - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. -- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. +- **Eager drive touches every unit per event** — bounded by each unit's transition and the same-reference gate, but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 2a3af5620f..d8f41d8f24 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -会话投影 Service Definition 与驱动注册表。它拥有 `ctx.sessionProjections`:该注册表在已提交的会话事件上驱动每个已注册的投影单元,并向载体提供完整的最终值,目前包括 api-proxy 历史尾页和 `session/projection` 推送帧。领域注册的只是纯数学;驱动权归框架。[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) 记录了设计理由。 +会话投影 Service Definition 与驱动注册表。它拥有 `ctx.sessionProjections`:该注册表把已提交的会话事件折叠进每个已注册投影,并向载体提供完整的最终值,目前包括 api-proxy 历史尾页和 `session/projection` 推送帧。领域注册的只是纯数学;驱动权归框架。[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) 记录了设计理由。 ## 服务:`SessionProjectionRegistry`(ctx 键:`sessionProjections`) ### 公开 API -- `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。 +- `ctx.sessionProjections.register(definition): () => void` 注册一个领域单元。具有相同 key 和 `stateVersion` 的注册方共享其 cell;版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,最后一个注册方卸载后,key 及其缓存 cell 会从后续驱动与快照中消失。 - `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。 - `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。 @@ -19,13 +19,13 @@ ## 约定 -- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 +- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`,并让每个单元的 `apply` 处理每个已提交事件。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 -- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 +- **全量投影值规则。** 每个对外投影值都是完整读模型。源事件可以携带完整值,也可以携带领域拥有的操作;单元的确定性 `apply` 负责回放,checkpoint 加前向 tail replay 会得到同一状态,无需在日志中重复聚合。 - **单元的同步纪律。**`init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。 - **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 -- **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 +- **依赖方式由用途决定。** 必须使用投影状态的领域把 `sessionProjections` 声明为 Cordis 服务依赖;可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 ## 职责 @@ -43,6 +43,6 @@ - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 -- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 +- **eager 驱动逐事件触达每个单元**——成本受每个单元的 transition 与同引用闸门约束;若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 9f0c24e72e..b575c25b9a 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -5,14 +5,10 @@ * forward eagerly over committed session events. Domain host plugins * contribute pure mathematics (init/apply/view); the framework owns the * subscription, the per-session watermark cache, and change notification; - * carriers consume the snapshot read face and the change feed. Neither side - * knows the other - * (capability-seam three-way split). Design authority: the session-projection - * RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). - * - * Whole-value event rule (load-bearing): a state-carrying log event MUST - * carry the complete post-change state, never a bare delta — it keeps every - * unit's transition trivially cheap and every served value self-describing. + * carriers consume the snapshot read face and the change feed. Source events + * may carry whole values or domain operations; every `view` returns a complete + * current value. Design authority: the session-projection RFC + * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * * @module @deepseek-ai/dsh-session-projection */ @@ -158,15 +154,15 @@ interface Registration { * every registered unit's `apply` (eager drive), and a changed state * reference notifies the change feed with the schema-validated view. * Cells build lazily — a unit registered after events flowed, or a session - * older than the registry, folds `init` over the in-memory log on first + * older than the registry, folds `init` over the full in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. Domain - * plugins register under `ctx.inject(['sessionProjections'], …)` so headless - * assemblies without the registry stay unaffected. Registrants sharing a key - * share one unit and are counted: the same tool package mounted in N agent - * presets registers N times, and the key survives until the last one - * unloads. + * and clients read it as capability absence. A domain that requires this + * capability declares a Cordis service dependency; an optional contributor + * may register under `ctx.inject(['sessionProjections'], …)`. Registrants + * sharing a key share one unit and are counted: the same tool package mounted + * in N agent presets registers N times, and the key survives until the last + * one unloads. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 6d6affdb44..5fade7b924 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -44,7 +44,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: scope.ctx, send: () => {}, @@ -55,6 +55,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(value, { inbox: new Inbox(value.ctx, value) }) ctx.agents.register(value) return value } diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 0386295c4c..b1885f420b 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -39,7 +39,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: scope.ctx, send: () => {}, @@ -50,6 +50,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(value, { inbox: new Inbox(value.ctx, value) }) ctx.agents.register(value) return value } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index d8dd9df2f4..ff9e14bf5f 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -39,12 +39,12 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', send: () => {}, followup: () => {}, @@ -54,14 +54,16 @@ function agentForCwd(cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { - return { + const agent: Agent = { id: SessionId(id), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'running', ctx: new Context(), send: () => {}, @@ -72,6 +74,8 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } function openMessageTurn(session: Session, turn = 1): void { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 3fd303e7f6..a2d9be0214 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -34,7 +34,7 @@ afterEach(() => { /** Boot the continuable stack with real JSONL session persistence. */ async function setup( script: Script, - options: { sessionProjections?: boolean; projectionCache?: boolean } = {}, + options: { projectionCache?: boolean } = {}, ) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) @@ -42,7 +42,6 @@ async function setup( roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) - if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry) if (options.projectionCache === true) { await ctx.plugin(Storage) ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool())) @@ -59,6 +58,13 @@ async function setup( return { ctx, parent } } +async function setupWithoutProjections(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SubagentRuntime) + return ctx +} + const testSignal = new AbortController().signal /** Start one continuable child through the real service path and await Activation release. */ @@ -175,8 +181,8 @@ describe('SubagentRuntime.listChildren', () => { }) it('fails loud when the projection registry is not mounted, even with no children', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( + const ctx = await setupWithoutProjections() + await expect(ctx.subagents.listChildren(SessionId('no-projections-parent'))).rejects.toThrow( expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, ) }) @@ -966,8 +972,9 @@ describe('SubagentRuntime.listChildren', () => { }) it('SubagentError from listChildren is typed with its stable code', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error) + const ctx = await setupWithoutProjections() + const caught: unknown = await ctx.subagents.listChildren(SessionId('typed-no-projections-parent')) + .catch((error: unknown) => error) expect(caught).toBeInstanceOf(SubagentError) expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') }) @@ -1206,8 +1213,8 @@ describe('SubagentRuntime.listDescendants', () => { }) it('fails loud when the projection registry is not mounted', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow( + const ctx = await setupWithoutProjections() + await expect(ctx.subagents.listDescendants(SessionId('no-projections-root'))).rejects.toThrow( expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, ) }) diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 4eb8008c3b..752abaecbc 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -58,7 +57,6 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 0636254841..1f88265219 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -57,7 +56,6 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 2a4d7848df..0e104d43c5 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -47,8 +47,8 @@ function config(): ResolvedConfig { function agent(ctx: Context, cwd?: string): Agent { const id = SessionId('agent') const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }) - return { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + const agent: Agent = { + id, options: {}, session, inbox: undefined as never, status: 'idle', ctx, send: () => {}, @@ -56,6 +56,8 @@ function agent(ctx: Context, cwd?: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } function terminalHandle(): SubprocessTerminalHandle { @@ -391,7 +393,7 @@ describe('terminal-bash plugin shape', () => { const session = ctx.sessions.create(SessionId('mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id: session.id, options: {}, session, inbox: undefined as never, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -399,6 +401,7 @@ describe('terminal-bash plugin shape', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(owner, { inbox: new Inbox(owner.ctx, owner) }) ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const created = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -440,7 +443,7 @@ describe('terminal-bash plugin shape', () => { const session = ctx.sessions.create(SessionId('pending-mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id: session.id, options: {}, session, inbox: undefined as never, status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -448,6 +451,7 @@ describe('terminal-bash plugin shape', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(owner, { inbox: new Inbox(owner.ctx, owner) }) ctx.agents.register(owner) const gate = Promise.withResolvers() await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise)) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index c7af8668a3..392a106f30 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -35,8 +35,8 @@ function stubAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const scope = ctx.plugin(() => {}) const session = Session.create(id) - return { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + const agent: Agent = { + id, options: {}, session, inbox: undefined as never, status: 'idle', ctx: scope.ctx, send: () => {}, @@ -44,6 +44,8 @@ function stubAgent(ctx: Context, rawId: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + return agent } async function harness( diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 10484d3c5c..7b73ebfbd7 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -26,7 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: undefined as never, status: 'idle', ctx: scopeFiber.ctx, send: () => {}, @@ -37,6 +37,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) return agent } diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index ada46ef78f..1262156be2 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -41,7 +41,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const session = Session.create(id) const value: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: undefined as never, status: 'idle', ctx: scope.ctx, send: () => {}, @@ -49,6 +49,7 @@ function agent(ctx: Context): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(value, { inbox: new Inbox(value.ctx, value) }) ctx.agents.register(value) return value } diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 9feaf1e7b0..4c50898355 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const session = Session.create(id) const agent: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: undefined as never, status: 'idle', ctx: scope.ctx, send: () => {}, @@ -26,6 +26,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) ctx.agents.register(agent) return agent } diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index e677c8befd..bbc9dd9b1d 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/test-support/agent-loop-testkit/src/index.ts b/packages/test-support/agent-loop-testkit/src/index.ts index e2ae19653d..8b327edc4b 100644 --- a/packages/test-support/agent-loop-testkit/src/index.ts +++ b/packages/test-support/agent-loop-testkit/src/index.ts @@ -7,8 +7,10 @@ import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -40,6 +42,8 @@ export async function mountAgentLoopTestDependencies( ): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) await ctx.plugin(ToolRuntime, options.tools ?? {}) await ctx.plugin(AgentRegistry) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 945b2e84e5..24199f8f10 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -32,12 +32,13 @@ function agent(ctx: Context): Agent { const id = SessionId('todo-loader-agent') const session = Session.create(id) const value: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: undefined as never, status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + Object.assign(value, { inbox: new Inbox(value.ctx, value) }) ctx.agents.register(value) return value } diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts index b87d0eb62a..d9a118c7bb 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts @@ -1,12 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -30,11 +27,7 @@ afterEach(async () => { async function harness(): Promise { const built = new Context() - await built.plugin(LlmRuntime) - await built.plugin(SessionStore) - await built.plugin(SystemPrompt) - await built.plugin(ToolRuntime) - await built.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(built) await built.plugin(AgentLoop, { agents: [] }) await built.plugin(LlmDeepSeek) await built.plugin(SubagentRuntime) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7849f183aa..8b729ed9ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3311,6 +3311,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs @@ -3439,6 +3442,10 @@ importers: version: link:../../core/system-prompt packages/core/agent: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3455,6 +3462,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -3517,6 +3527,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3870,6 +3883,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title @@ -5426,6 +5442,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5696,6 +5715,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -7777,6 +7799,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fac02f9db3..fec6cbf6bc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -54,6 +54,7 @@ export const SERVICE_PAGE: Record = { agentDefaultModel: 'core.md', agentPresets: 'core.md', agents: 'core.md', + inboxes: 'core.md', apiProxy: 'typert.md', approval: 'approval.md', attachments: 'attachment.md', @@ -225,6 +226,7 @@ export const LINK_MAP: Readonly> = { ContentBlock: 'llm-streaming.md', CreateAgentOptions: 'core.md', GenerateOptions: 'llm-streaming.md', + Inbox: 'core.md', InboxItem: 'core.md', InboxPlacement: 'core.md', MessageId: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1cd41fe3de..4be1cd9d33 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -139,6 +139,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, + { + key: 'inboxes', + pkg: 'agent', + title: 'Durable pending-input facade', + mode: 'core', + consumers: ['agent-loop'], + note: 'Registers the standard Inbox projection and creates command facades over its sole live state.', + }, { key: 'invariants', pkg: 'invariants', diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f97108033..3a74653569 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -95,6 +95,8 @@ "@deepseek-ai/dsh-user-questions/types": ["./packages/interaction/user-questions/src/types.ts"], "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"], "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], + "@deepseek-ai/dsh-agent/inbox": ["./packages/core/agent/src/inbox.ts"], + "@deepseek-ai/dsh-agent/inbox-projection": ["./packages/core/agent/src/inbox-projection.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], From 4d5ccf39ef4717c5829d9afcddaeaae38bca9ae1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 11:19:36 +0800 Subject: [PATCH 02/88] refactor(agent): remove inbox service --- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 4 +- ...-07-31-claimed-pre-step-inbox-lifecycle.md | 4 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 4 +- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 2 +- ...7-session-projection-and-command-log.zh.md | 2 +- apps/cli/composition.md | 3 - apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 - docs/capability-seams.zh.md | 4 - docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 21 +----- docs/subsystems/core.zh.md | 21 +----- .../headless-agent/tests/code-mode.e2e.ts | 3 - .../tests/fixtures/e2b/e2b/bin.ts | 4 +- packages/bundle/base/cordis.patch.yml | 3 - .../bundle/headless/tests/headless.spec.ts | 6 +- .../tests/agent-instructions.spec.ts | 8 +- .../time-context/tests/time-context.spec.ts | 2 +- .../tmux-context/tests/tmux-context.spec.ts | 2 +- packages/core/agent-loop/package.json | 1 + packages/core/agent-loop/src/agent.ts | 5 +- packages/core/agent-loop/src/index.ts | 2 +- .../agent-loop/tests/agent-initiator.spec.ts | 4 - packages/core/agent-loop/tests/agent.spec.ts | 2 - packages/core/agent-loop/tests/cancel.spec.ts | 3 - .../tests/config-session-id.spec.ts | 8 -- .../tests/contract-regressions.spec.ts | 9 --- .../agent-loop/tests/coverage-edges.spec.ts | 2 - .../agent-loop/tests/interception.spec.ts | 2 - packages/core/agent-loop/tests/loop.spec.ts | 4 - .../core/agent-loop/tests/properties.spec.ts | 2 - .../agent-loop/tests/request-cache.e2e.ts | 2 - .../agent-loop/tests/request-error.spec.ts | 2 - .../tests/request-reconstruction.spec.ts | 4 - packages/core/agent-loop/tests/resume.spec.ts | 11 --- .../agent-loop/tests/scope-lifecycle.spec.ts | 2 - .../core/agent-loop/tests/settings.spec.ts | 2 - .../core/agent-loop/tests/tool-calls.spec.ts | 5 -- .../core/agent-loop/tests/tool-order.spec.ts | 2 - packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/package.json | 8 -- packages/core/agent/src/inbox-projection.ts | 25 +++++++ packages/core/agent/src/inbox.ts | 75 +++---------------- packages/core/agent/src/index.ts | 6 ++ packages/core/agent/tests/agent.spec.ts | 19 +++-- packages/e2b/e2b/tests/composition.e2e.ts | 4 +- .../examples/agent-spine-demo/src/index.ts | 2 - .../extensions/tool-cordis/src/api-catalog.ts | 23 +++--- .../tests/command-feedback.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../tests/tools.spec.ts | 4 +- .../command-goal/tests/command-goal.spec.ts | 4 +- packages/goal/goal/tests/goal.spec.ts | 10 +-- .../goal/tool-goal/tests/tool-goal.spec.ts | 8 +- .../tests/api-proxy-projections.spec.ts | 7 +- .../tests/api-proxy-workspace.spec.ts | 4 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 3 - .../plan/plan-mode/tests/integration.spec.ts | 2 - .../agent-presets/tests/invariant.spec.ts | 2 - .../preset/agent-presets/tests/mount.spec.ts | 4 - .../agent-presets/tests/settings.spec.ts | 2 - .../schedule/schedule/tests/runtime.spec.ts | 4 +- .../schedule/schedule/tests/tools.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../tool-bash-persistent/tests/tools.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 4 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 +- .../terminal-bash/tests/index.spec.ts | 8 +- .../terminal-bash/tests/local.spec.ts | 4 +- .../terminal/terminal/tests/service.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../tool-terminal/tests/tools.spec.ts | 4 +- .../agent-loop-testkit/src/index.ts | 2 - .../tests/loader-composition.spec.ts | 4 +- scripts/gen-cordis-catalog.ts | 1 - scripts/gen-doc-graphs.ts | 8 -- tsconfig.base.json | 2 - 89 files changed, 160 insertions(+), 350 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 3c9a697dd3..84deedb9f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: c46c22cde3e96fb774233d9621acf1abf4ba0c33 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 51ada096a105a5c97cffd70c2d40782a71beddf7 +2026-07-31-claimed-pre-step-inbox-lifecycle.md: 8149c9df09ff69c017ba66a779d6fd3dfc314193 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: e855b88b26a55dd9f5c33c87a4b59d67496d00ed diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index c46c22cde3..8149c9df09 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -18,11 +18,11 @@ Before every proposed step, `Inbox.claim(target)` atomically removes the complet The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from Inbox itself. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `InboxService` registers the standard `inbox` projection over the durable `agent/inbox/spliced` stream for whole-state consumers and live restoration; UI edits and removals route through an Inbox mutation method so the same projection records every change. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. -The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while `InboxService` registers `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `AgentRegistry` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index 51ada096a1..e855b88b26 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -18,11 +18,11 @@ Status: implemented 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 Inbox 自行发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`InboxService` 在持久 `agent/inbox/spliced` 流上注册标准 `inbox` 投影,供整体状态消费方与 live 恢复使用;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 -已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而 `InboxService` 把 `inbox` 注册为持久 splice 上的标准会话投影。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `AgentRegistry` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 ## 曾考虑的替代方案 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index cd83295b92..a13f220dfa 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: d2280d70d1a94a0de6b9f509c142b3d2fe88638b -2026-07-27-session-projection-and-command-log.zh.md: dcd731029445936e28e8e18d469b35ecd6c41d97 +2026-07-27-session-projection-and-command-log.md: ec21cfe761d499f111f2a147f022d553561fd98d +2026-07-27-session-projection-and-command-log.zh.md: a24698990c860a2fb39bcdfff79e04c8c7c8310f diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index d2280d70d1..ec21cfe761 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -65,7 +65,7 @@ declare module 'cordis' { The registry's two read faces serve [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md): a `subagent` unit folds durable mode/label identity last-wins from `subagent/descriptor`, and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child and `restore({}, events, 0)` over one persistence inspection for a cold one. An absent value remains that consumer's signal, and how absence renders is its own decision. -Inbox's owning service registers its operation-folding unit. Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner. +The Agent registry contributes Inbox's operation-folding unit whenever the projection registry is composed. Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner. ### Wire: projections block on the history tail page diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index dcd7310294..a24698990c 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -65,7 +65,7 @@ declare module 'cordis' { 注册表的两处读取接口服务于[通过投影单元读取 subagent 列表身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md):`subagent` 单元从 `subagent/descriptor` 按 last-wins 规则折叠持久化的 mode/label 身份,`SubagentRuntime.listChildren` 对 live child 通过 `snapshot()` 读取,对 cold child 则通过一次持久化检查结果调用 `restore({}, events, 0)` 读取。值缺席仍是该消费方的信号,如何呈现缺席仍由消费方自行决定。 -Inbox 的所属服务注册负责折叠操作的单元。它与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。 +Agent 注册表会在投影注册表已组合时贡献 Inbox 的操作折叠单元。该单元与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。 ### 协议层:历史尾页上的 projections 块 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 08e5d4db39..4d37b9186a 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -30,8 +30,6 @@ flowchart LR cfg --> plugin_dsh_base_user_questions plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent - plugin_dsh_base_agent_inbox["agent-inbox
@deepseek-ai/dsh-agent/inbox"] - cfg --> plugin_dsh_base_agent_inbox plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] @@ -181,7 +179,6 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | | `agent` | `@deepseek-ai/dsh-agent` | -| `agent-inbox` | `@deepseek-ai/dsh-agent/inbox` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 87254a8d2d..049123717b 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' -import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' @@ -34,7 +34,7 @@ try { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: new AbortController().signal }, diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index a7b8b371b1..e696bc3423 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 2f6bc45f9cb60809cedf31f86fcb68785ef80b9a -capability-seams.zh.md: a9eeb06c063a86cdcd6ef3560677129d3adb2798 +capability-seams.md: a84a6d6e0836524e1f39f2fd067ae1f6570741a5 +capability-seams.zh.md: b6b0aba72729f9b9b752347ab00cd76a45c248d9 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2f6bc45f9c..a84a6d6e08 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -31,7 +31,6 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] - svc_inboxes["ctx.inboxes
Durable pending-input facade"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -202,7 +201,6 @@ flowchart LR svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval pkg_agent --> svc_agents - pkg_agent --> svc_inboxes pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets @@ -329,7 +327,6 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fs --> pkg_tool_fs - svc_inboxes --> pkg_agent_loop svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope @@ -429,7 +426,6 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.inboxes` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop) | - | Registers the standard Inbox projection and creates command facades over its sole live state. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a9eeb06c06..b6b0aba727 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -33,7 +33,6 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] - svc_inboxes["ctx.inboxes
Durable pending-input facade"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -204,7 +203,6 @@ flowchart LR svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval pkg_agent --> svc_agents - pkg_agent --> svc_inboxes pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets @@ -331,7 +329,6 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fs --> pkg_tool_fs - svc_inboxes --> pkg_agent_loop svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope @@ -431,7 +428,6 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | -| `ctx.inboxes` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop) | - | 注册标准 Inbox 投影,并在其唯一 live 状态上创建命令 facade。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 41dc01f906..8070261be3 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: b35358c651454ed42a9c0cfd466f305c7d632e70 -config-catalog.zh.md: f6bb73d14e3da28e05628d7b1d7c29d053382f24 +config-catalog.md: 453065bec7ddc373627f9f632445db7e16b56f59 +config-catalog.zh.md: 2c2a9d84f051e79437493490af63cea70358c300 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b35358c651..453065bec7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -136,7 +136,7 @@ Source: [`packages/context/agent-instructions/src/config.ts:18`](../packages/con ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `inboxes` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `sessionProjections` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -292,7 +292,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:94`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f6bb73d14e..2c2a9d84f0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -138,7 +138,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-loop` -需要:`agents` · `inboxes` · `sessions` · `llm` · `tools` · `systemPrompt` +需要:`agents` · `sessionProjections` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -294,7 +294,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:94`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 358a4e3b08..1d36b48b41 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 4fa25899f83f2140a8e28af123ed5ff202a437e4 -module-graph.zh.md: ba604a3ee7462584cd2d2324ee90936502ee35f9 +module-graph.md: c9b0d3dd7305bf0444c777f21468c3baa27d12d9 +module-graph.zh.md: b3eaf5a47cb5267fcbaa45017ea932948eaec68d diff --git a/docs/module-graph.md b/docs/module-graph.md index 4fa25899f8..c9b0d3dd73 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -759,6 +759,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -1580,7 +1581,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ba604a3ee7..b3eaf5a47c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -760,6 +760,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -1581,7 +1582,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 022720323d..ce3293a461 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: 6e919d02ac1c23cf4f49e92e5ce50a8983ac449d -core.zh.md: 853d9500cd7e51b02ec2f9595bd7f294f7b1d66f +core.md: b978cfdc102c93e0939dfc48c24947c45c96ee6f +core.zh.md: 0dfef5b75ef9273884afe6e038a83e7e5785d9bc diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 6e919d02ac..b978cfdc10 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -175,7 +175,7 @@ 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, then Inbox emits per-message claimed notifications. `InboxService` registers the standard `inbox` projection; its registry cell is the sole live state and the same fold serves cold consumers. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. +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, then Inbox emits per-message claimed notifications. `AgentRegistry` contributes the standard `inbox` projection whenever the projection registry is composed; its cell is the sole live state and the same fold serves cold consumers. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: @@ -720,24 +720,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) - - - -### `ctx.inboxes` — `InboxService` - -Root Inbox service: creates live inboxes and owns their durable projection. - -```ts cordis-catalog -/** - * Restore one live Inbox for an agent and publish its committed mutations. - * @param agent - agent that owns the durable session and live Inbox events. - * @returns the restored Inbox. - */ -create(agent: Agent): Inbox -``` - -Source: [`packages/core/agent/src/inbox.ts:25`](../../packages/core/agent/src/inbox.ts) +Source: [`packages/core/agent/src/index.ts:259`](../../packages/core/agent/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 853d9500cd..0dfef5b75e 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -179,7 +179,7 @@ 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 通知,随后由 Inbox 逐条发出 claimed 通知。`InboxService` 注册标准 `inbox` 投影;其注册表 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +每个待处理入队项就是其 `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 通知,随后由 Inbox 逐条发出 claimed 通知。`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 投影;其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: @@ -728,24 +728,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) - - - -### `ctx.inboxes` — `InboxService` - -Root Inbox service: creates live inboxes and owns their durable projection. - -```ts cordis-catalog -/** - * Restore one live Inbox for an agent and publish its committed mutations. - * @param agent - agent that owns the durable session and live Inbox events. - * @returns the restored Inbox. - */ -create(agent: Agent): Inbox -``` - -Source: [`packages/core/agent/src/inbox.ts:25`](../../packages/core/agent/src/inbox.ts) +Source: [`packages/core/agent/src/index.ts:259`](../../packages/core/agent/src/index.ts) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 64413599d6..cbab8b3f39 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -11,7 +11,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -56,7 +55,6 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) await harness.plugin(SessionProjectionRegistry) - await harness.plugin(InboxService) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) @@ -75,7 +73,6 @@ async function workspaceCodeModeHarness(): Promise { await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) await harness.plugin(SessionProjectionRegistry) - await harness.plugin(InboxService) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts index 9446fcb7b2..54e71feefd 100644 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts +++ b/examples/headless-agent/tests/fixtures/e2b/e2b/bin.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { boot } from '@deepseek-ai/dsh-app-boot' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-fs-e2b' import type {} from '@deepseek-ai/dsh-bash-local' @@ -30,7 +30,7 @@ const owner: Agent = { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } -Object.assign(owner, { inbox: ctx.inboxes.create(owner) }) +Object.assign(owner, { inbox: new Inbox(ctx, owner.session, agentEvents(ctx, owner)) }) const unregisterOwner = ctx.agents.register(owner) let terminalId: Awaited>['sessionId'] | undefined try { diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 6e1656e3f3..e9567d9206 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -58,9 +58,6 @@ - id: agent name: '@deepseek-ai/dsh-agent' - - id: agent-inbox - name: '@deepseek-ai/dsh-agent/inbox' - # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6e69d8c5d3..3cfffe7d20 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' import { createAssistantMessage } from '@deepseek-ai/dsh-llm' @@ -57,7 +56,6 @@ async function bench(script: Script): Promise<{ const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' }) ctx.agents.setFactory({ @@ -86,7 +84,7 @@ async function bench(script: Script): Promise<{ inject: () => {}, whenIdle: () => idle, } satisfies Partial) - Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) await options.setup?.(agentCtx) script.before?.(session) ctx.agents.register(agent) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 178667dae1..b6cab01c38 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -7,8 +7,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' +import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -52,7 +51,7 @@ const testToolSignal = new AbortController().signal const isolatedInboxCtx = new Context() await isolatedInboxCtx.plugin(SessionStore) await isolatedInboxCtx.plugin(SessionProjectionRegistry) -await isolatedInboxCtx.plugin(InboxService) +await isolatedInboxCtx.plugin(AgentRegistry) let nextStubSession = 1 async function tempRepo(): Promise { @@ -209,7 +208,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: agentCtx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(agentCtx, agent.session, agentEvents(agentCtx, agent)) }) return agent } @@ -2523,7 +2522,6 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 581bf0ca8c..1c06c7fdee 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -51,7 +51,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 6f02f08e74..9a2066eb16 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -107,7 +107,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 76f7884b72..a5e615b30e 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1950195663..404ecdd7fe 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -11,12 +11,11 @@ import type { AgentOptions, AgentStatus, CancelOptions, - Inbox, InboxTarget, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, @@ -85,7 +84,7 @@ export class ReactLoopAgent implements Agent { public readonly session: Session, ) { this.dispatch = agentEvents(loopCtx, this) - this.inbox = loopCtx.inboxes.create(this) + this.inbox = new Inbox(loopCtx, session, this.dispatch) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 79bb469f25..6aad9745b6 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -294,7 +294,7 @@ function validateConfiguredAgents(agents: Config['agents']): void { /** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { - static inject = ['agents', 'inboxes', 'sessions', 'llm', 'tools', 'systemPrompt'] + static inject = ['agents', 'sessionProjections', 'sessions', 'llm', 'tools', 'systemPrompt'] /** Runtime schema for declarative agents. */ static Config = z.object({ diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index a5ca4cd36f..b69f5a6739 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' @@ -24,7 +23,6 @@ async function harness(adapter: LlmAdapter): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agentsFiber = await ctx.plugin(AgentRegistry) @@ -126,7 +124,6 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -387,7 +384,6 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index ea933ff374..4e6aa9b4df 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' @@ -16,7 +15,6 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index de44998536..58060a5cf9 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' /** @@ -28,7 +27,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -674,7 +672,6 @@ describe('Agent.cancel()', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 5b043f6277..96c1d03022 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -32,7 +31,6 @@ async function makeCoreContext(): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -332,7 +330,6 @@ describe('config-driven session id', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -359,7 +356,6 @@ describe('config-driven session id', () => { await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) await ctx1.plugin(SessionProjectionRegistry) - await ctx1.plugin(InboxService) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -380,7 +376,6 @@ describe('config-driven session id', () => { await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -406,7 +401,6 @@ describe('config-driven session id', () => { await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) await ctx1.plugin(SessionProjectionRegistry) - await ctx1.plugin(InboxService) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -424,7 +418,6 @@ describe('config-driven session id', () => { await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -451,7 +444,6 @@ describe('config-driven session id', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index a2b93a5fc0..cd30160999 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -33,7 +32,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -530,7 +528,6 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -683,7 +680,6 @@ describe('turn and step boundary recovery', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1117,7 +1113,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1169,7 +1164,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1221,7 +1215,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1269,7 +1262,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1319,7 +1311,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 85cc720fed..6fc28f6400 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -21,7 +20,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 60a3be7d01..5116c778ce 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -34,7 +33,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 7515e88c91..5c23522f16 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -20,7 +19,6 @@ async function harness(adapter: MockAdapter, persona = '') { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1454,7 +1452,6 @@ describe('agent loop', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1480,7 +1477,6 @@ describe('agent loop', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 90e733c19a..87755f646b 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * Property-based tests for the agent loop's inbox/turn scheduling (the @@ -42,7 +41,6 @@ async function harness() { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index a14c5f4bae..64394855f0 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -6,7 +6,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -44,7 +43,6 @@ async function loopHarness(): Promise { await created.plugin(LlmRuntime) await created.plugin(SessionStore) await created.plugin(SessionProjectionRegistry) - await created.plugin(InboxService) await created.plugin(SystemPrompt, { persona: SYSTEM }) await created.plugin(ToolRuntime) await created.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 776280765b..c947530b3c 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -16,7 +15,6 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 1b178a127f..dacca2f424 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * Loop-level reconstructability: every request the loop sends is a pure function of the @@ -31,7 +30,6 @@ async function harnessRoutes( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -260,7 +258,6 @@ describe('request stability across the loop', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -380,7 +377,6 @@ describe('request stability across the loop', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index d1278f77cf..4a6dfb7b01 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -31,7 +30,6 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -249,7 +247,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -279,7 +276,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -531,7 +527,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -596,7 +591,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -630,7 +624,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -668,7 +661,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) await ctx2.plugin(SessionProjectionRegistry) - await ctx2.plugin(InboxService) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -703,7 +695,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -894,7 +885,6 @@ describe('configured-start failure edges', () => { await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) await configured.plugin(SessionProjectionRegistry) - await configured.plugin(InboxService) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) @@ -941,7 +931,6 @@ describe('configured-start failure edges', () => { await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) await configured.plugin(SessionProjectionRegistry) - await configured.plugin(InboxService) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 9e7e441665..25fa383261 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' @@ -20,7 +19,6 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts index 5f0f98d5a9..d88182a674 100644 --- a/packages/core/agent-loop/tests/settings.spec.ts +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** The `agent-loop` settings section layered over the composition entry. */ @@ -37,7 +36,6 @@ async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; loopFiber: await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 276838654e..8aaa99fd0f 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** * Exercises scheduler ordering and cancellation with deterministic gated tools. @@ -23,7 +22,6 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -283,7 +281,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -353,7 +350,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -713,7 +709,6 @@ describe('code-mode native-tool denial through the agent loop', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime, { mode: 'code' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index da9d59d6e0..d0e4bd29d6 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,4 +1,3 @@ -import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** @@ -26,7 +25,6 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 07b8e54cc2..08c110f474 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 6af7e57bdb0ae710a6d1e6e58ca2ac187a45eeb8 -README.zh.md: 1533f918e037c7dc7d131c7046d25fc259d33580 +README.md: aeddc6caf24289333a3e5b0f9b29124b1e591316 +README.zh.md: f815283ba638f0374ae30d8a8c3fe4ce6a66968b diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 6af7e57bdb..aeddc6caf2 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Most interception points are cooperative waterfalls. `agent/pre-step` receives a `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. -`InboxService` owns the standard `inbox` session projection. The projection registry folds durable `agent/inbox/spliced` events once and remains the sole owner of the live `{ 'next-turn', 'next-step' }` state; Inbox is a command facade that reads the registry snapshot rather than replaying or copying the fold. Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. Inbox emits them as it commits the corresponding mutation, without adding another lifecycle envelope. +`AgentRegistry` contributes the standard `inbox` session projection whenever the projection registry is composed. The registry folds durable `agent/inbox/spliced` events once and remains the sole owner of the live `{ 'next-turn', 'next-step' }` state; Inbox is a command facade that reads that unit rather than replaying or copying the fold. Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. Inbox emits them as it commits the corresponding mutation, without adding another lifecycle envelope. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 1533f918e0..f815283ba6 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -54,7 +54,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 -`InboxService` 拥有标准 `inbox` 会话投影。投影注册表只折叠一次持久 `agent/inbox/spliced` 事件,并继续作为 live `{ 'next-turn', 'next-step' }` 状态的唯一所有者;Inbox 是读取注册表快照的命令 facade,不会重新回放或复制折叠结果。Inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。Inbox 在提交对应变更时自行发出这些通知,不引入另一层生命周期封套。 +`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 会话投影。注册表只折叠一次持久 `agent/inbox/spliced` 事件,并继续作为 live `{ 'next-turn', 'next-step' }` 状态的唯一所有者;Inbox 是读取该单元的命令 facade,不会重新回放或复制折叠结果。Inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。Inbox 在提交对应变更时自行发出这些通知,不引入另一层生命周期封套。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 1af7dfbe7d..e056207c36 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -18,14 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./inbox": { - "types": "./lib/types/inbox.d.ts", - "default": "./lib/types/inbox.js" - }, - "./inbox-projection": { - "types": "./lib/types/inbox-projection.d.ts", - "default": "./lib/types/inbox-projection.js" - }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" diff --git a/packages/core/agent/src/inbox-projection.ts b/packages/core/agent/src/inbox-projection.ts index cfb5f7579c..d45f79cfbb 100644 --- a/packages/core/agent/src/inbox-projection.ts +++ b/packages/core/agent/src/inbox-projection.ts @@ -1,6 +1,7 @@ /** Inbox projection schema and its inferred wire value. */ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { z } from 'zod' /** Wire validation for pending agent input reconstructed from durable inbox splices. */ @@ -12,6 +13,30 @@ export const inboxProjectionSchema = z.object({ /** Complete pending Inbox value reconstructed from durable splices. */ export type InboxState = z.infer +/** Standard fold that reconstructs pending agent input from durable splices. */ +export const inboxProjectionDefinition = { + key: 'inbox', + stateSchema: inboxProjectionSchema, + init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }), + apply(state: InboxState, event) { + if (event.type !== 'agent/inbox/spliced') return state + const splice = event.data + const next = state[splice.target].toSpliced( + splice.start, + splice.removedCount ?? 0, + ...splice.inserted, + ) + return splice.target === 'next-turn' + ? { 'next-turn': next, 'next-step': state['next-step'] } + : { 'next-turn': state['next-turn'], 'next-step': next } + }, + wire: { + viewSchema: inboxProjectionSchema, + view: (state: InboxState) => state, + }, + stateVersion: 1, +} satisfies ProjectionDefinition<'inbox', InboxState> + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { /** Pending agent input reconstructed from durable inbox splices. */ diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index c404194ed7..2ffc4bed39 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -1,74 +1,25 @@ /** - * Incremental projection of durable agent inbox events. + * Command facade over the durable agent Inbox projection. * * @module @deepseek-ai/dsh-agent/inbox */ -import { Context, Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { MessageId } from '@deepseek-ai/dsh-llm' // Type-only: resolves ctx.sessionProjections for the required Inbox projection. import type {} from '@deepseek-ai/dsh-session-projection' -import type { SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' -import { agentEvents } from './dispatch.ts' +import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' import type { AgentEventDispatch } from './dispatch.ts' -import { inboxProjectionSchema } from './inbox-projection.ts' import type { InboxState } from './inbox-projection.ts' -import type { Agent } from './runtime-types.ts' import type { InboxTarget } from './types.ts' -declare module '@deepseek-ai/cordis' { - interface Context { - inboxes: InboxService - } -} - -/** Root Inbox service: creates live inboxes and owns their durable projection. */ -export class InboxService extends Service { - static inject = ['sessionProjections'] - - constructor(ctx: Context) { - super(ctx, 'inboxes') - ctx.sessionProjections.register<'inbox', InboxState>({ - key: 'inbox', - stateSchema: inboxProjectionSchema, - init: () => ({ 'next-turn': [], 'next-step': [] }), - apply(state, event) { - if (event.type !== 'agent/inbox/spliced') return state - const splice = event.data - const next = state[splice.target].toSpliced( - splice.start, - splice.removedCount ?? 0, - ...splice.inserted, - ) - return splice.target === 'next-turn' - ? { 'next-turn': next, 'next-step': state['next-step'] } - : { 'next-turn': state['next-turn'], 'next-step': next } - }, - wire: { - viewSchema: inboxProjectionSchema, - view: state => state, - }, - stateVersion: 1, - }) - } - - /** - * Restore one live Inbox for an agent and publish its committed mutations. - * @param agent - agent that owns the durable session and live Inbox events. - * @returns the restored Inbox. - */ - create(agent: Agent): Inbox { - return new Inbox(this.ctx, agent) - } -} - /** Agent-owned command facade over the standard durable Inbox projection. */ export class Inbox { - private readonly dispatch: AgentEventDispatch - - constructor(private readonly ctx: Context, private readonly agent: Agent) { - this.dispatch = agentEvents(ctx, agent) - } + constructor( + private readonly ctx: Context, + private readonly session: Session, + private readonly dispatch: AgentEventDispatch, + ) {} /** Prompts awaiting individual turns. */ get nextTurn(): readonly UserMessage[] { @@ -175,11 +126,11 @@ export class Inbox { return undefined } - /** Read the current durable projection value. */ + /** Read the current durable projection state. */ private current(): InboxState { - // InboxService registers this required projection before creating an Inbox. + // AgentLoop requires sessionProjections; AgentRegistry contributes this unit to it. // oxlint-disable-next-line typescript/no-non-null-assertion - return this.ctx.sessionProjections.snapshot(this.agent.session).values.inbox! + return this.ctx.sessionProjections.stateOf(this.session, 'inbox')! } /** Commit one normalized mutation and publish its live events. */ @@ -220,7 +171,7 @@ export class Inbox { ...(outcome === undefined ? {} : { outcome }), } const removed = inbox.slice(actualStart, actualStart + actualDeleteCount) - const event = this.agent.session.append('agent/inbox/spliced', splice) + const event = this.session.append('agent/inbox/spliced', splice) if (discardRemoved) { for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message }) } @@ -230,5 +181,3 @@ export class Inbox { return removed } } - -export default InboxService diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 81052096dc..22111e437b 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -12,7 +12,10 @@ import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +// Type-only: resolves ctx.sessionProjections for the optional Inbox projection contribution. +import type {} from '@deepseek-ai/dsh-session-projection' import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' +import { inboxProjectionDefinition } from './inbox-projection.ts' import type { Agent, AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' @@ -265,6 +268,9 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(inboxProjectionDefinition) + }) ctx.inject(['typert'], (typeCtx) => { typeCtx.typert.lookups.register('agent', { parameter: 'agent', diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index af25e7e870..293ed93e67 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -4,10 +4,9 @@ import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, - InboxService, + Inbox, } from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import type {} from '@deepseek-ai/dsh-agent/inbox-projection' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { @@ -48,10 +47,10 @@ async function inboxAgent(rawId: string): Promise<{ ctx: Context; session: Sessi const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) + await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(SessionId(rawId)) const agent = stubAgent(rawId, { ctx, session }) - Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) return { ctx, session, agent } } @@ -65,7 +64,7 @@ describe('Inbox', () => { parentAgent.inbox.append('next-turn', inherited) const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child')) const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) - Object.assign(childAgent, { inbox: ctx.inboxes.create(childAgent) }) + Object.assign(childAgent, { inbox: new Inbox(ctx, childAgent.session, agentEvents(ctx, childAgent)) }) expect(child.header.seedLength).toBe(parent.events.length) expect(childAgent.inbox.nextTurn).toEqual([inherited]) @@ -169,15 +168,15 @@ describe('Inbox', () => { expect(session.events).toHaveLength(beforeClear + 2) }) - it('registers the durable Inbox projection from the Inbox service', async () => { + it('registers the durable Inbox projection from the Agent registry', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const inboxFiber = ctx.plugin(InboxService) - await inboxFiber + const agentFiber = ctx.plugin(AgentRegistry) + await agentFiber const session = ctx.sessions.create(SessionId('inbox-projection')) const agent = stubAgent('inbox-projection', { ctx, session }) - Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) const pending = createUserMessage({ content: [{ type: 'text', text: 'pending' }], source: { kind: 'user' }, @@ -189,7 +188,7 @@ describe('Inbox', () => { 'next-turn': [pending], 'next-step': [], }) - await inboxFiber.dispose() + await agentFiber.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 5b17a19dbd..305aa1cb66 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -3,7 +3,7 @@ import { join, posix } from 'node:path' import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { Inbox } from '@deepseek-ai/dsh-agent' +import { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { @@ -96,7 +96,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(owner, { inbox: new Inbox(owner.ctx, owner) }) + Object.assign(owner, { inbox: new Inbox(owner.ctx, owner.session, agentEvents(owner.ctx, owner)) }) const backend = new BashTerminalBackend(ctx, { backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 4cf542540b..f67efff4bb 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -20,7 +20,6 @@ import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import SkillRegistry, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' import * as goalSession from '@deepseek-ai/dsh-goal-round-driver' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' @@ -238,7 +237,6 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SkillFileSystem, Object.assign({}, config.skills?.filesystem, { dshHome })) } ctx.plugin(AgentRegistry) - ctx.plugin(InboxService) ctx.plugin(llmRetry) if (config.goals !== undefined && config.goals !== false) { ctx.plugin(GoalService, config.goals.domain ?? {}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 739fc69a2a..86e722f825 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -807,19 +807,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'inboxes', - summary: 'Root Inbox service: creates live inboxes and owns their durable projection.', - description: 'Root Inbox service: creates live inboxes and owns their durable projection.', - methods: [ - { - signature: 'create(agent: Agent): Inbox', - description: 'Restore one live Inbox for an agent and publish its committed mutations.', - parameters: [{ name: 'agent', description: 'agent that owns the durable session and live Inbox events.' }], - returns: 'the restored Inbox.', - }, - ], - }, { key: 'invariants', summary: 'Package-owned invariant registry with global and regex-based selection.', @@ -2762,6 +2749,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentCancelCause', declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n} | {\n readonly kind: \'hook\';\n readonly reason: string;\n} | {\n readonly kind: \'disposed\';\n};', }, + { + name: 'AgentEventDispatch', + declaration: 'export interface AgentEventDispatch {\n emit(name: K, payload: PayloadRest): void;\n serial(name: K, payload: PayloadRest): Promise>>;\n waterfall(name: K, payload: PayloadRest, ...rest: Tail): Return;\n}', + }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', @@ -2790,6 +2781,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, + { + name: 'AgentSubjectEvent', + declaration: 'export type AgentSubjectEvent = {\n [K in keyof Events]: Events[K] extends (this: Scoped, ...args: infer P) => unknown ? P extends [\n infer Payload,\n ...unknown[]\n ] ? Payload extends {\n agent: Agent;\n } ? K : never : never : never;\n}[keyof Events];', + }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -3300,7 +3295,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Inbox', - declaration: 'export class Inbox {\n constructor(private readonly ctx: Context, private readonly agent: Agent);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', + declaration: 'export class Inbox {\n constructor(private readonly ctx: Context, private readonly session: Session, private readonly dispatch: AgentEventDispatch);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', }, { name: 'InboxTarget', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 4f58bfbdc7..90c48ffa96 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' @@ -59,7 +59,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return { agent, session } } diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 4f01449b15..831481a702 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -45,7 +45,7 @@ function agent(ctx: Context): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 69d931673a..eb1aece0c3 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import { FsVersion } from '@deepseek-ai/dsh-fs' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' @@ -44,7 +44,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index d03054b717..ec139886ee 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -36,7 +36,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return { agent, session } } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 8d664dbe9c..6888c4eacc 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' @@ -22,7 +21,7 @@ interface StubAgent { const isolatedInboxCtx = new Context() await isolatedInboxCtx.plugin(SessionStore) await isolatedInboxCtx.plugin(SessionProjectionRegistry) -await isolatedInboxCtx.plugin(InboxService) +await isolatedInboxCtx.plugin(AgentRegistry) const sessionStubs = new WeakMap() /** Number the next balanced test-fixture turn. */ @@ -59,7 +58,7 @@ function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - Object.assign(agent, { inbox: agentCtx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(agentCtx, agent.session, agentEvents(agentCtx, agent)) }) const stub = { agent, session, @@ -84,7 +83,6 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService, config) const stub = stubAgent(`goal-test-${Math.random()}`) @@ -195,7 +193,6 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx) @@ -451,7 +448,6 @@ describe('GoalService mutations', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 7e25c3f5b7..d538fcc4f8 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' @@ -26,7 +25,7 @@ interface StubAgent { const isolatedInboxCtx = new Context() await isolatedInboxCtx.plugin(SessionStore) await isolatedInboxCtx.plugin(SessionProjectionRegistry) -await isolatedInboxCtx.plugin(InboxService) +await isolatedInboxCtx.plugin(AgentRegistry) /** Build one registry-compatible live agent whose injections enter the durable inbox. */ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): StubAgent { @@ -55,7 +54,7 @@ function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): St runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - Object.assign(agent, { inbox: agentCtx.inboxes.create(agent) }) + Object.assign(agent, { inbox: new Inbox(agentCtx, agent.session, agentEvents(agentCtx, agent)) }) return { agent, session, setStatus(value) { status = value } } } @@ -87,7 +86,6 @@ async function harness(config: toolGoal.Config = {}) { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index 0f290621cd..e94dbb420d 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -10,9 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' -import type {} from '@deepseek-ai/dsh-agent/inbox-projection' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -71,7 +69,6 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: await ctx.plugin(AgentRegistry) if (withRegistry) { await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) } const session = ctx.sessions.create() const agent = { @@ -89,7 +86,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } satisfies Agent - if (withRegistry) Object.assign(agent, { inbox: ctx.inboxes.create(agent) }) + if (withRegistry) Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) ctx.agents.register(agent) return { ctx, session } } diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 6019da820a..9e63b7a3db 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -3,7 +3,7 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -55,7 +55,7 @@ function stubAgent(session: Session): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index b333b87222..ce7f2347dc 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' @@ -45,7 +45,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) return agent } diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 7d869af764..b4bef4c239 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -7,7 +7,6 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmRuntime, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -66,7 +65,6 @@ async function loadYaml(lines: readonly string[]): Promise { ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-agent', AgentRegistry], - ['@deepseek-ai/dsh-agent/inbox', InboxService], ['@deepseek-ai/dsh-llm-retry', retry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ]) @@ -97,7 +95,6 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", - "- name: '@deepseek-ai/dsh-agent/inbox'", "- name: '@deepseek-ai/dsh-llm-retry'", "- name: '@deepseek-ai/dsh-agent-loop'", ]) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 959b36f2ac..68d24db709 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import LlmRuntime, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -26,7 +25,6 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index f1e26a79d7..24514fe0ab 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -1,7 +1,6 @@ import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' @@ -30,7 +29,6 @@ async function harness(roster: Partial = {}): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 23a56112e7..fe7f2adc53 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' @@ -48,7 +47,6 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -419,7 +417,6 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(LlmRuntime) await scoped.plugin(SessionStore) await scoped.plugin(SessionProjectionRegistry) - await scoped.plugin(InboxService) await scoped.plugin(SystemPrompt, { persona: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) @@ -586,7 +583,6 @@ describe('replacing a composition', () => { await scoped.plugin(LlmRuntime) await scoped.plugin(SessionStore) await scoped.plugin(SessionProjectionRegistry) - await scoped.plugin(InboxService) await scoped.plugin(SystemPrompt, { persona: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 4b09b1b663..b1bf43b247 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -16,7 +16,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' @@ -46,7 +45,6 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index 106725c8ad..0c5c6bce54 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent' import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -96,7 +96,7 @@ async function harness(): Promise { steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) const disposeAgent = ctx.agents.register(agent) ctx.on('session/event', (_session, event) => { if (event.type === 'schedule/change' && event.data.operation === 'dispatch') order.push('dispatch') diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index 7b387c7ef5..7343a9529c 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' @@ -39,7 +39,7 @@ function stubAgent(ctx: Context, id: string): Agent { steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 5fade7b924..e225378ddb 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' @@ -55,7 +55,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 5277c04a04..965b3dbbc5 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -50,7 +50,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 8d24a7cb8a..57abf1d2b1 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -9,7 +9,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash' @@ -62,7 +62,7 @@ function agent(ctx: Context, cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index db4e3d20fa..4cc139d9d3 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -50,7 +50,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ff9e14bf5f..6d50d54b4d 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -54,7 +54,7 @@ function agentForCwd(cwd: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } @@ -74,7 +74,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index a4d4b79617..66331fb870 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -59,7 +59,7 @@ function agent(ctx: Context, cwd?: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } @@ -530,7 +530,7 @@ describe('terminal-bash plugin shape', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(owner, { inbox: new Inbox(owner.ctx, owner) }) + Object.assign(owner, { inbox: new Inbox(owner.ctx, owner.session, agentEvents(owner.ctx, owner)) }) ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const created = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -580,7 +580,7 @@ describe('terminal-bash plugin shape', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(owner, { inbox: new Inbox(owner.ctx, owner) }) + Object.assign(owner, { inbox: new Inbox(owner.ctx, owner.session, agentEvents(owner.ctx, owner)) }) ctx.agents.register(owner) const gate = Promise.withResolvers() await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise)) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 709daf0b3a..bafeffd8b0 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal' @@ -46,7 +46,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) return agent } diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 7b73ebfbd7..7fb957be4e 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { @@ -37,7 +37,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) return agent } diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index 1262156be2..35df0d2d46 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -49,7 +49,7 @@ function agent(ctx: Context): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 4c50898355..e2692f0f74 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' @@ -26,7 +26,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { runMaintenance: job => job(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(agent, { inbox: new Inbox(agent.ctx, agent) }) + Object.assign(agent, { inbox: new Inbox(agent.ctx, agent.session, agentEvents(agent.ctx, agent)) }) ctx.agents.register(agent) return agent } diff --git a/packages/test-support/agent-loop-testkit/src/index.ts b/packages/test-support/agent-loop-testkit/src/index.ts index 8b327edc4b..7867d00c1e 100644 --- a/packages/test-support/agent-loop-testkit/src/index.ts +++ b/packages/test-support/agent-loop-testkit/src/index.ts @@ -7,7 +7,6 @@ import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -43,7 +42,6 @@ export async function mountAgentLoopTestDependencies( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(InboxService) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) await ctx.plugin(ToolRuntime, options.tools ?? {}) await ctx.plugin(AgentRegistry) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 24199f8f10..63ba14ed76 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -11,7 +11,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -38,7 +38,7 @@ function agent(ctx: Context): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } - Object.assign(value, { inbox: new Inbox(value.ctx, value) }) + Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) }) ctx.agents.register(value) return value } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 274d0307e4..e932ff9fa6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -54,7 +54,6 @@ export const SERVICE_PAGE: Record = { agentDefaultModel: 'core.md', agentPresets: 'core.md', agents: 'core.md', - inboxes: 'core.md', apiProxy: 'typert.md', approval: 'approval.md', attachments: 'attachment.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 24550a0a3c..420bad476c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -139,14 +139,6 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, - { - key: 'inboxes', - pkg: 'agent', - title: 'Durable pending-input facade', - mode: 'core', - consumers: ['agent-loop'], - note: 'Registers the standard Inbox projection and creates command facades over its sole live state.', - }, { key: 'invariants', pkg: 'invariants', diff --git a/tsconfig.base.json b/tsconfig.base.json index 242e848c5e..91e623e2d1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -98,8 +98,6 @@ "@deepseek-ai/dsh-user-questions/types": ["./packages/interaction/user-questions/src/types.ts"], "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"], "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], - "@deepseek-ai/dsh-agent/inbox": ["./packages/core/agent/src/inbox.ts"], - "@deepseek-ai/dsh-agent/inbox-projection": ["./packages/core/agent/src/inbox-projection.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], From 09fab78863215c24888e09da8e3b280bc0efe97b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 11:25:22 +0800 Subject: [PATCH 03/88] test: trim unrelated inbox changes --- packages/core/agent-loop/tests/loop.spec.ts | 16 ---------------- .../host/apiproxy/tests/api-proxy-jobs.spec.ts | 16 ++-------------- .../apiproxy/tests/api-proxy-projections.spec.ts | 14 ++------------ 3 files changed, 4 insertions(+), 42 deletions(-) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5c23522f16..401e45366f 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -134,22 +134,6 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) }) - it('rejects overlapping maintenance work', async () => { - const ctx = await harness(new MockAdapter([])) - const agent = ctx.agentLoop.create(SessionId('overlapping-maintenance'), { - provider: 'mock', - model: 'mock', - }) - const finish = Promise.withResolvers() - const maintenance = agent.runMaintenance(() => finish.promise) - - expect(() => agent.runMaintenance(async () => undefined)) - .toThrow('agent "overlapping-maintenance" already has active work') - - finish.resolve(undefined) - await maintenance - }) - it('suppresses the replay when a latched maintenance wake is removed', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) diff --git a/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts b/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts index 7e61b1eff7..3e73ee2616 100644 --- a/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts @@ -55,20 +55,8 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: } const session = ctx.sessions.create() const agent = { - id: session.id, - options: {}, - session, - inbox: { nextTurn: [], nextStep: [], hasPending: false } as never, - status: 'idle', - ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, - cancel() {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } satisfies Agent + id: session.id, session, inbox: { nextTurn: [], nextStep: [], hasPending: false }, status: 'idle', ctx, + } as Agent ctx.agents.register(agent) return { ctx, session, agent } } diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index e94dbb420d..4c1092658d 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -67,25 +67,15 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: await ctx.plugin(SessionStore) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) - if (withRegistry) { - await ctx.plugin(SessionProjectionRegistry) - } + if (withRegistry) await ctx.plugin(SessionProjectionRegistry) const session = ctx.sessions.create() const agent = { id: session.id, - options: {}, session, inbox: { nextTurn: [], nextStep: [], hasPending: false } as never, status: 'idle', ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, - cancel() {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } satisfies Agent + } as Agent if (withRegistry) Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) ctx.agents.register(agent) return { ctx, session } From 0aee112c6fd400e9fd29cb4af7f5d52a4483e723 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 13:24:41 +0800 Subject: [PATCH 04/88] test(session-projection): scope projection assertions --- packages/goal/goal/tests/projection.spec.ts | 4 ++-- packages/plan/plan-mode/tests/projection.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 0286e93103..3370ab3b41 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -81,7 +81,7 @@ describe('goal projection unit', () => { it('serves null before the first create', async () => { const bench = await harness(true) seedMessage(bench.session) - expect(bench.tailValues()).toEqual({ goal: null }) + expect(bench.tailValues().goal).toBeNull() expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1) }) @@ -199,7 +199,7 @@ describe('goal projection unit', () => { const bench = await harness(false) seedMessage(bench.session) const fiber = await bench.ctx.plugin(GoalService) - expect(bench.tailValues()).toEqual({ goal: null }) + expect(bench.tailValues().goal).toBeNull() await fiber.dispose() expect('goal' in (bench.tailValues() ?? {})).toBe(false) }) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index e0ad20148c..c16078e54a 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -74,7 +74,7 @@ function commitPlanMode(session: Session, active: boolean, turn: number): void { describe('plan projection unit', () => { it('serves inactive/not-pending for the empty log', async () => { const bench = await harness(true) - expect(bench.values()).toEqual({ plan: { active: false, pending: false } }) + expect(bench.values().plan).toEqual({ active: false, pending: false }) }) it('a logged /plan selection reads pending until plan/mode records it', async () => { From 015d7e0752801b9d22c0288af57268bebb542b2c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 24 Aug 2026 13:14:21 +0800 Subject: [PATCH 05/88] docs(session-projection): keep registry contract unchanged --- docs/subsystems/session-projection.i18n.yaml | 4 +-- docs/subsystems/session-projection.md | 2 +- docs/subsystems/session-projection.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session/session-projection/src/index.ts | 26 +++++++++++-------- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index f9b9d74147..a7a8c85712 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-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 docs/subsystems/session-projection.md -session-projection.md: e3921e892a1acb47dd6b0c84abc564b379aedd19 -session-projection.zh.md: 7e866a3b4197deacf52c27ea30937e739e1a61ec +session-projection.md: 033fa070c646b91a3195ae161507ac187d9a8881 +session-projection.zh.md: 5764f02befc66ebf3541324b7c4cf23da8f49e04 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index e3921e892a..033fa070c6 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the full in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A domain that requires this capability declares a Cordis service dependency; an optional contributor may register under `ctx.inject(['sessionProjections'], …)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 7e866a3b41..5764f02bef 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the full in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A domain that requires this capability declares a Cordis service dependency; an optional contributor may register under `ctx.inject(['sessionProjections'], …)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1d1a356cad..9d0fc17eb8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1396,7 +1396,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the full in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A domain that requires this capability declares a Cordis service dependency; an optional contributor may register under `ctx.inject([\'sessionProjections\'], …)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 96dbfc8c7a..89d356df02 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -5,10 +5,14 @@ * forward eagerly over committed session events. Domain host plugins * contribute pure folds and optional client views; the framework owns the * subscription, the per-session watermark cache, and change notification; - * carriers consume the snapshot read face and the change feed. Source events - * may carry whole values or domain operations; every `view` returns a complete - * current value. Design authority: the session-projection RFC - * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). + * carriers consume the snapshot read face and the change feed. Neither side + * knows the other + * (capability-seam three-way split). Design authority: the session-projection + * RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). + * + * Whole-value event rule (load-bearing): a state-carrying log event MUST + * carry the complete post-change state, never a bare delta — it keeps every + * unit's transition trivially cheap and every served value self-describing. * * @module @deepseek-ai/dsh-session-projection */ @@ -163,15 +167,15 @@ interface Registration { * reference in a client-visible unit notifies the change feed with the * schema-validated view. * Cells build lazily — a unit registered after events flowed, or a session - * older than the registry, folds `init` over the full in-memory log on first + * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. A domain that requires this - * capability declares a Cordis service dependency; an optional contributor - * may register under `ctx.inject(['sessionProjections'], …)`. Registrants - * sharing a key share one unit and are counted: the same tool package mounted - * in N agent presets registers N times, and the key survives until the last - * one unloads. + * and clients read it as capability absence. Domain + * plugins register under `ctx.inject(['sessionProjections'], …)` so headless + * assemblies without the registry stay unaffected. Registrants sharing a key + * share one unit and are counted: the same tool package mounted in N agent + * presets registers N times, and the key survives until the last one + * unloads. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() From fb3f3ff162308f5dd11e826b748fe1e57d38d98f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 17:34:36 +0800 Subject: [PATCH 06/88] fix(agent): validate durable inbox reconstruction --- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 4 +- ...-07-31-claimed-pre-step-inbox-lifecycle.md | 4 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/inbox-projection.ts | 32 ++++++++++---- packages/core/agent/tests/agent.spec.ts | 44 +++++++++++++++++++ 11 files changed, 81 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 84deedb9f9..87ef2eb913 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: 8149c9df09ff69c017ba66a779d6fd3dfc314193 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: e855b88b26a55dd9f5c33c87a4b59d67496d00ed +2026-07-31-claimed-pre-step-inbox-lifecycle.md: aee385bec30c572bd23d7aecac3a3515ad8cd98b +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 79460b9abf25ef12925f459a8d13b78c7e63b604 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index 8149c9df09..aee385bec3 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -18,7 +18,7 @@ Before every proposed step, `Inbox.claim(target)` atomically removes the complet The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from Inbox itself. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. @@ -34,7 +34,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026 ## Verification -Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads. +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, and rejection of invalid persisted coordinates or cross-list identities. Generated event and type catalogs expose only the new waterfall and payloads. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index e855b88b26..79460b9abf 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -18,7 +18,7 @@ Status: implemented 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 Inbox 自行发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 @@ -34,7 +34,7 @@ Status: implemented ## 验证 -agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 +agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影,以及对非法持久坐标或跨列表重复标识的拒绝。生成的事件与类型目录只公开新的 waterfall 与载荷。 ## 后果 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a9e7e5c2e8..00592ec1fe 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: 03299a114987d9508b8d58ec24a3c4f919006648 -core.zh.md: bee87294670a7f7ea23169e00beb0ff324846025 +core.md: 2d80985bb16ea2858bffd53e4ab584e096c2dc7a +core.zh.md: 6c731e07decbc50cdd552d81615b511f42266aef diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 03299a1149..2d80985bb1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -177,7 +177,7 @@ 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, then Inbox emits per-message claimed notifications. `AgentRegistry` contributes the standard `inbox` projection whenever the projection registry is composed; its cell is the sole live state and the same fold serves cold consumers. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. +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, then Inbox emits per-message claimed notifications. `AgentRegistry` contributes the standard `inbox` projection whenever the projection registry is composed; its cell is the sole live state and the same fold serves cold consumers. That fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index bee8729467..6c731e07de 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -181,7 +181,7 @@ 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 通知,随后由 Inbox 逐条发出 claimed 通知。`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 投影;其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +每个待处理入队项就是其 `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 通知,随后由 Inbox 逐条发出 claimed 通知。`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 投影;其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 6cae6d2220..1ac6354659 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: d56a917976e07d99e947703294bfe1e195533f8c -README.zh.md: 3cc0cec48861161bdc61550d9f8b13c78109e94a +README.md: 90e6aa0f570f5bde4a120f2d8213fb308cb7792a +README.zh.md: 8922d78591211a6b48976f2c5632766d90eb3186 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index d56a917976..90e6aa0f57 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Most interception points are cooperative waterfalls. `agent/pre-step` receives a `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch is the complete identified, frozen batch for the proposed step. `startsRequestSeries: true` declares that this admitted batch begins a distinct model-message series; ordinary follow-ups leave it absent. A listener that wraps downstream entry preserves both that declaration and the batch unless it intentionally replaces either one; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. -`AgentRegistry` contributes the standard `inbox` session projection whenever the projection registry is composed. The registry folds durable `agent/inbox/spliced` events once and remains the sole owner of the live `{ 'next-turn', 'next-step' }` state; Inbox is a command facade that reads that unit rather than replaying or copying the fold. Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. Inbox emits them as it commits the corresponding mutation, without adding another lifecycle envelope. +`AgentRegistry` contributes the standard `inbox` session projection whenever the projection registry is composed. The registry folds durable `agent/inbox/spliced` events once and remains the sole owner of the live `{ 'next-turn', 'next-step' }` state; Inbox is a command facade that reads that unit rather than replaying or copying the fold. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists, reporting the offending event seq instead of accepting malformed durable history. Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. Inbox emits them as it commits the corresponding mutation, without adding another lifecycle envelope. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 3cc0cec488..8922d78591 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -56,7 +56,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。`startsRequestSeries: true` 声明该接纳批次会开启一个独立的模型消息序列;普通 follow-up 不设置它。包装下游 enter 的监听器会同时保留该声明和消息批次,除非有意替换其中一项;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 -`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 会话投影。注册表只折叠一次持久 `agent/inbox/spliced` 事件,并继续作为 live `{ 'next-turn', 'next-step' }` 状态的唯一所有者;Inbox 是读取该单元的命令 facade,不会重新回放或复制折叠结果。Inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。Inbox 在提交对应变更时自行发出这些通知,不引入另一层生命周期封套。 +`AgentRegistry` 会在投影注册表已组合时贡献标准 `inbox` 会话投影。注册表只折叠一次持久 `agent/inbox/spliced` 事件,并继续作为 live `{ 'next-turn', 'next-step' }` 状态的唯一所有者;Inbox 是读取该单元的命令 facade,不会重新回放或复制折叠结果。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq,而不会接受格式错误的持久历史。Inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。Inbox 在提交对应变更时自行发出这些通知,不引入另一层生命周期封套。 轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.zh.md) 记录的完整流水线。 diff --git a/packages/core/agent/src/inbox-projection.ts b/packages/core/agent/src/inbox-projection.ts index 79ffb7c863..5eba9ad87a 100644 --- a/packages/core/agent/src/inbox-projection.ts +++ b/packages/core/agent/src/inbox-projection.ts @@ -11,7 +11,7 @@ export const inboxProjectionSchema = z.object({ 'next-step': z.array(z.custom()).readonly(), }).readonly() -/** Standard fold that reconstructs pending agent input from durable splices. */ +/** Standard fold that reconstructs pending input and rejects invalid durable splice history. */ export const inboxProjectionDefinition = { key: 'inbox', stateSchema: inboxProjectionSchema, @@ -19,14 +19,28 @@ export const inboxProjectionDefinition = { apply(state: InboxState, event) { if (event.type !== 'agent/inbox/spliced') return state const splice = event.data - const next = state[splice.target].toSpliced( - splice.start, - splice.removedCount ?? 0, - ...splice.inserted, - ) - return splice.target === 'next-turn' - ? { 'next-turn': next, 'next-step': state['next-step'] } - : { 'next-turn': state['next-turn'], 'next-step': next } + try { + const inbox = state[splice.target] + const removedCount = splice.removedCount ?? 0 + if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length + || !Number.isSafeInteger(removedCount) || removedCount < 0 + || splice.start + removedCount > inbox.length) { + throw new Error('invalid inbox splice') + } + const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted) + const ids = new Set() + for (const message of splice.target === 'next-turn' + ? [...next, ...state['next-step']] + : [...state['next-turn'], ...next]) { + if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) + ids.add(message.id) + } + return splice.target === 'next-turn' + ? { 'next-turn': next, 'next-step': state['next-step'] } + : { 'next-turn': state['next-turn'], 'next-step': next } + } catch (error: unknown) { + throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error }) + } }, wire: { // The wire value is the fold state itself: every pending message already diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 41db67d521..f6a82a7a7d 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -54,7 +54,51 @@ async function inboxAgent(rawId: string): Promise<{ ctx: Context; session: Sessi return { ctx, session, agent } } +async function reconstructPersistedInbox( + rawId: string, + populate: (session: Session) => void, +): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId(rawId)) + populate(session) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + try { + ctx.sessionProjections.stateOf(session, 'inbox') + } catch (error: unknown) { + if (error instanceof Error) return error + throw error + } + throw new Error('persisted inbox reconstruction unexpectedly succeeded') +} + describe('Inbox', () => { + it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => { + const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => { + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, removedCount: 1, inserted: [], + }) + }) + expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0') + expect((outOfRange.cause as Error).message).toBe('invalid inbox splice') + + const pending = createUserMessage({ + content: [{ type: 'text', text: 'duplicate' }], + source: { kind: 'user' }, + }) + const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => { + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, inserted: [pending], + }) + session.append('agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [pending], + }) + }) + expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1') + expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`) + }) + it('projects inherited Inbox events in a forked session', async () => { const { ctx, session: parent, agent: parentAgent } = await inboxAgent('inbox-fork-parent') const inherited = createUserMessage({ From 5b9b7c59ddc582d09763b7d4ec39c7646f6009ce Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 17:35:55 +0800 Subject: [PATCH 07/88] fix(session-controller): derive queues from projections --- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 4 +- ...-07-31-claimed-pre-step-inbox-lifecycle.md | 4 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 4 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/control.ts | 41 +++++++++---------- .../tests/control-queue.host.spec.ts | 32 +++++++++++++++ packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- 11 files changed, 65 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 87ef2eb913..93176ac3f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: aee385bec30c572bd23d7aecac3a3515ad8cd98b -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 79460b9abf25ef12925f459a8d13b78c7e63b604 +2026-07-31-claimed-pre-step-inbox-lifecycle.md: d914b075607181a8b036ede003e2998f18c6be3b +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 1701b949500c310656f48b9872e1d714a984658a diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index aee385bec3..d914b07560 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -18,7 +18,7 @@ Before every proposed step, `Inbox.claim(target)` atomically removes the complet The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from Inbox itself. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. @@ -34,7 +34,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026 ## Verification -Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, and rejection of invalid persisted coordinates or cross-list identities. Generated event and type catalogs expose only the new waterfall and payloads. +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Generated event and type catalogs expose only the new waterfall and payloads. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index 79460b9abf..1701b94950 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -18,7 +18,7 @@ Status: implemented 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 Inbox 自行发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 @@ -34,7 +34,7 @@ Status: implemented ## 验证 -agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影,以及对非法持久坐标或跨列表重复标识的拒绝。生成的事件与类型目录只公开新的 waterfall 与载荷。 +agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。生成的事件与类型目录只公开新的 waterfall 与载荷。 ## 后果 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d417370290..67e2d20f79 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: 510f336c76dd80ed01bdd2bd4a364106f418831f -README.zh.md: 4503a8f9d0bcfc7f669f00cbc4db69e0d3efd3cd +README.md: cbd8f1236ef717c2d37bb03a18d22500966bd441 +README.zh.md: 79bb03e76fd3d61780dc313d0edfea12371cbbe3 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 510f336c76..cbd8f1236e 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame. ## Model Experience diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 4503a8f9d0..79bb03e76f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。 ## 模型体验 diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 58219116c7..31fb2ff756 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,10 +1,10 @@ /** Live Session queue, jobs, and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, InboxState } from '@deepseek-ai/dsh-agent' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { - JsonValue, Session, SessionEvent, SessionId, UserMessage, + JsonValue, Session, SessionId, } from '@deepseek-ai/dsh-session' import type { SessionControlBaseline, @@ -21,7 +21,6 @@ export class SessionControlController { /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ constructor(private readonly ctx: Context) { - ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ @@ -31,6 +30,14 @@ export class SessionControlController { value: value as JsonValue, seq, }) + if (key !== 'inbox') return + const agent = this.ctx.agents.get(session.id) + if (agent?.session !== session) return + this.broadcast({ + type: 'queue', + sessionId: session.id, + items: queueItemsFromInbox(value as InboxState), + }) }) }) ctx.inject(['jobs'], (jobsCtx) => { @@ -98,17 +105,6 @@ export class SessionControlController { return blocks } - private onSessionEvent(session: Session, event: SessionEvent): void { - if (event.type !== 'agent/inbox/spliced') return - const agent = this.ctx.agents.get(session.id) - if (agent?.session !== session) return - this.broadcast({ - type: 'queue', - sessionId: session.id, - items: queueItems(agent), - }) - } - private onJobsChanged(owner: Agent | undefined): void { if (owner !== undefined) { this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) @@ -175,19 +171,20 @@ class ControlQueue { } function queueItems(agent: Agent): SessionQueuedItem[] { - // The inbox projection folds the committed splice before `session/event` - // reaches this controller (the registry subscribes earlier), so the live - // lists already reflect the mutation that triggered the broadcast. - const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { - return target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep - } + return queueItemsFromInbox({ + 'next-turn': agent.inbox.nextTurn, + 'next-step': agent.inbox.nextStep, + }) +} + +function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] { return [ - ...project('next-turn').map(message => ({ + ...inbox['next-turn'].map(message => ({ id: message.id, placement: 'queued' as const, message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), - ...project('next-step').map(message => ({ + ...inbox['next-step'].map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, message: { id: message.id, content: message.content as unknown as JsonValue[] }, diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index c7d927d23c..5a7d7ce166 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -81,6 +81,38 @@ describe('Session control queue projection', () => { await iterator.next() }) + it('derives queue replacements from the completed projection regardless of registration order', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const control = new SessionControlController(ctx) + await ctx.plugin(SessionProjectionRegistry) + const session = ctx.sessions.create(SessionId('late-projection-queue')) + const agent = { id: session.id, session, inbox: undefined as never, status: 'running', ctx } as unknown as Agent + Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) + ctx.agents.register(agent) + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const pending = message('late projection') + + agent.inbox.append('next-turn', pending) + + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: 'projection', + key: 'inbox', + value: { 'next-turn': [{ id: pending.id }], 'next-step': [] }, + }, + }) + await expect(nextQueueFrame(iterator)).resolves.toMatchObject({ + items: [{ id: pending.id, placement: 'queued' }], + }) + + abort.abort() + await iterator.next() + }) + it('ignores inbox events without the exact live Agent session', async () => { const { ctx, control, agent, inbox } = await harness() const abort = new AbortController() diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 9fe9297155..0dc6ed0632 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 8b35b970aac93ac3c20fe570c79c3524abbe079f -README.zh.md: 4a11d81e6cbdbce1c1e7997785a2cf4456609171 +README.md: 63e9542d4a045c1e3e6da3f6b2365e7b4b01d8f0 +README.zh.md: c1eebceb1a70bd5862a902912bf9f6ec416135ff diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 8b35b970aa..63e9542d4a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -58,7 +58,7 @@ The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver. -Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection. +Every inbox mutation commits one normalized `agent/inbox/spliced` event. The projection registry folds that event synchronously, so the live projection reflects the splice when `Session.append()` returns. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome and emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists. Consumers that need a removed message use the claimed or discarded notification instead of depending on a pre-splice `session/event` view. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 4a11d81e6c..c1eebceb1a 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -58,7 +58,7 @@ interface Config { 统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会先打开持久轮次,再原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取操作通过仅执行删除的 splice 移除整批消息,并为每条消息各发出一次 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回拒绝结果,或返回将进入拟议步骤的完整消息。拒绝后,已领取批次保持已删除,并关闭不含步骤的轮次;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。 -每次 inbox 变更都会在修改实时投影之前,先发布一条规范化的 `agent/inbox/spliced` 事件。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一,持久事件的同步观察方可以从 splice 前投影重建被移除的值。 +每次 inbox 变更都会提交一条规范化的 `agent/inbox/spliced` 事件。投影注册表会同步折叠该事件,因此 `Session.append()` 返回时,实时投影已经反映该 splice。插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,并发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一。需要被移除消息的消费方应使用 claimed 或 discarded 通知,而不依赖 splice 前的 `session/event` 投影视图。 ### 循环生命周期(`agent.ts`) From bfb119850b3f9da72ca6b20314cc244050677cf9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 18:10:52 +0800 Subject: [PATCH 08/88] test: cover projection-aware session helpers --- .../preset/agent-presets/tests/remote.spec.ts | 2 ++ .../session-query/tests/observation.spec.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index bd6232e2c8..daf2bca00d 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -17,6 +17,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { TypertRemoteFailure, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' @@ -72,6 +73,7 @@ async function harness( ctx.loader.builtins.include = Include 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) diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 91bd1e8bc9..639b0ea9a7 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -89,6 +89,21 @@ describe('SessionObservationReader', () => { await ctx.fiber.dispose() }) + it('omits prepared projections when the registry is absent', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('prepared-without-projections') + ctx.provide('sessionPersistence', { + borrowSession: () => Promise.resolve(preparedSource(meta)), + } as never) + + using observed = await new SessionObservationReader(ctx).read(meta.id) + + expect(observed.source).toBe('prepared') + expect(observed.projections).toBeUndefined() + await ctx.fiber.dispose() + }) + it('reference-counts prepared leases and rejects retention after disposal', async () => { const ctx = new Context() await ctx.plugin(SessionStore) From bfaa403b7cd3e64567aa0297d0059b4bbb138ca5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 18:10:58 +0800 Subject: [PATCH 09/88] docs: refresh Claude SDK notices --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d47cdd7d2c..08c7f13f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies From 6322bb96e8457c8dcdb5553f2caf010ab1e7bde1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 14:00:44 +0800 Subject: [PATCH 10/88] fix(session-controller): resume cold sessions for Inbox commands --- ...08-17-durable-web-queue-recovery.i18n.yaml | 6 ++ .../2026-08-17-durable-web-queue-recovery.md | 29 ++++++++ ...026-08-17-durable-web-queue-recovery.zh.md | 29 ++++++++ .../api/session-controller/src/commands.ts | 16 +++-- packages/api/session-controller/src/index.ts | 4 +- .../tests/session-cold.host.spec.ts | 68 +++++++++++++++++++ 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml new file mode 100644 index 0000000000..0ecbe98874 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md +2026-08-17-durable-web-queue-recovery.md: 4b8f6a321be6550631763a99e0a130376ab4b081 +2026-08-17-durable-web-queue-recovery.zh.md: c482a97c55af74f90e3d9f6f4ab82bdda3489086 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md new file mode 100644 index 0000000000..4b8f6a321b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -0,0 +1,29 @@ +# Agent Note: Resume cold sessions for Inbox commands + +Status: implemented + +English | [中文](2026-08-17-durable-web-queue-recovery.zh.md) + +## Problem + +Inbox state survives in the session log, but `session.updateQueue` previously looked up only a live Agent. After a Host restart, an ordinary persisted Session remains cold until an operation needs its Agent, so editing or removing a restored pending item incorrectly returned `queue-item-not-found`. + +## Decision + +`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before reading or mutating its Inbox. A missing persisted Session still maps to `queue-item-not-found`, while other resume failures keep their existing error and subagent ownership keeps the same fence as other Agent operations. + +The resolved Agent constructs its Inbox from the registered durable projection. The command therefore reads the restored pending lists and records edits or removals through the existing normalized `agent/inbox/spliced` event. No new session event or on-disk format is introduced. + +## Verification + +A cold-operation test provides a detached persisted Session with a pending Inbox splice, invokes `session.updateQueue`, and proves that the Session is resumed, the row is removed, and the durable removal splice is appended. + +## Alternatives considered + +**Treat every missing live Agent as a missing queue item.** Rejected because persistence may still own the ordinary Session and its durable Inbox projection. + +**Fold the session log inside `session.updateQueue`.** Rejected because the Inbox projection already owns reconstruction, while the shared Agent resolver owns cold lifecycle setup and preset composition. + +## Consequences + +Operations on restored Inbox rows use the same preset composition, ownership checks, and durable mutation path as operations on live rows. Reading durable state does not itself require eager Agent recovery; only an explicit command resumes the ordinary Agent. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md new file mode 100644 index 0000000000..c482a97c55 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 为 Inbox 命令恢复冷会话 + +Status: implemented + +[English](2026-08-17-durable-web-queue-recovery.md) | 中文 + +## 问题 + +Inbox 状态保存在会话日志中,但 `session.updateQueue` 之前只查找 live Agent。Host 重启后,普通持久 Session 会保持冷状态,直到某项操作需要其 Agent,因此编辑或移除已恢复的待处理项会错误返回 `queue-item-not-found`。 + +## 决策 + +`session.updateQueue` 在读取或修改 Inbox 前,通过共享 Agent 解析器解析普通冷 Session。持久 Session 确实不存在时仍映射为 `queue-item-not-found`;其他恢复失败保留原有错误,subagent ownership 也保持与其他 Agent 操作相同的限制。 + +解析出的 Agent 从已注册的持久投影构建 Inbox。因此,该命令会读取恢复出的待处理列表,并通过既有的规范化 `agent/inbox/spliced` 事件记录编辑或移除。系统不引入新的会话事件或磁盘格式。 + +## 验证 + +冷操作测试提供一份带待处理 Inbox splice 的分离持久 Session,调用 `session.updateQueue`,并证明 Session 会被恢复、待处理项会被移除且持久删除 splice 会被追加。 + +## 考虑过的替代方案 + +**把所有缺少 live Agent 的情况都当作队列项不存在。** 不予采纳,因为持久层可能仍拥有该普通 Session 及其持久 Inbox 投影。 + +**在 `session.updateQueue` 内折叠会话日志。** 不予采纳,因为 Inbox 投影已经拥有重建逻辑,而共享 Agent 解析器拥有冷生命周期初始化和 preset 组合。 + +## 后果 + +对已恢复 Inbox 项的操作使用与 live 项相同的 preset 组合、所有权检查和持久变更路径。读取持久状态本身不要求提前恢复 Agent;只有显式命令会恢复普通 Agent。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..371042b1a6 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -377,11 +377,11 @@ export class SessionCommandController { } /** - * Mutate one still-pending queue occurrence without resuming a cold Agent. + * Mutate one still-pending queue occurrence, resuming a cold Agent first. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ - updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + async updateQueue(request: SessionUpdateQueueRequest): Promise { if (request.action.kind === 'edit' && request.action.content.some(block => block.type !== 'text')) { reject( @@ -390,13 +390,15 @@ export class SessionCommandController { { reason: 'QUEUE_EDIT_NON_TEXT' }, ) } - const agent = this.ctx.agents.get(request.sessionId) - if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) - } - if (agent === undefined) { + const found = await this.agents.resolveAgent(request.sessionId) + if ('error' in found) { + if (found.error.code !== 'session-not-found') rejectFailure(found.error) reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } + const { agent } = found + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + } const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) const located = nextTurn === undefined diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a3ce201aaf..26f5e9471b 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -257,12 +257,12 @@ export class SessionController extends TypertRemoteService { } /** - * Mutate one still-pending queue occurrence on a live Agent. + * Mutate one still-pending queue occurrence, resuming a cold Agent first. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ @Remote('updateQueue') - updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + updateQueue(request: SessionUpdateQueueRequest): Promise { return this.commands.updateQueue(request) } diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7156ac569d..8eb5f7ff66 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -11,6 +11,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import InboxService from '@deepseek-ai/dsh-agent/inbox' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' @@ -419,6 +420,73 @@ describe('cold history recovery view', () => { }) describe('Remote Agent and Session lookup policy', () => { + it('resumes a cold session before mutating a restored queue row', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(InboxService) + const sessionId = sid('session-cold-queue-mutation') + const meta = header(sessionId, 1000) + const message = createUserMessage({ + content: [{ type: 'text', text: 'survives restart' }], + source: { kind: 'user' }, + }) + const events = [{ + type: 'agent/inbox/spliced', + seq: 0, + time: 1001, + data: { target: 'next-turn', start: 0, inserted: [message] }, + }] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + locate: () => undefined, + } as never) + let resumedAgent: Agent | undefined + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + const session = ctx.sessions.create(sessionId, { + seed: events, + meta: { cwd: '/proj', createdAt: meta.createdAt }, + }) + resumedAgent = { + id: session.id, + options: {}, + session, + inbox: undefined as never, + status: 'idle', + ctx, + send() {}, + followup() {}, + steer() {}, + inject() {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } satisfies Agent + Object.assign(resumedAgent, { inbox: ctx.inboxes.create(resumedAgent) }) + ctx.agents.register(resumedAgent) + return { agent: resumedAgent, dispose: () => Promise.resolve() } + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + + const response = await remote.updateQueue(request({ + sessionId, + itemId: message.id, + action: { kind: 'remove' }, + })) + + expect(response).toEqual({ ok: true, value: { accepted: true } }) + expect(resume).toHaveBeenCalledOnce() + expect(resumedAgent?.inbox.nextTurn).toEqual([]) + expect(resumedAgent?.session.events.at(-1)).toMatchObject({ + type: 'agent/inbox/spliced', + data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, + }) + }) + it('deduplicates a cold resume across Agent and Session parameters', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) From 6bfda7a0e53f64d60c40266dbec189d383bd5b69 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 16:13:38 +0800 Subject: [PATCH 11/88] fix(session-controller): address review feedback on cold Inbox commands - Map an absent persistence backend to queue-item-not-found for updateQueue. - Re-fence the published Agent after shared cold resume settles. - Document cold queue activation and regenerate its public API catalog. - Supersede the attached-only claim in the Web subagent conversations note. --- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 3 +- ...-session-history-and-event-transport.zh.md | 3 +- ...08-17-durable-web-queue-recovery.i18n.yaml | 4 +- .../2026-08-17-durable-web-queue-recovery.md | 6 +- ...026-08-17-durable-web-queue-recovery.zh.md | 6 +- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 2 +- ...026-07-27-web-subagent-conversations.zh.md | 2 +- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +- .../2026-07-30-web-queue-steer-action.md | 2 +- .../2026-07-30-web-queue-steer-action.zh.md | 2 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 4 +- ...8-10-unary-apiproxy-remote-migration.zh.md | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 4 +- docs/subsystems/session.zh.md | 4 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 4 +- packages/api/session-controller/README.zh.md | 4 +- packages/api/session-controller/src/agent.ts | 6 +- .../api/session-controller/src/commands.ts | 3 - .../commands-queue-attachment.host.spec.ts | 22 +++++-- .../tests/session-cold.host.spec.ts | 66 +++++++++++++++++-- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- 26 files changed, 124 insertions(+), 55 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 00652d3b09..975d2880f1 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362 -2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e +2026-08-18-session-history-and-event-transport.md: 2dc5eac282ac8a9902a1d27371e1ae55cae82751 +2026-08-18-session-history-and-event-transport.zh.md: fc80aa7279b5f7d7ce80c401e7eb80d2b288c889 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 808565ff7d..2dc5eac282 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -153,7 +153,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca | `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background | | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | | `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | -| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state | +| `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox | +| `session.cancel` | only the current live Agent | Does not resume vanished state | | `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it | | `create` and fork target | new Session/Agent | The user command supplies creation authority | diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 8bd00def45..fc80aa7279 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -153,7 +153,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 | `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 | | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | | `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | -| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | +| `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session | +| `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | | `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | | `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 | diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml index 0ecbe98874..dc79579565 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md -2026-08-17-durable-web-queue-recovery.md: 4b8f6a321be6550631763a99e0a130376ab4b081 -2026-08-17-durable-web-queue-recovery.zh.md: c482a97c55af74f90e3d9f6f4ab82bdda3489086 +2026-08-17-durable-web-queue-recovery.md: 8bfdeaa46d7b2854c3e78848216c05e0688f9d15 +2026-08-17-durable-web-queue-recovery.zh.md: d75789430abcc638a779db882d7491373805e583 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md index 4b8f6a321b..8bfdeaa46d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -6,17 +6,17 @@ English | [中文](2026-08-17-durable-web-queue-recovery.zh.md) ## Problem -Inbox state survives in the session log, but `session.updateQueue` previously looked up only a live Agent. After a Host restart, an ordinary persisted Session remains cold until an operation needs its Agent, so editing or removing a restored pending item incorrectly returned `queue-item-not-found`. +Inbox state survives in the session log. An implementation that looks up only a live Agent cannot reach the restored pending rows of an ordinary persisted Session after a Host restart, so editing or removing one returns `queue-item-not-found` even though persistence still owns the queue occurrence. ## Decision -`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before reading or mutating its Inbox. A missing persisted Session still maps to `queue-item-not-found`, while other resume failures keep their existing error and subagent ownership keeps the same fence as other Agent operations. +`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before reading or mutating its Inbox. A missing persisted Session — including in a deployment that composes no persistence backend — still maps to `queue-item-not-found`, while other resume failures keep their existing error and subagent ownership keeps the same fence as other Agent operations. The resolved Agent constructs its Inbox from the registered durable projection. The command therefore reads the restored pending lists and records edits or removals through the existing normalized `agent/inbox/spliced` event. No new session event or on-disk format is introduced. ## Verification -A cold-operation test provides a detached persisted Session with a pending Inbox splice, invokes `session.updateQueue`, and proves that the Session is resumed, the row is removed, and the durable removal splice is appended. +A cold-operation test provides a detached persisted Session with a pending Inbox splice, invokes `session.updateQueue`, and proves that the Session is resumed, the row is removed, and the durable removal splice is appended. The shipped keyless Web queue-actions snapshot covers user-visible editing and removal through the real HTTP/SSE path; its output is unchanged, while the Host test isolates the cold lifecycle branch. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md index c482a97c55..d75789430a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -6,17 +6,17 @@ Status: implemented ## 问题 -Inbox 状态保存在会话日志中,但 `session.updateQueue` 之前只查找 live Agent。Host 重启后,普通持久 Session 会保持冷状态,直到某项操作需要其 Agent,因此编辑或移除已恢复的待处理项会错误返回 `queue-item-not-found`。 +Inbox 状态保存在会话日志中。若实现只查找 live Agent,Host 重启后就无法访问普通持久 Session 中已恢复的待处理行,因此即使持久层仍拥有该 queue occurrence,编辑或移除操作也会返回 `queue-item-not-found`。 ## 决策 -`session.updateQueue` 在读取或修改 Inbox 前,通过共享 Agent 解析器解析普通冷 Session。持久 Session 确实不存在时仍映射为 `queue-item-not-found`;其他恢复失败保留原有错误,subagent ownership 也保持与其他 Agent 操作相同的限制。 +`session.updateQueue` 在读取或修改 Inbox 前,通过共享 Agent 解析器解析普通冷 Session。持久 Session 确实不存在时(包括未组装持久化后端的部署)仍映射为 `queue-item-not-found`;其他恢复失败保留原有错误,subagent ownership 也保持与其他 Agent 操作相同的限制。 解析出的 Agent 从已注册的持久投影构建 Inbox。因此,该命令会读取恢复出的待处理列表,并通过既有的规范化 `agent/inbox/spliced` 事件记录编辑或移除。系统不引入新的会话事件或磁盘格式。 ## 验证 -冷操作测试提供一份带待处理 Inbox splice 的分离持久 Session,调用 `session.updateQueue`,并证明 Session 会被恢复、待处理项会被移除且持久删除 splice 会被追加。 +冷操作测试提供一份带待处理 Inbox splice 的分离持久 Session,调用 `session.updateQueue`,并证明 Session 会被恢复、待处理项会被移除且持久删除 splice 会被追加。既有 keyless Web queue-actions snapshot 通过真实 HTTP/SSE 路径覆盖用户可见的编辑与移除;其输出保持不变,Host 测试则隔离验证冷生命周期分支。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 684ebea047..8a04f4fe5f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95 -2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2 +2026-07-27-web-subagent-conversations.md: b65561bccd81d74609bcde89044b913695374a08 +2026-07-27-web-subagent-conversations.zh.md: 00b24ac1f2b0eccb7c872fcc82afc635f00c113c diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index c897d345d9..b65561bccd 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -61,7 +61,7 @@ The gateway maps missing parent, missing or diagnostic catalog entries, not-resu Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`. -The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents. +The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` applies the check directly because it intentionally queries only attached Agents, while `session.updateQueue` resolves cold sessions through the same fence ([durable web queue recovery](../bug-fix/2026-08-17-durable-web-queue-recovery.md)). The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0fdfd19b6d..00b24ac1f2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -61,7 +61,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。 -普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。 +普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 会直接执行同一检查,因为它有意只查询已附加的 Agent,而 `session.updateQueue` 会经由同一栅栏恢复冷会话([持久 Web 队列恢复](../bug-fix/2026-08-17-durable-web-queue-recovery.zh.md))。 适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index a391387a29..e8318b7177 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: 2718c5b3cc95f1ab02db80230ba158d9b5c3b4e6 -2026-07-30-web-queue-steer-action.zh.md: 377117e2aa9c20b1c39d1fb7f450dbe729b00580 +2026-07-30-web-queue-steer-action.md: a112b875cd33422b5d22e3638afa8789155eb307 +2026-07-30-web-queue-steer-action.zh.md: ff0567e1b277df9658474b137f76c36c27d613ae diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index 2718c5b3cc..a112b875cd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -32,7 +32,7 @@ The action does not run `agent/prompt-submit`: choosing steering intentionally c ### Host and client boundary -`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous Agent operation; the Host never reconstructs it by combining remove and prompt calls. +`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one Agent operation; the Host never reconstructs it by combining remove and prompt calls. The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 377117e2aa..ff0567e1b2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -32,7 +32,7 @@ Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲 ### Host 与客户端边界 -`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 +`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个注册表。 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index f7a2725d96..46f87abe57 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: b63946b581d3a2afcd159e3b1c0ef44f824aa35c -2026-08-10-unary-apiproxy-remote-migration.zh.md: 92f40fc79f2be44855620b6b6915798834284747 +2026-08-10-unary-apiproxy-remote-migration.md: 37e9ba636b03ca865bd8c48b0406e6b057b7d49a +2026-08-10-unary-apiproxy-remote-migration.zh.md: d0060da0cd8399ffe2592ae7ef3ec83d9bf358c7 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md index b63946b581..37e9ba636b 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -44,7 +44,7 @@ The Remote API deliberately follows Service names rather than preserving dotted | Session Host lifecycle | `session.list`, `search`, `create`, `fork` | Cross-Agent persistence, Workspace assignment, preset composition, and creation policy. | | Session transcript | `session.history`, `attachment`, `subagent.history` | Cold/live logs, pagination, projections, presenters, and attachment authorization. | | Agent model selection | `session.models`, `selectModel` | Per-Agent state, model validation, and default persistence are BFF policy. | -| Agent input and control | `session.prompt`, `updateQueue`, `cancel` | Image admission, Inbox mutation, and endpoint-specific live-only semantics. | +| Agent input and control | `session.prompt`, `updateQueue`, `cancel` | Image admission, Inbox mutation, and endpoint-specific live-only (`cancel`) or cold-resume (`updateQueue`) semantics. | | Native settings document | `settings.openDocument` | Host path resolution, document preparation, and native opening remain product policy in API Proxy. | | Session skill catalog | `skill.list` | Cold Sessions must not resume; preset standing scope and presenter filtering are BFF joins. | | Host runtime information | `host.describe` | Version, cwd, default model, and attached count combine several Host owners. | @@ -67,7 +67,7 @@ The migration must pin these outcomes with integration tests: - an id missing from durable persistence fails with `session-not-found`; - resolver failures keep their existing `RpcError` through `TypertLookupFailure`. -Lookup policy is key-wide, not endpoint-specific. Methods such as prompt, queue editing, cancellation, model selection, and skill listing cannot use the shared `agent` or `session` lookup while retaining live-only or no-resume behavior, so they remain in the API Proxy until Typert supports an explicit per-endpoint policy. +Lookup policy is key-wide, not endpoint-specific. Methods such as prompt, cancellation, model selection, and skill listing cannot use the shared `agent` or `session` lookup while retaining live-only or no-resume behavior; queue editing now resolves cold sessions through the shared resolver but keeps its Inbox-mutation and error-mapping policy local. These methods remain in the API Proxy until Typert supports an explicit per-endpoint policy. Methods whose signatures contain only branded ids do not invoke Typert object lookup. `subagents.interruptByParent()` must retain the existing process-local Activation lookup and parent-offline behavior: it does not call `agentFor`, read the catalog, inspect persistence, or cold-resume a parent or child. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index 92f40fc79f..d0060da0cd 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -44,7 +44,7 @@ Remote API 有意采用服务名称,而不保留旧 RPC 的点分名称。例 | Session Host 生命周期 | `session.list`、`search`、`create`、`fork` | 跨 Agent 持久化、Workspace 分配、preset 组合和创建策略。 | | Session transcript | `session.history`、`attachment`、`subagent.history` | cold/live 日志、分页、投影、呈现器和附件授权。 | | Agent 模型选择 | `session.models`、`selectModel` | 各 Agent 的状态、模型校验和默认值持久化属于 BFF 策略。 | -| Agent 输入与控制 | `session.prompt`、`updateQueue`、`cancel` | 图片准入、Inbox 变更和端点特有的仅限 live 语义。 | +| Agent 输入与控制 | `session.prompt`、`updateQueue`、`cancel` | 图片准入、Inbox 变更,以及端点特有的仅限 live(`cancel`)或恢复冷会话(`updateQueue`)语义。 | | 原生 settings 文档 | `settings.openDocument` | Host 路径解析、文档准备和原生打开仍属于 API Proxy 中的产品策略。 | | Session skill 目录 | `skill.list` | 不得恢复冷 Session;preset 的常驻 scope 和呈现器过滤属于 BFF 关联操作。 | | Host 运行时信息 | `host.describe` | 版本、cwd、默认模型和当前已附加的 Session 数量来自多个 Host 所有者。 | @@ -67,7 +67,7 @@ Remote API 有意采用服务名称,而不保留旧 RPC 的点分名称。例 - 持久化存储中不存在的 id 以 `session-not-found` 失败; - resolver 失败会保留现有的 `RpcError`,并通过 `TypertLookupFailure` 传递。 -Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列编辑、取消、模型选择和 skill 列表等方法如果使用共享 `agent` 或 `session` lookup,就无法保留仅限 live 或禁止恢复的行为,因此在 Typert 支持显式的逐端点策略之前,这些方法仍留在 API Proxy 中。 +Lookup 策略作用于整个 key,而非特定端点。提示词输入、取消、模型选择和 skill 列表等方法如果使用共享 `agent` 或 `session` lookup,就无法保留仅限 live 或禁止恢复的行为;队列编辑目前已通过共享 resolver 恢复冷会话,但把 Inbox 变更和错误映射策略留在本地。在 Typert 支持显式的逐端点策略之前,这些方法仍留在 API Proxy 中。 签名只包含 branded id 的方法不会调用 Typert 对象 lookup。`subagents.interruptByParent()` 必须保留现有的进程内 Activation lookup 和父级离线行为:它不会调用 `agentFor`、读取目录、检查持久化,也不会冷恢复父 Agent 或子 Agent。 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 80666a3c88..b531359c99 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: 87e8b33dec8eda6f7a716d71f5c4afa69a420d35 -session.zh.md: d30b7d24412a04521300c657c1b07daa1620ca5f +session.md: 910f2d77a85ee1c03e07102a8bfbbbdd5eb1389a +session.zh.md: 942241c3dda19955554d6fab1ae584d62fc0d87a diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 87e8b33dec..910f2d77a8 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -667,11 +667,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise /** - * Mutate one still-pending queue occurrence on a live Agent. + * Mutate one still-pending queue occurrence, resuming a cold Agent first. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ -@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue +@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): Promise /** * Cancel one active Agent turn without dropping its pending inbox. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d30b7d2441..942241c3dd 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -671,11 +671,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise /** - * Mutate one still-pending queue occurrence on a live Agent. + * Mutate one still-pending queue occurrence, resuming a cold Agent first. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ -@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue +@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): Promise /** * Cancel one active Agent turn without dropping its pending inbox. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8f59ec07e8..dcdfebabd6 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: 8f81caf78432f5c70586fd772a7db7d590035af2 -README.zh.md: 9932ed2a5b1995b3835243c7ffeb3c8c4767a523 +README.md: 6cae1d4a3a4be04aefd473f0af4cdf2bc86b3cbd +README.zh.md: 48004caa160764a421ccc5d1d7c9122a2a722e86 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 8f81caf784..6cae1d4a3a 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,7 +25,9 @@ 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, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. +Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; cancellation requires the corresponding live Agent; queue mutation, model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. + +`session.updateQueue` resolves the ordinary Session's Agent before locating the addressed `MessageId`, so an edit or removal can mutate a pending row reconstructed from the durable Inbox projection after a Host restart. A missing durable Session, including a Host without persistence, maps to `queue-item-not-found`; other resume failures retain their typed error, and subagent-owned identities remain fenced. Successful mutations append the standard `agent/inbox/spliced` event through the Agent's Inbox. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9932ed2a5b..48004caa16 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,7 +25,9 @@ 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 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;取消要求对应的 live Agent;queue 变更、模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 + +`session.updateQueue` 会先解析普通 Session 的 Agent,再定位被寻址的 `MessageId`,因此 Host 重启后,编辑或移除操作仍可修改从持久 Inbox 投影重建的待处理行。持久 Session 不存在时(包括 Host 未组装 persistence 后端)会映射为 `queue-item-not-found`;其他恢复失败保留其类型化错误,由 subagent 拥有的 identity 仍会被拒绝。成功的变更通过 Agent 的 Inbox 追加标准 `agent/inbox/spliced` 事件。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index f96c66b464..7dd5cb2f36 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -195,7 +195,11 @@ export class ApiSessionAgentController { this.resumes.set(sessionId, resume) } try { - return { agent: await resume } + const agent = await resume + // A shared resume can publish an identity that subagent routing adopts + // before every waiter observes it; apply the live ownership policy again. + const published = this.liveAgent(sessionId) + return published ?? { agent } } catch (error: unknown) { if (error instanceof ApiSessionNotFound) { return { diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 371042b1a6..b1a4e7f231 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -396,9 +396,6 @@ export class SessionCommandController { reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const { agent } = found - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) - } const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) const located = nextTurn === undefined diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 668b9b689f..3ed2a023dd 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -49,7 +49,15 @@ async function commandHarness(): Promise<{ assembled: undefined, } const agents = { - resolveAgent: () => Promise.resolve({ agent }), + resolveAgent: (sessionId: SessionId) => Promise.resolve(sessionId === agent.id + ? { agent } + : { + error: { + code: 'session-not-found' as const, + message: `session "${sessionId}" not found`, + details: { sessionId }, + }, + }), selectionFor: () => selection, serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), composeAgent: () => Promise.resolve({ setup: () => {} }), @@ -96,22 +104,22 @@ describe('Session queue commands', () => { await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, })), 'steer-unavailable') - expect(controller.updateQueue({ + await expect(controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, - })).toEqual({ accepted: true }) + })).resolves.toEqual({ accepted: true }) expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }]) - expect(controller.updateQueue({ + await expect(controller.updateQueue({ sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' }, - })).toEqual({ accepted: true }) + })).resolves.toEqual({ accepted: true }) Object.assign(agent, { status: 'running' }) const steered = inbox.nextTurn[0] if (steered === undefined) throw new Error('missing edited queue item') - expect(controller.updateQueue({ + await expect(controller.updateQueue({ sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' }, - })).toEqual({ accepted: true }) + })).resolves.toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(steered) await expectFailure(Promise.resolve().then(() => controller.cancel({ diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 8eb5f7ff66..357ea3a440 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -10,8 +10,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import InboxService from '@deepseek-ai/dsh-agent/inbox' +import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' @@ -424,7 +423,6 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(InboxService) const sessionId = sid('session-cold-queue-mutation') const meta = header(sessionId, 1000) const message = createUserMessage({ @@ -437,11 +435,11 @@ describe('Remote Agent and Session lookup policy', () => { time: 1001, data: { target: 'next-turn', start: 0, inserted: [message] }, }] as SessionEvent[] - ctx.provide('sessionPersistence', { + providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events }), locate: () => undefined, - } as never) + }) let resumedAgent: Agent | undefined const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { const session = ctx.sessions.create(sessionId, { @@ -463,7 +461,9 @@ describe('Remote Agent and Session lookup policy', () => { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } satisfies Agent - Object.assign(resumedAgent, { inbox: ctx.inboxes.create(resumedAgent) }) + Object.assign(resumedAgent, { + inbox: new Inbox(ctx, resumedAgent.session, agentEvents(ctx, resumedAgent)), + }) ctx.agents.register(resumedAgent) return { agent: resumedAgent, dispose: () => Promise.resolve() } }) @@ -487,6 +487,25 @@ describe('Remote Agent and Session lookup policy', () => { }) }) + it('keeps queue-item-not-found for a cold session when no persistence backend is composed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + + const response = await remote.updateQueue(request({ + sessionId: sid('session-no-persistence'), + itemId: MessageId('queued-item'), + action: { kind: 'remove' }, + })) + + expect(response.ok).toBe(false) + if (!response.ok) expect(response.error.code).toBe('queue-item-not-found') + }) + it('deduplicates a cold resume across Agent and Session parameters', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) @@ -576,6 +595,41 @@ describe('Remote Agent and Session lookup policy', () => { expect(resume).not.toHaveBeenCalled() expect(inspect).toHaveBeenCalledOnce() }) + + it('reapplies the subagent ownership fence after a successful resume publishes the Agent', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = sid('session-remote-resumed-child') + const meta = header(sessionId, 1000) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + }) + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + const session = ctx.sessions.create(sessionId, { + meta: { cwd: '/proj', origin: 'subagent' }, + }) + const published = { id: session.id, session, status: 'idle', ctx } as Agent + ctx.agents.register(published) + return { agent: published, dispose: () => Promise.resolve() } + }) + const defaultLookup = ctx.typert.lookups.get('agent') + createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultLookup) }) + const lookup = ctx.typert.lookups.get('agent') + if (lookup === undefined) throw new Error('Agent lookup provider was not mounted') + + const resolution = lookup.resolve(sessionId) + + await expect(resolution).rejects.toBeInstanceOf(TypertLookupFailure) + await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + }) }) describe('subagent ownership fence', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5412e025cd..dfae501ed9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1383,8 +1383,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the durable attachment reference and base64-encoded bytes.', }, { - signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue', - description: 'Mutate one still-pending queue occurrence on a live Agent.', + signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): Promise', + description: 'Mutate one still-pending queue occurrence, resuming a cold Agent first.', parameters: [{ name: 'request', description: 'Session, queue item, and requested mutation.' }], returns: 'acknowledgement that the queue mutation was applied.', }, From 8c8c5b2680f1e4ac8d4f8ef7fa808acdfa2829bb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 27 Aug 2026 12:37:00 +0800 Subject: [PATCH 12/88] fix(web): restore pending Inbox after restart --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 2 +- ...26-07-19-gui-web-client-architecture.zh.md | 2 +- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 4 +- ...-07-31-claimed-pre-step-inbox-lifecycle.md | 4 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 4 +- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 10 +- ...-session-history-and-event-transport.zh.md | 10 +- ...6-07-31-web-stop-preserves-queue.i18n.yaml | 4 +- .../2026-07-31-web-stop-preserves-queue.md | 6 +- .../2026-07-31-web-stop-preserves-queue.zh.md | 6 +- ...08-17-durable-web-queue-recovery.i18n.yaml | 4 +- .../2026-08-17-durable-web-queue-recovery.md | 36 +- ...026-08-17-durable-web-queue-recovery.zh.md | 36 +- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +- .../2026-07-30-web-queue-steer-action.md | 26 +- .../2026-07-30-web-queue-steer-action.zh.md | 26 +- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 +- .../2026-08-06-web-queue-steer-all-gesture.md | 4 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 4 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 2 +- ...026-08-08-web-background-job-display.zh.md | 2 +- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 2 +- ...7-session-projection-and-command-log.zh.md | 2 +- .../feature/2026-08-04-task-surface.i18n.yaml | 4 +- .../feature/2026-08-04-task-surface.md | 16 +- .../feature/2026-08-04-task-surface.zh.md | 16 +- apps/web/tests/cold-blank-session.e2e.ts | 22 +- .../cold-blank-session/queue.expected.md | 9 + .../cold-blank-session/sidebar.expected.md | 5 +- apps/web/tests/scaffold.ts | 37 +- apps/web/tests/steering.e2e.ts | 7 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/snapshot.ts | 13 - .../session-controller/src/client/index.ts | 1 - .../src/client/sessions/manager.ts | 28 +- .../src/client/sessions/queue-mirror.ts | 67 ---- .../src/client/sessions/session.ts | 29 +- .../api/session-controller/src/control.ts | 38 +-- packages/api/session-controller/src/types.ts | 13 - .../tests/client-apply.client.spec.ts | 2 +- .../tests/control-queue.host.spec.ts | 168 --------- .../tests/fake-api.client.ts | 1 - .../tests/manager.client.spec.ts | 6 +- .../tests/projection-store.client.spec.ts | 2 +- .../tests/queue-store.client.spec.ts | 321 ++++-------------- .../tests/sessions-service.client.spec.ts | 2 +- .../tests/transport.client.spec.ts | 6 +- .../client/connection/src/client/fixture.ts | 4 - .../connection/tests/fixture.client.spec.ts | 1 - .../ui-chat/src/client/chat/ChatView.tsx | 8 +- .../ui-chat/tests/chat-view.client.spec.tsx | 73 ++-- .../tests/gate-branch-tails.client.spec.tsx | 1 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 2 + .../src/client/contract/input.ts | 9 +- .../src/client/contract/queue.ts | 11 - .../src/client/input/facade.ts | 13 +- .../ui-conversation/src/client/input/hub.ts | 7 +- .../src/client/input/queue-store.ts | 25 -- .../src/client/queue/QueueDock.tsx | 63 ++-- .../ui-conversation/src/client/service.ts | 7 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../conversation-registry.client.spec.ts | 1 - .../tests/input-bar.client.spec.tsx | 36 +- .../tests/queue-dock.client.spec.tsx | 83 +++-- .../service-orchestration.client.spec.ts | 38 +-- .../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 - .../src/client/api-catalog.ts | 6 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 +- .../client-runtime/src/fixtures.ts | 1 - .../client-runtime/src/sessions.ts | 11 + pnpm-lock.yaml | 3 + 89 files changed, 523 insertions(+), 979 deletions(-) create mode 100644 apps/web/tests/expected/cold-blank-session/queue.expected.md delete mode 100644 packages/api/session-controller/src/client/sessions/queue-mirror.ts delete mode 100644 packages/api/session-controller/tests/control-queue.host.spec.ts delete mode 100644 packages/client/ui-conversation/src/client/contract/queue.ts delete mode 100644 packages/client/ui-conversation/src/client/input/queue-store.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b80381f031..cde9d4fb80 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 -2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 +2026-07-19-gui-web-client-architecture.md: f55723899fdec8b8d6624ae41b7eb58e55db21ca +2026-07-19-gui-web-client-architecture.zh.md: bda5322625bf5bfc85c31b82c688ec7b459a2226 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 4448fd5c68..f55723899f 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ``` - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates. +- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, running, removal, open state, paging, and prompt errors remain Session facts; pending Inbox values live in the generic Session projection store. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React reads Session lifecycle through `useSession(selector)` and domain projections through `useProjection(key, selector)`, so each hook isolates unrelated updates. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index e8a8121a1a..bda5322625 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ``` - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。 +- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、running、removed、open state、paging 和 prompt error 仍是 Session 信息;待处理 Inbox 值则位于通用 Session projection store。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 通过 `useSession(selector)` 读取 Session lifecycle,通过 `useProjection(key, selector)` 读取领域投影,使每个 hook 都隔离无关更新。 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md)拥有 Tool 递归渲染。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 64e6bbc289..30ce09087e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 6f159dc16e0e4063f9077c31829a10caca98eae0 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 2cb082dce75f1845e52a52289a8e3eaa1a000931 +2026-07-25-web-client-session-scope-and-provide-channel.md: 49b02fe3776f4cac8080ab527f5958baca3ef7ab +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e1a8427be2c02bb01a1b7a80d723b78e9ca4c7d9 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 6f159dc16e..49b02fe377 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -99,7 +99,7 @@ Slot scope is the closed set `root | session-maybe | session`: - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`). -### The read-only queue mirror +### Input delivery - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 2cb082dce7..e1a8427be2 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -99,7 +99,7 @@ slot scope 是闭集 `root | session-maybe | session`: - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。 -### 队列只读镜像 +### 输入投递 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 93176ac3f9..03f0b51de1 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: d914b075607181a8b036ede003e2998f18c6be3b -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 1701b949500c310656f48b9872e1d714a984658a +2026-07-31-claimed-pre-step-inbox-lifecycle.md: 920237c7191b6d1c1861737073c6e41935cd6633 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 6433240be9587b137c649bb9b420a001f088a6f0 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index d914b07560..920237c719 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -18,7 +18,7 @@ Before every proposed step, `Inbox.claim(target)` atomically removes the complet The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from Inbox itself. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentRegistry` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream whenever the projection registry is composed; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue UI consumers read the same raw projection through the generic Session projection carrier; no derived queue value or dedicated control frame participates. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. @@ -34,7 +34,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026 ## Verification -Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Generated event and type catalogs expose only the new waterfall and payloads. +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and generic client delivery of the folded Inbox value. Generated event and type catalogs expose only the new waterfall and payloads. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index 1701b94950..6433240be9 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -18,7 +18,7 @@ Status: implemented 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 Inbox 自行发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentRegistry` 会在投影注册表已组合时,在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。需要整份队列的 UI 消费方通过通用 Session projection carrier 直接读取同一份原始投影,不会派生 queue 值,也没有专用 control frame。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 @@ -34,7 +34,7 @@ Status: implemented ## 验证 -agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。生成的事件与类型目录只公开新的 waterfall 与载荷。 +agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及折叠后 Inbox 值经通用客户端传输投递。生成的事件与类型目录只公开新的 waterfall 与载荷。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 975d2880f1..540387b1d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 2dc5eac282ac8a9902a1d27371e1ae55cae82751 -2026-08-18-session-history-and-event-transport.zh.md: fc80aa7279b5f7d7ce80c401e7eb80d2b288c889 +2026-08-18-session-history-and-event-transport.md: 42c08caf49425695fc66556578afc6b815600d51 +2026-08-18-session-history-and-event-transport.zh.md: 2cbb2174da31e9af472ecb8b6acbea76a51c145e diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 2dc5eac282..42c08caf49 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -8,7 +8,7 @@ English | [中文](2026-08-18-session-history-and-event-transport.zh.md) The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay. -These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation. +These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation. Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects. @@ -200,9 +200,9 @@ A terminal failure from the initial page, repair page, or follow enters the curr `session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript. -Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents. +Each generation emits a complete baseline first, followed by jobs and projection deltas. The baseline reads process-local registries and folded projection values without resuming cold Agents. -Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline. +Jobs use complete replacement values and apply last-wins. Projection updates carry monotonically increasing revisions, while a new baseline replaces the complete projection map. Session and owner disposal clear stale mirrors. The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests. @@ -309,7 +309,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re **Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently. -**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`. +**Move jobs, projections, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`. **Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics. @@ -339,7 +339,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources; the r Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection. -Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark. +Session control tests pin baseline-first delivery, no cold-Session resume, jobs replacement, and the projection watermark. Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index fc80aa7279..2cbb2174da 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -8,7 +8,7 @@ Status: implemented 浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。 -这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 +这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;jobs、projection 值和 Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。 @@ -200,9 +200,9 @@ initial page、repair page 或 follow 的 terminal failure 进入当前 Session `session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。 -每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。 +每个 generation 先发完整 baseline,再发 jobs 与 projection 增量帧。baseline 读取进程内 registry 和已折叠的 projection 值,不恢复冷 Agent。 -queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。 +jobs 使用完整 replacement 值并按 last-wins 应用。Projection update 携带单调递增 revision,新 baseline 则替换完整 projection map。Session 与 owner disposal 会清理陈旧镜像。 原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。 @@ -309,7 +309,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace **把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。 -**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。 +**把 jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。 **让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。 @@ -339,7 +339,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、`$eve Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。 -Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。 +Session control 测试固定 baseline-first、冷 Session 不恢复、jobs replacement 与 projection watermark。 Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml index a9eade53c1..fca9ec8eb2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md -2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93 -2026-07-31-web-stop-preserves-queue.zh.md: af9c114fd06452e58322f2554a0f2ccd861c81c9 +2026-07-31-web-stop-preserves-queue.md: 5cf49326d606dbb621d1ba88724ecd06f6145645 +2026-07-31-web-stop-preserves-queue.zh.md: 9bbe2fb28a165d964d59ac2b553a7e9adf796d02 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md index b8bba45009..5cf49326d6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md @@ -8,13 +8,13 @@ English | [中文](2026-07-31-web-stop-preserves-queue.zh.md) The Web stop button reached `session.cancel`, which mapped to broad `agent.cancel({ kind: 'user' })`. During an active turn, ordinary composer submissions are already accepted as independently addressable Queue occurrences. Broad cancellation discarded every occurrence when the user intended to stop only the current generation, conflating turn interruption with the Queue's explicit delete operation. -The browser cannot repair that loss by resending visible rows. It does not own their live `InboxItemId`, wake policy, or claim race, and a resend can duplicate work that the Host has already claimed. +The browser cannot repair that loss by resending visible rows. It does not own their `MessageId` admission, wake policy, or claim race, and a resend can duplicate work that the Host has already claimed. ## Decision -`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries. +`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending Inbox work while cooperatively aborting the current turn. The standard `inbox` projection carries both lists; QueueDock reads only `next-turn`. -The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles. +The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued message through its existing FIFO driver. That claim records a durable pure-deletion `agent/inbox/spliced` event, so the standard `inbox` projection retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this transition until it settles. This mapping changes only the Host `session.cancel` endpoint used by Web clients. The default `Agent.cancel()` contract remains broad, ACP and TUI retain their existing cancellation policies, and `AgentHandle.dispose()` still clears pending work during teardown. Queue row removal remains the explicit Web action for discarding one pending occurrence. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md index af9c114fd0..9bbe2fb28a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md @@ -8,13 +8,13 @@ Status: implemented Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({ kind: 'user' })`。在活动轮次期间,普通 composer 提交已经被接纳为可独立寻址的 Queue 入队项。用户只想停止当前生成时,广义取消却会丢弃所有入队项,混淆了轮次中断与 Queue 的显式删除操作。 -浏览器无法通过重发可见行修复这一损失。它不拥有这些行的实时 `InboxItemId`、唤醒策略或认领竞态;重发还可能重复 Host 已认领的工作。 +浏览器无法通过重发可见行修复这一损失。它不拥有这些行的 `MessageId` 准入、唤醒策略或认领竞态;重发还可能重复 Host 已认领的工作。 ## 决策 -`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。 +`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 Inbox 工作。标准 `inbox` 投影携带两份列表;QueueDock 只读取 `next-turn`。 -AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。 +AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一条可唤醒的 queued 消息。该认领会记录一条持久纯删除 `agent/inbox/spliced` 事件,因此标准 `inbox` 投影会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一转换,直到该工作结算。 该映射只更改 Web 客户端使用的 Host `session.cancel` 端点。`Agent.cancel()` 默认约定仍为广义取消,ACP 和 TUI 保留既有取消策略,`AgentHandle.dispose()` 在拆卸期间仍会清除待处理工作。移除 Queue 行仍是用于丢弃单个待处理入队项的显式 Web 操作。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml index dc79579565..fd94133c62 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md -2026-08-17-durable-web-queue-recovery.md: 8bfdeaa46d7b2854c3e78848216c05e0688f9d15 -2026-08-17-durable-web-queue-recovery.zh.md: d75789430abcc638a779db882d7491373805e583 +2026-08-17-durable-web-queue-recovery.md: feea3b9c02fe5ce81bb6a8a453e83db658616ecd +2026-08-17-durable-web-queue-recovery.zh.md: 35c5bea725bb632a587aba407535192ccb5eca29 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md index 8bfdeaa46d..feea3b9c02 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -1,4 +1,4 @@ -# Agent Note: Resume cold sessions for Inbox commands +# Agent Note: Recover the Web queue from durable Inbox state Status: implemented @@ -6,24 +6,44 @@ English | [中文](2026-08-17-durable-web-queue-recovery.zh.md) ## Problem -Inbox state survives in the session log. An implementation that looks up only a live Agent cannot reach the restored pending rows of an ordinary persisted Session after a Host restart, so editing or removing one returns `queue-item-not-found` even though persistence still owns the queue occurrence. +Inbox acceptance records normalized `agent/inbox/spliced` events, but the Web queue used a separate mux baseline built by enumerating live Agents. After a Host process restart, a persisted ordinary Session remained cold until an operation needed its Agent, so the live-only baseline omitted accepted pending messages that were still present in the durable log. + +A reconnect-only repair would retain two recovery implementations: one for a live Inbox and one for cold Web reads. The correct owner is the Inbox domain, and the session-projection framework already provides live drive, cold folding, reconnect baselines, and cache restoration. ## Decision -`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before reading or mutating its Inbox. A missing persisted Session — including in a deployment that composes no persistence backend — still maps to `queue-item-not-found`, while other resume failures keep their existing error and subagent ownership keeps the same fence as other Agent operations. +`AgentRegistry` registers the standard `inbox` projection whenever the Session projection registry is composed. The projection folds normalized `agent/inbox/spliced` operations from the complete session log into the complete `{ 'next-turn', 'next-step' }` state and exposes that same state as its value. A fork therefore projects pending Inbox operations inherited in its seed, like every other projection. Its schema and `InboxState` type have one definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator. -The resolved Agent constructs its Inbox from the registered durable projection. The command therefore reads the restored pending lists and records edits or removals through the existing normalized `agent/inbox/spliced` event. No new session event or on-disk format is introduced. +The projection registry owns both durable reconstruction and live state. Its existing `session/event` drive folds each committed splice before `Session.append()` returns. Each Agent's `Inbox` command facade reads that same registry state. Inbox normalizes splice coordinates and checks pending `MessageId` uniqueness before append. It does not traverse `session.events`, copy the folded arrays, or apply the committed transition again. It emits the per-message `inserted`, `discarded`, and `claimed` live notifications owned by its mutations. + +The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block on history tail pages, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration. + +The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection. + +`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before mutating its Inbox. A restored pending row therefore remains editable, removable, or steerable after restart, while subagent ownership keeps the same fence as other Agent operations. + +No new session event or on-disk format is introduced. The existing splice stream remains the durable source of truth. ## Verification -A cold-operation test provides a detached persisted Session with a pending Inbox splice, invokes `session.updateQueue`, and proves that the Session is resumed, the row is removed, and the durable removal splice is appended. The shipped keyless Web queue-actions snapshot covers user-visible editing and removal through the real HTTP/SSE path; its output is unchanged, while the Host test isolates the cold lifecycle branch. +Inbox tests prove that service creation restores both lists through the registered projection, a direct durable append is immediately visible through the same live cell, a fork projects pending input inherited in its seed, and Inbox mutations reject duplicate pending identities before append. Host projection coverage reads a detached persisted Session with a pending splice, returns `values.inbox` through `session.history`, and proves that no live Agent is required. A separate cold-operation test proves `session.updateQueue` resumes the Session and appends the durable removal splice. + +Client coverage pins generic Inbox projection delivery, reconnect truncation, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart. ## Alternatives considered -**Treat every missing live Agent as a missing queue item.** Rejected because persistence may still own the ordinary Session and its durable Inbox projection. +**Add cold Sessions to the old queue reconnect loop.** Rejected because it would duplicate the projection registry's cold fold and preserve separate implementations for live pushes, history, cache, and reconnect. -**Fold the session log inside `session.updateQueue`.** Rejected because the Inbox projection already owns reconstruction, while the shared Agent resolver owns cold lifecycle setup and preset composition. +**Register a Web-specific `queue` projection in Session Controller.** Rejected because pending input belongs to Inbox. Placement rows and a handoff list would introduce a second domain model solely for one client. + +**Store a complete Inbox snapshot on every splice event.** Rejected because the durable event is a normalized mutation, not a repeated aggregate. The projection framework owns aggregate reconstruction and checkpointing. + +**Reconstruct Inbox in the client from raw session events.** Rejected because pagination may omit the insertion that established current state and every client would duplicate splice semantics. + +**Resume every cold Agent while opening the mux stream.** Rejected because displaying durable state must not publish runtime resources, mount presets, or start lifecycle work. ## Consequences -Operations on restored Inbox rows use the same preset composition, ownership checks, and durable mutation path as operations on live rows. Reading durable state does not itself require eager Agent recovery; only an explicit command resumes the ordinary Agent. +Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks. + +Clients receive the raw two-list Inbox value and decide which messages their surface presents. Forked sessions project pending Inbox operations present in their inherited seed. The projection state version invalidates cached rows whenever its serialized state or fold semantics change. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md index d75789430a..35c5bea725 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 为 Inbox 命令恢复冷会话 +# Agent Note: 从持久 Inbox 状态恢复 Web Queue Status: implemented @@ -6,24 +6,44 @@ Status: implemented ## 问题 -Inbox 状态保存在会话日志中。若实现只查找 live Agent,Host 重启后就无法访问普通持久 Session 中已恢复的待处理行,因此即使持久层仍拥有该 queue occurrence,编辑或移除操作也会返回 `queue-item-not-found`。 +Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 Web Queue 使用另一份通过枚举 live Agent 构建的 mux 基线。Host 进程重启后,持久化的普通 Session 会保持冷状态,直到某项操作需要其 Agent,因此 live-only 基线会遗漏仍存在于持久日志中的已接受待处理消息。 + +只修复重连逻辑仍会保留两套恢复实现:一套用于 live Inbox,另一套用于 Web 冷读取。正确的所有者是 Inbox 领域,而会话投影框架已经提供 live 驱动、冷折叠、重连基线和缓存恢复。 ## 决策 -`session.updateQueue` 在读取或修改 Inbox 前,通过共享 Agent 解析器解析普通冷 Session。持久 Session 确实不存在时(包括未组装持久化后端的部署)仍映射为 `queue-item-not-found`;其他恢复失败保留原有错误,subagent ownership 也保持与其他 Agent 操作相同的限制。 +`AgentRegistry` 会在 Session projection registry 已组合时注册标准 `inbox` 投影。该投影从完整会话日志中折叠规范化的 `agent/inbox/spliced` 操作,得到完整的 `{ 'next-turn', 'next-step' }` 状态,并直接暴露同一状态。因此 fork 会像其他投影一样,自然投影其 seed 中继承的待处理 Inbox 操作。其 schema 与 `InboxState` 类型只有一个定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。 -解析出的 Agent 从已注册的持久投影构建 Inbox。因此,该命令会读取恢复出的待处理列表,并通过既有的规范化 `agent/inbox/spliced` 事件记录编辑或移除。系统不引入新的会话事件或磁盘格式。 +投影注册表同时拥有持久重建与 live 状态。其既有的 `session/event` 驱动会在 `Session.append()` 返回前折叠每个已提交 splice。每个 Agent 的 `Inbox` 命令 facade 都读取同一份注册表状态。Inbox 在 append 前规范化 splice 坐标并检查待处理 `MessageId` 唯一性。Inbox 不遍历 `session.events`,不复制已折叠数组,也不再次应用已提交 transition。它仍发出其变更拥有的逐消息 `inserted`、`discarded` 与 `claimed` live 通知。 + +通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在历史尾页中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。 + +客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。 + +`session.updateQueue` 在修改 Inbox 前通过共享 Agent 解析器解析普通冷 Session。因此,恢复出的待处理行在重启后仍可编辑、移除或 steering,而 subagent ownership 保持与其他 Agent 操作相同的 fence。 + +系统没有引入新的会话事件或磁盘格式。既有 splice 流仍是持久真源。 ## 验证 -冷操作测试提供一份带待处理 Inbox splice 的分离持久 Session,调用 `session.updateQueue`,并证明 Session 会被恢复、待处理项会被移除且持久删除 splice 会被追加。既有 keyless Web queue-actions snapshot 通过真实 HTTP/SSE 路径覆盖用户可见的编辑与移除;其输出保持不变,Host 测试则隔离验证冷生命周期分支。 +Inbox 测试证明服务创建会通过已注册投影恢复两份列表、直接追加的持久事件会立即通过同一 live cell 可见、fork 会投影其 seed 中继承的待处理输入,并且 Inbox 变更会在 append 前拒绝重复的待处理标识。Host 投影覆盖会读取包含待处理 splice 的已分离持久 Session,经 `session.history` 返回 `values.inbox`,并证明不需要 live Agent。另一项冷操作测试证明 `session.updateQueue` 会恢复 Session 并追加持久删除 splice。 + +客户端覆盖固定通用 Inbox 投影投递、重连截断、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。 ## 考虑过的替代方案 -**把所有缺少 live Agent 的情况都当作队列项不存在。** 不予采纳,因为持久层可能仍拥有该普通 Session 及其持久 Inbox 投影。 +**把冷 Session 加入旧 queue 重连循环。** 不予采纳,因为这会重复投影注册表的冷折叠,并让实时推送、历史、缓存和重连继续使用不同实现。 -**在 `session.updateQueue` 内折叠会话日志。** 不予采纳,因为 Inbox 投影已经拥有重建逻辑,而共享 Agent 解析器拥有冷生命周期初始化和 preset 组合。 +**在 Session Controller 注册 Web 专属 `queue` 投影。** 不予采纳,因为待处理输入属于 Inbox。placement 行与 handoff 列表会只为一个客户端引入第二套领域模型。 + +**在每条 splice 事件中保存完整 Inbox 快照。** 不予采纳,因为持久事件是规范化变更,不是重复聚合。聚合重建与 checkpoint 属于投影框架。 + +**在客户端根据原始会话事件重建 Inbox。** 不予采纳,因为分页可能省略建立当前状态的插入事件,每个客户端也会重复实现 splice 语义。 + +**打开 mux 流时恢复每个冷 Agent。** 不予采纳,因为展示持久状态不应发布运行时资源、挂载 preset 或启动生命周期工作。 ## 后果 -对已恢复 Inbox 项的操作使用与 live 项相同的 preset 组合、所有权检查和持久变更路径。读取持久状态本身不要求提前恢复 Agent;只有显式命令会恢复普通 Agent。 +待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。 + +客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。fork 出的会话会投影继承 seed 中存在的待处理 Inbox 操作。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index e8318b7177..bbc7de53b8 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: a112b875cd33422b5d22e3638afa8789155eb307 -2026-07-30-web-queue-steer-action.zh.md: ff0567e1b277df9658474b137f76c36c27d613ae +2026-07-30-web-queue-steer-action.md: 4135c465166d88873e5b4595758778839efe83ee +2026-07-30-web-queue-steer-action.zh.md: 2717c8a53b6a46c4751b1a8bb1e563b7aa37a42e diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index a112b875cd..4135c46516 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -16,27 +16,23 @@ Implementing the row action as a client-side delete followed by `session.prompt( Each non-editing ordinary-session QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. An addressed subagent keeps its Queue projection read-only because its continuation transport does not expose queue mutation. -Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `user/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path. +Activating the action requests current-turn steering for that exact `MessageId`. Success moves the message from `inbox['next-turn']` to `inbox['next-step']`; QueueDock removes its row and ChatView renders the same pending steering after the `Deep diving...` running-status row, with Copy but no Fork. Once AgentLoop drains it, the durable deletion removes the pending bubble and the existing `user/message` event enters the ordinary conversation-node path with its clock and Copy. -The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. +The running bit is both the interaction hint and the Host's synchronous admission check. If the Agent is no longer running, the operation leaves the Queue message unchanged and returns a typed `steer-unavailable` error, after which the original waking message proceeds through Queue. If the driver already claimed the message, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The Host settings document persists the preference across Web origins sharing one DSH home, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. ### Agent and lifecycle boundary -`InboxAction` gains a consumer-backed `{ kind: 'steer' }` operation alongside edit and remove. `Agent.updateInbox()` handles it only after locating the queued occurrence and proving `acceptsNextStep`; it never delegates to the best-effort `agent.steer()` alias. - -An applied action ends the queued occurrence and accepts the same immutable `UserMessage` as a new steering occurrence. The steering occurrence receives a new `InboxItemId` and truthful `placement: 'steering'`, while the message retains its `MessageId`, content, source, and any pending `SteeringReceipt` delivery controller. AgentLoop installs the new outbox entry before publishing lifecycle events, then emits its enqueue before the old occurrence's discard so re-entrant cancellation cannot observe or retire an unannounced item. The existing inbox conservation invariant therefore continues to require one enqueue and one terminal dequeue or discard for each occurrence. - -The action does not run `agent/prompt-submit`: choosing steering intentionally changes delivery from an independently admitted turn to current-turn next-step input. It neither cancels current work nor reorders the remaining Queue. +`session.updateQueue` locates the `MessageId` in `Inbox.nextTurn` and requires the Agent to be running. It then removes that exact message and synchronously passes the same immutable `UserMessage` to `Agent.steer()`. The two Inbox mutations append a canceled `next-turn` deletion followed by a `next-step` insertion without an await or a second RPC, while preserving the message id, content, and source. Choosing steering changes delivery from an independently admitted turn to current-turn next-step input; it neither cancels current work nor reorders the remaining Queue. ### Host and client boundary -`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one Agent operation; the Host never reconstructs it by combining remove and prompt calls. +`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous pair of Inbox operations; the Host never reconstructs it by combining remove and prompt RPCs. -The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. +AgentRegistry's standard `inbox` session projection is the sole Web pending-input authority. It folds durable `agent/inbox/spliced` events without consulting a live Agent and carries the raw `next-turn` and `next-step` lists. QueueDock renders `next-turn`, while ChatView renders user-origin `next-step` messages at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. The generic projection carrier supplies live pushes, history-tail reconnect baselines, cold-log folding, and process-restart recovery, so this visibility requires neither client optimism nor a live-only mirror. -When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. +When AgentLoop claims pending steering, its pure deletion splice removes the message from `next-step`. The matching durable `user/message` then enters the ordinary conversation-node projection, which restores the clock and Copy against its logged event time and sequence. No client-side Inbox fold or projection-owned handoff list participates. The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser submission policy owns the live busy-Enter preference while the Host settings service owns durability; the policy resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions, and the Settings row and InputBar share it without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. @@ -44,7 +40,7 @@ The existing `session.prompt(mode: 'steer')` contract remains best-effort for ne AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value and delivery receipt, drains as a `user/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. -Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. Client tests cover silent convergence of both semantic races, genuine error reporting, read-only subagent rows and Queue-only subagent gestures. Runtime and ChatView tests cover occurrence-aware pending-to-durable handoff, including repeated `MessageId` values, while Web ARIA snapshots cover pending steering after the running-status row with Copy alone and the durable node with clock, Copy, and Fork. +Host schema and proxy tests cover the new action, both typed errors, Inbox projection snapshots, reconnect replay, and durable deletion ordering. Client tests cover silent convergence of both semantic races, genuine error reporting, read-only subagent rows, and Queue-only subagent gestures. Runtime and ChatView tests cover direct pending Inbox rendering, while Web ARIA snapshots cover pending steering after the running-status row and the later durable node. The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. It proves the Host-backed pending bubble appears before admission, hands off to one durable interjection after the answer, and affects the next model request. Assembled composer scenarios prove default-mode Cmd+Enter reaches the same pending and durable path without creating a Queue row, while Steer-mode Cmd+Enter creates a Queue row instead. Settings and submission-policy coverage pin the default, persistence, busy-only scope, and complementary gesture mapping; Queue edit/delete scenarios continue to prove those actions are unchanged. @@ -58,14 +54,14 @@ The keyless Web steering scenario queues a message through the real composer whi **Change `agent.steer()` to be strict for every caller.** Rejected because TUI and plugin callers use its safe follow-up fallback for newly submitted input. A queued row has recoverable state that those callers do not. -**Preserve the same `InboxItemId` while changing placement.** Rejected because `InboxItemId` identifies one FIFO acceptance and `placement` records that acceptance's resolved delivery. Ending one queued occurrence and accepting one steering occurrence keeps lifecycle facts truthful and leaves the conservation invariant unchanged. +**Mint a second row identity while changing placement.** Rejected because `MessageId` already addresses the durable pending message across both Inbox lists. Keeping it through the synchronous deletion and insertion lets one projection express the delivery change without an occurrence wrapper. -**Add a dedicated pending-steering projection and client store.** Rejected because queued and steering occurrences already share one Agent inbox lifecycle and one Host mirror. A second projection would duplicate reconnect state and ordering authority; a placement tag lets each client surface select its rows without widening Queue mutation semantics. +**Add a dedicated pending-steering projection and client store.** Rejected because queued and steering messages already share the standard durable `inbox` projection. Each client surface selects the owned list and message sources it presents without duplicating reconnect state or ordering authority. **Cancel the active turn and run the selected Queue item.** Rejected because it destroys unrelated in-flight work and starts a new turn rather than steering the current one. ## Consequences -`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until the durable `user/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so an enabled action may internally return `steer-unavailable` while the product continues through Queue without reporting failure. +`inbox` is the domain-owned durable session projection. Pending steering survives reconnect and process restart and appears immediately from `next-step`; after claim, the durable conversation node follows through the ordinary event projection. The running bit can change between rendering and the synchronous operation, so an enabled action may return `steer-unavailable` while the product continues through Queue without reporting failure. -The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering. +The explicit action changes delivery from an independently admitted turn to current-turn steering. Its two durable splices briefly expose the deletion and insertion as adjacent projection revisions, while client snapshot batching converges on the message moving from `next-turn` to `next-step`. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index ff0567e1b2..2717c8a53b 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -16,27 +16,23 @@ Web composer 原本会在 agent(智能体)运行期间把所有 Enter 提交 普通会话中每个非编辑态的 QueueDock 行都会提供名为「插话发送」的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。已寻址 subagent 的 Queue 投影保持只读,因为其继续执行传输不提供 Queue 变更。 -触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `user/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。 +触发该操作会针对对应的 `MessageId` 请求当前轮次 steering。操作成功后,消息会从 `inbox['next-turn']` 移入 `inbox['next-step']`;QueueDock 移除其行,ChatView 则在 `Deep diving...` 运行状态行之后渲染同一条待处理 steering,提供复制但不提供 fork。AgentLoop 排空该项后,持久删除会移除待处理气泡,现有 `user/message` 事件则进入普通 Conversation Node 路径,并携带时钟与复制操作。 -running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 +running 标志位既是交互提示,也是 Host 的同步准入检查。如果 Agent 已不再运行,操作会保持 Queue 消息不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒消息会经 Queue 继续执行。如果驱动器已经认领该消息,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。Host settings 文档会在共享同一 DSH home 的 Web origin 之间持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 ### Agent 与生命周期边界 -`InboxAction` 会在编辑和移除之外,新增由实际消费方支撑的 `{ kind: 'steer' }` 操作。`Agent.updateInbox()` 只有在找到 queued 单次入队项并确认 `acceptsNextStep` 后才会处理该操作,绝不会委托给尽力而为的 `agent.steer()` 别名。 - -操作成功应用后,系统会结束 queued 单次入队项,并把同一个不可变 `UserMessage` 接受为新的 steering 单次入队项。steering 单次入队项会获得新的 `InboxItemId` 和如实反映投递方式的 `placement: 'steering'`,消息则保留其 `MessageId`、内容、来源和任何待处理 `SteeringReceipt` 投递控制器。AgentLoop 会先安装新的 outbox 项,再发布生命周期事件;随后先发出新单次入队项的 enqueue,再发出旧单次入队项的 discard,确保可重入取消无法观察或退役一个尚未公布的项。因此,现有 inbox 守恒不变量仍然要求每个单次入队项恰好对应一个 enqueue,以及一个终态 dequeue 或 discard。 - -该操作不会运行 `agent/prompt-submit`:选择 steering 会有意把投递方式从经独立接纳的轮次改为当前轮次的 next-step 输入。它既不会取消当前工作,也不会重新排序 Queue 中的剩余项。 +`session.updateQueue` 会在 `Inbox.nextTurn` 中定位 `MessageId`,并要求 Agent 正在运行。随后,它会移除对应消息,并同步把同一个不可变 `UserMessage` 传给 `Agent.steer()`。这两次 Inbox 变更会在没有 await 或第二次 RPC 的情况下,先追加一条 canceled `next-turn` 删除,再追加一条 `next-step` 插入,同时保留消息 id、内容与来源。选择 steering 会把投递方式从经独立接纳的轮次改为当前轮次的 next-step 输入;它既不会取消当前工作,也不会重新排序 Queue 中的剩余项。 ### Host 与客户端边界 -`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 +`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一对同步 Inbox 操作;Host 绝不会通过组合移除与提示词 RPC 来重建它。 -Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个注册表。 +AgentRegistry 的标准 `inbox` 会话投影是唯一的 Web 待处理输入权威。它无需查询 live Agent 即可折叠持久 `agent/inbox/spliced` 事件,并携带原始 `next-turn` 与 `next-step` 列表。QueueDock 渲染 `next-turn`,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染用户来源的 `next-step` 消息,提供复制操作,但不提供 fork、编辑或删除操作。通用投影传输层会提供实时推送、历史尾页的重连基线、冷日志折叠与进程重启恢复,因此这项可见性既不需要客户端乐观展示,也不需要仅适用于 live 状态的镜像。 -AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久会话事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 +AgentLoop 认领待处理 steering 时,对应纯删除 splice 会从 `next-step` 移除消息。匹配的持久 `user/message` 随后进入普通 Conversation Node 投影,并根据已记录的事件时间与序号恢复时钟和复制操作。客户端 Inbox 折叠或投影拥有的交接列表都不参与其中。 现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器提交策略拥有实时繁忙态 Enter 偏好,而 Host settings 服务拥有持久性;该策略只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势,Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 @@ -44,7 +40,7 @@ AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` AgentLoop 约定覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `user/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 -Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖按单次入队项完成的待处理到持久交接,包括重复的 `MessageId` 值;Web ARIA 快照则覆盖位于运行状态行之后且仅有复制的待处理 steering,以及带时钟、复制和 fork 的持久节点。 +Host schema 和代理测试覆盖新操作、两种类型化错误、Inbox 投影快照、重连重放与持久删除顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖直接渲染待处理 Inbox;Web ARIA 快照则覆盖运行状态行之后的待处理 steering 与后续持久节点。 无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。 @@ -58,14 +54,14 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、带 placeme **让每个调用方使用的 `agent.steer()` 都采用严格语义。** 不予采纳,因为 TUI 和插件调用方会针对新提交的输入使用其安全的后续轮次回退。queued 行具有这些调用方不具备的可恢复状态。 -**改变投递方式时保留同一个 `InboxItemId`。** 不予采纳,因为 `InboxItemId` 标识一次 FIFO 接受,而 `placement` 记录该次接受解析出的投递方式。结束一个 queued 单次入队项并接受一个 steering 单次入队项,能够使生命周期事实保持如实,并让守恒不变量保持不变。 +**在改变 placement 时生成第二个行标识。** 不予采纳,因为 `MessageId` 已经可以跨两份 Inbox 列表寻址持久待处理消息。让同步删除与插入保持该标识,即可由同一份投影表达投递变化,无需单次出现包装层。 -**增加专用的待处理 steering 投影和客户端 store。** 不予采纳,因为 queued 与 steering 单次入队项已经共享同一套 Agent inbox 生命周期和 Host mirror。第二份投影会重复保存重连状态与顺序权威;placement 标签能让各客户端界面选取自己的行,而不扩大 Queue 变更语义。 +**增加专用的待处理 steering 投影和客户端 store。** 不予采纳,因为 queued 与 steering 消息已经共享标准持久 `inbox` 投影。各客户端界面选取自己呈现的列表与消息来源,无需重复重连状态或顺序权威。 **取消活动轮次并运行选中的 Queue 项。** 不予采纳,因为这会破坏无关的进行中工作,并且会启动新轮次,而不是 steering 当前轮次。 ## 后果 -`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在持久 `user/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此已启用的操作可能会在内部返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 +`inbox` 是领域拥有的持久会话投影。待处理 steering 会从 `next-step` 立即出现,能在重连与进程重启后恢复;认领后,持久会话节点经普通事件投影跟进。running 标志位可能在渲染与同步操作之间发生变化,因此已启用的操作可能返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 -这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序。 +这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering。它的两条持久 splice 会短暂地把删除与插入暴露为相邻投影修订,而客户端快照批处理会收敛到消息从 `next-turn` 移入 `next-step`。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index e7612e322b..27aea1b8c9 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: c51e837d37b61a73f9602445daea9fbdfc77a299 -2026-08-06-web-queue-steer-all-gesture.zh.md: b0009a7a51ebe89f61d1a4b995b1138a7247d266 +2026-08-06-web-queue-steer-all-gesture.md: c068a5c2ce234353e6faf3971c1118774d666bfe +2026-08-06-web-queue-steer-all-gesture.zh.md: eb91889ee5433765ce03013f81b0d9aedbc01fb6 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index c51e837d37..c068a5c2ce 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -10,7 +10,7 @@ While a primary session runs, the Web queue accumulates messages the user typed ## Decision -Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary. +Empty-draft Cmd/Ctrl+Enter now steers every still-pending `next-turn` Inbox message into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one queued message calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads `inbox['next-turn']` and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No on-disk or agent-loop change: the host already owns the strict-steer operation. The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. @@ -30,4 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. -- **A send-button tooltip.** Rejected: the primary button is Stop in the empty-draft running window, which is also the only window where the whole-queue gesture is available. The placeholder occupies that exact window and can describe the keyboard action directly. +- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index b0009a7a51..eb91889ee5 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。 +空草稿的 Cmd/Ctrl+Enter 现在会把仍在 `next-turn` 的 Inbox 消息按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条排队消息时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取 `inbox['next-turn']`,并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无磁盘或 agent-loop 改动:严格 steer 操作本来就在 host 侧。 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 @@ -30,4 +30,4 @@ Status: implemented - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 -- **发送按钮 tooltip。** 已拒绝:主按钮在空草稿的运行窗口内是 Stop,而这也正是整队列手势唯一可用的窗口。placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 +- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 1ee7b9dfc3..8cf2e53343 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 -2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a +2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17 +2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 8da6c2fd91..4b8396c1c0 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -81,7 +81,7 @@ Four rules the carrier keeps: `SessionListState` carries `jobsBySession: Readonly>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation. -It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. +It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, no composer behavior depends on tasks, and a later sidebar indicator gets the data without opening a second channel. Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 0e05ef9d2f..03a43b87c4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -81,7 +81,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void `SessionListState` 带有 `jobsBySession: Readonly>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。 -它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 +它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。 两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 65012cee1c..a992eedee8 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: ab83d73983261f4f92fa4a88521855eb79865ef6 -2026-07-27-session-projection-and-command-log.zh.md: b8b6ec6fd673537d41609b5082af287cafad337b +2026-07-27-session-projection-and-command-log.md: bf2a61f2bfea44e06a96cccf6aa7a56a4e4765d5 +2026-07-27-session-projection-and-command-log.zh.md: 900423a9893f50fbd30f4ff1f912ac202098ff32 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index ab83d73983..bf2a61f2bf 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -65,7 +65,7 @@ declare module 'cordis' { The registry's two read faces serve [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md): a `subagent` unit folds durable mode/label identity last-wins from `subagent/descriptor`, and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child and `restore({}, events, 0)` over one persistence inspection for a cold one. An absent value remains that consumer's signal, and how absence renders is its own decision. -The Agent registry contributes Inbox's operation-folding unit whenever the projection registry is composed. Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner. +The Agent registry contributes Inbox's operation-folding unit whenever the projection registry is composed, as recorded by [durable Web queue recovery](../../implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md). Like other units, it folds the complete session log, including inherited fork seed events, while preserving one projection state owner. ### Wire: projections block on the history tail page diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index b8b6ec6fd6..900423a989 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -65,7 +65,7 @@ declare module 'cordis' { 注册表的两处读取接口服务于[通过投影单元读取 subagent 列表身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md):`subagent` 单元从 `subagent/descriptor` 按 last-wins 规则折叠持久化的 mode/label 身份,`SubagentRuntime.listChildren` 对 live child 通过 `snapshot()` 读取,对 cold child 则通过一次持久化检查结果调用 `restore({}, events, 0)` 读取。值缺席仍是该消费方的信号,如何呈现缺席仍由消费方自行决定。 -Agent 注册表会在投影注册表已组合时贡献 Inbox 的操作折叠单元。该单元与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。 +Agent 注册表会在投影注册表已组合时贡献 Inbox 的操作折叠单元,详见[持久化 Web Queue 恢复](../../implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md)。该单元与其他单元一样折叠完整会话日志,包括 fork 继承的 seed 事件,同时维持唯一的投影状态所有者。 ### 协议层:历史尾页上的 projections 块 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 5c561d9188..cfebd17618 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: dbf73976b606a4a45202c1b29b79f5cfbd77ac1c -2026-08-04-task-surface.zh.md: ecf8764b7b1da7a4a874dd78c6f69df80fb36f36 +2026-08-04-task-surface.md: 6bc8a60b1c0cf03bcdf40f8fbfeb4a3768b1fd42 +2026-08-04-task-surface.zh.md: 184adb537f23f57728d6accf229f3ff7727be4b6 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index dbf73976b6..6bc8a60b1c 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource { } ``` -The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation: - -```ts ignore-check -interface QueuedMessage { - id: InboxItemId - messageId: MessageId - placement: 'queued' | 'steering' - source: MessageSource - content: readonly ContentBlock[] - preview: string - text: string | null -} -``` +The standard `inbox` projection already carries each complete `UserMessage`, including its `MessageId`, source, and content, in the raw `next-turn` or `next-step` list. The client retains that value in its generic projection store, so this proposal needs no queue transport extension or second pending-message type. The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction. @@ -198,7 +186,7 @@ The product shell owns collapse and dismiss. Collapse is local view state and se Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface. -The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard. +The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent claims that message for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before the durable deletion splice removes it from the generic `inbox` projection. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard. The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index ecf8764b7b..184adb537f 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource { } ``` -`session/queue` 协议条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息: - -```ts ignore-check -interface QueuedMessage { - id: InboxItemId - messageId: MessageId - placement: 'queued' | 'steering' - source: MessageSource - content: readonly ContentBlock[] - preview: string - text: string | null -} -``` +标准 `inbox` 投影已经在原始 `next-turn` 或 `next-step` 列表中携带每条完整 `UserMessage`,包括 `MessageId`、source 与 content。客户端会把该值保存在通用 projection store 中,因此本提案不需要扩展 queue 传输,也不需要第二种待处理消息类型。 浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 @@ -198,7 +186,7 @@ interface QueuedMessage { 客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中,Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。 -Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。 +Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 为普通提示词接纳认领该消息时,服务会先同步把同一份待处理记录改为 `claiming`,随后持久删除 splice 才会从通用 `inbox` 投影移除该消息。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。 匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose(资源释放)时,系统会报告丢弃、清除认领状态,并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive`:`pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态,不是第二个持久权威来源;Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。 diff --git a/apps/web/tests/cold-blank-session.e2e.ts b/apps/web/tests/cold-blank-session.e2e.ts index b9e64960d3..dc4638849f 100644 --- a/apps/web/tests/cold-blank-session.e2e.ts +++ b/apps/web/tests/cold-blank-session.e2e.ts @@ -8,15 +8,18 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedBlankSession, - watchConsole, webSnapshotMode, type WebScaffold, + seedQueuedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/cold-blank-session', import.meta.url)) const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') +const QUEUE_EXPECTED = join(SNAPSHOT_DIR, 'queue.expected.md') const MODE = webSnapshotMode() const SESSION_ID = 'cold-blank-session-web-e2e' const WORKSPACE_NAME = 'cold-blank-workspace' +const QUEUED_SESSION_ID = 'cold-queue-recovery-web-e2e' +const PENDING_TEXT = 'Accepted before the Host restarted' describe('web e2e: cold blank Session visibility', () => { let scaffold: WebScaffold @@ -29,6 +32,8 @@ describe('web e2e: cold blank Session visibility', () => { const cwd = join(scaffold.workspaceCwd, WORKSPACE_NAME) await mkdir(cwd, { recursive: true }) await seedBlankSession(scaffold, SESSION_ID, cwd) + const queuedSessionId = await seedQueuedSession(scaffold, QUEUED_SESSION_ID, PENDING_TEXT) + expect(scaffold.ctx.agents.get(queuedSessionId)).toBeUndefined() const header = (await scaffold.ctx.sessionPersistence.list()) .find(candidate => candidate.id === SESSION_ID) if (header === undefined) throw new Error('blank Session fixture did not materialize') @@ -57,4 +62,19 @@ describe('web e2e: cold blank Session visibility', () => { await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) expect(tripwire.pageErrors).toEqual([]) }) + + it('restores an accepted Queue row from the cold durable projection', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-queue-recovery')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + const dock = page.locator('[data-queue-dock]') + await dock.getByText(PENDING_TEXT, { exact: true }).waitFor({ timeout: 15_000 }) + const snapshot = await captureStableAria(page, '[data-queue-dock]', scaffold.workspaceCwd) + await compareOrRefreshGolden(QUEUE_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }) }) diff --git a/apps/web/tests/expected/cold-blank-session/queue.expected.md b/apps/web/tests/expected/cold-blank-session/queue.expected.md new file mode 100644 index 0000000000..9fcb11cde6 --- /dev/null +++ b/apps/web/tests/expected/cold-blank-session/queue.expected.md @@ -0,0 +1,9 @@ +- list: + - listitem: + - text: Accepted before the Host restarted + - button "Edit queued message": + - img + - button "Remove queued message": + - img + - button "Steer queued message" [disabled]: + - img diff --git a/apps/web/tests/expected/cold-blank-session/sidebar.expected.md b/apps/web/tests/expected/cold-blank-session/sidebar.expected.md index d4d370892e..0f956d3a72 100644 --- a/apps/web/tests/expected/cold-blank-session/sidebar.expected.md +++ b/apps/web/tests/expected/cold-blank-session/sidebar.expected.md @@ -1 +1,4 @@ -- tree "Sessions": No sessions yet +- tree "Sessions": + - treeitem "Ungrouped": + - img + - text: Ungrouped diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6e89812da7..ebba300484 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -58,7 +58,7 @@ import { } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' @@ -1064,6 +1064,41 @@ export async function seedBlankSession( return meta.id } +/** Seed one detached non-blank Session with accepted next-turn input still pending. */ +export async function seedQueuedSession( + scaffold: WebScaffold, + id: string, + text: string, +): Promise { + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: SessionId(id), + createdAt: Date.now() - 60_000, + cwd: scaffold.workspaceCwd, + delegationDepth: 0, + } + const pending = createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }) + await persistSeedSession(scaffold, meta, [ + { type: 'turn/start', seq: 0, time: meta.createdAt, data: { turn: 1 } }, + { + type: 'turn/end', + seq: 1, + time: meta.createdAt + 1, + data: { turn: 1, reason: { kind: 'blocked' } }, + }, + { + type: 'agent/inbox/spliced', + seq: 2, + time: meta.createdAt + 2, + data: { target: 'next-turn', start: 0, inserted: [pending] }, + }, + ]) + return meta.id +} + /** Materialize one detached Session fixture through the shipped JSONL provider. */ async function persistSeedSession( scaffold: WebScaffold, diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 41f42aaf31..6a441f9205 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -18,10 +18,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// Two goldens pin the transient Host projection and its durable handoff: the -// mid-turn state renders accepted steering from the Session control queue while the -// question blocks admission, then the settled state renders the same message -// from user/message beside the reply that obeys it. +// Two goldens pin both durable surfaces: the mid-turn state renders accepted +// steering from inbox.next-step while the question blocks admission, then the +// settled state renders its user/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0b215ee0bb..15bb324313 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: 4bc7cfa29b7c32b5e71705096fa8b02a0c7b0adc -event-producer-consumer.zh.md: 6a22d7b6cb152195e3fd3f2eab35b4142a7beb22 +event-producer-consumer.md: 1153531e1a78d318a254732c1219e0fba883c197 +event-producer-consumer.zh.md: e527c7154db214477f5ed0c5086fd099041d48ea diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4bc7cfa29b..1153531e1a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../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:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6a22d7b6cb..e527c7154d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `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:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:475`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../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:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index dcdfebabd6..961df5cf7d 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: 6cae1d4a3a4be04aefd473f0af4cdf2bc86b3cbd -README.zh.md: 48004caa160764a421ccc5d1d7c9122a2a722e86 +README.md: 396b679edc4d496c77c249dbf3627e27dab0fcc3 +README.zh.md: e248aa29153fdea9b58199987309428d1a8a571a diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 6cae1d4a3a..396b679edc 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa `session.updateQueue` resolves the ordinary Session's Agent before locating the addressed `MessageId`, so an edit or removal can mutate a pending row reconstructed from the durable Inbox projection after a Host restart. A missing durable Session, including a Host without persistence, maps to `queue-item-not-found`; other resume failures retain their typed error, and subagent-owned identities remain fenced. Successful mutations append the standard `agent/inbox/spliced` event through the Agent's Inbox. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. The generic projection carrier delivers the domain-owned `inbox` value for live Agents and cold persisted Sessions; it has no dedicated queue frame or live-Agent queue mirror. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 48004caa16..e248aa2915 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `session.updateQueue` 会先解析普通 Session 的 Agent,再定位被寻址的 `MessageId`,因此 Host 重启后,编辑或移除操作仍可修改从持久 Inbox 投影重建的待处理行。持久 Session 不存在时(包括 Host 未组装 persistence 后端)会映射为 `queue-item-not-found`;其他恢复失败保留其类型化错误,由 subagent 拥有的 identity 仍会被拒绝。成功的变更通过 Agent 的 Inbox 追加标准 `agent/inbox/spliced` 事件。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。通用 projection carrier 会为 live Agent 和冷持久 Session 投递由领域拥有的 `inbox` 值;系统没有专用 queue frame,也没有枚举 live Agent 的 queue mirror。 ----- diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 3f5f4ebd1c..bfe6a00c52 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -1,20 +1,8 @@ /** Session-owned observable state excluding Conversation target data. */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { ClientFailure } from './result.ts' -/** One transient inbox occurrence from the authoritative queue snapshot. */ -export interface QueuedMessage { - readonly id: MessageId - readonly messageId: MessageId - readonly placement: 'queued' | 'steering' | 'context' - readonly content: readonly ContentBlock[] - readonly preview: string - readonly text: string | null -} - /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' @@ -27,7 +15,6 @@ export interface PromptError { /** Immutable Session lifecycle and control snapshot. */ export interface SessionSnapshot { readonly sessionId: SessionId - readonly queue: readonly QueuedMessage[] readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 10a10261bf..a993d52349 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -54,7 +54,6 @@ export type { export type { OpenState, PromptError, - QueuedMessage, SessionSnapshot, } from './contract/snapshot.ts' export type { ClientFailure, ClientResult } from './contract/result.ts' diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 9ad6d88f96..f0426a3ea0 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -8,7 +8,6 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, SessionControlFrame, - SessionQueuedItem, SessionError, SessionSummary, SessionJob as JobView, @@ -93,8 +92,6 @@ export class SessionManager { private readonly sessions = new Map() /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ private readonly sessionDisposals = new Set>() - /** Latest transient queues, retained independently of Session object materialization. */ - private readonly queues = new Map() /** * Sessions that finished running while not selected — the sidebar's green * "done" reminder (manager-owned, survives connection generations; cleared @@ -287,11 +284,6 @@ export class SessionManager { if (session === undefined) { session = this.createSession(sessionId) this.sessions.set(sessionId, session) - // Install the latest control baseline before the running-bit sync: a - // not-running summary must sweep replayed queue - // rows the same way a live status flip would (their retirement events dropped - // while the session was uninstantiated). - session.replaceControl(this.queues.get(sessionId) ?? []) // Sync the running and blank bits from the list snapshot into the new // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) @@ -679,22 +671,12 @@ export class SessionManager { this.notifier.markDirty() return } - if (frame.type === 'jobs') { - if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) - else this.jobsBySession.set(frame.sessionId, frame.jobs) - this.notifier.markDirty() - return - } - this.queues.set(frame.sessionId, frame.items) - this.sessions.get(frame.sessionId)?.handleControlFrame(frame) + if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) + else this.jobsBySession.set(frame.sessionId, frame.jobs) + this.notifier.markDirty() } private replaceControlBaseline(baseline: SessionControlBaseline): void { - this.queues.clear() - for (const [sessionId, items] of Object.entries(baseline.queues)) { - this.queues.set(sessionId as SessionId, items) - } - this.jobsBySession.clear() for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) @@ -705,9 +687,6 @@ export class SessionManager { store.truncate(block.asOfSeq) store.seed(block) } - for (const [sessionId, session] of this.sessions) { - session.replaceControl(this.queues.get(sessionId) ?? []) - } this.notifier.markDirty() } @@ -747,7 +726,6 @@ export class SessionManager { this.updateCatalogActivity(sessionId, false) if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) else this.sessions.get(sessionId)?.handleRemoved() - this.queues.delete(sessionId) this.jobsBySession.delete(sessionId) if (!durableSubagent) this.projectionStores.delete(sessionId) const inflightCatalog = this.catalogInflight.get(sessionId) diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts deleted file mode 100644 index 209349af2c..0000000000 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionQueuedItem } from '../../types.ts' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { QueuedMessage } from '../contract/snapshot.ts' - -const QUEUE_PREVIEW_CHARS = 200 - -function previewOf(content: readonly ContentBlock[]): string { - const flat = content - .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) - .join(' ').replace(/\s+/g, ' ').trim() - const chars = Array.from(flat) - return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat -} - -function textOf(content: readonly ContentBlock[]): string | null { - if (!content.every(block => block.type === 'text')) return null - return content.map(block => block.text).join('') -} - -type QueueItems = readonly SessionQueuedItem[] - -/** Authoritative transient queue projection and durable steering handoff. */ -export class SessionQueueMirror { - private current: readonly QueuedMessage[] = [] - - /** - * Return the current immutable queue projection. - * @returns current queue rows. - */ - snapshot(): readonly QueuedMessage[] { - return this.current - } - - /** - * Replace from one authoritative stream queue frame. - * @param items - complete host queue snapshot. - */ - replace(items: QueueItems): void { - this.current = items.map((item) => { - const content = item.message.content as unknown as readonly ContentBlock[] - return { - id: item.id, - messageId: item.message.id, - placement: item.placement, - content, - preview: previewOf(content), - text: textOf(content), - } - }) - } - - /** - * Retire a transient steering row once its durable message enters the log. - * @param event - newly contiguous durable Session event. - * @returns whether the projection changed. - */ - acceptDurable(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const messageId = event.data.id - const index = this.current.findIndex(item => - item.placement === 'steering' && item.messageId === messageId) - if (index < 0) return false - this.current = this.current.filter((_item, candidate) => candidate !== index) - return true - } -} diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index db7cf9a997..65cce0d982 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -15,8 +15,6 @@ import type { PromptContentPart, QueueAction, SessionAddress, - SessionControlFrame, - SessionQueuedItem, SessionRequestId, SessionError, } from '../../types.ts' @@ -36,7 +34,6 @@ import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' import { resolvedClientTimeZone } from '../time-zone.ts' -import { SessionQueueMirror } from './queue-mirror.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 @@ -81,8 +78,6 @@ export class Session implements SessionFace { * passes drop all writes once the generation moves on. */ private openGeneration = 0 private loadingOlder = false - /** Authoritative stream-only inbox snapshot; pending work never hits history. */ - private readonly queueMirror = new SessionQueueMirror() private running = false private address: SubagentAddress | undefined private parentAvailable: boolean | undefined @@ -393,7 +388,7 @@ export class Session implements SessionFace { } /** Rebuild an opened history source after address replacement. - * Invalidates any in-flight open first; queue state belongs to the independently + * Invalidates any in-flight open first; projection state belongs to the independently * reconnecting control stream and remains untouched. */ async resync(): Promise { if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) @@ -431,24 +426,6 @@ export class Session implements SessionFace { // ---- Manager-only entry points (@internal; never called by the UI) ---- - /** - * Replace every transient control value for this Session from one stream baseline. - * @param queue - complete pending queue for this Session. - */ - replaceControl(queue: readonly SessionQueuedItem[]): void { - this.queueMirror.replace(queue) - this.notifier.markDirty() - } - - /** - * Apply one Session-addressed live control update. - * @param frame - queue replacement addressed to this Session. - */ - handleControlFrame(frame: Extract): void { - this.queueMirror.replace(frame.items) - this.notifier.markDirty() - } - /** * Running-bit relay from the host stream (list entry and snapshot stay consistent). * @param running - the new running state. @@ -599,9 +576,8 @@ export class Session implements SessionFace { const event = entry.event const awaitingFirstTurn = this.firstPromptPendingTurn if (event.type === 'turn/start') this.firstPromptPendingTurn = false - const queueChanged = this.queueMirror.acceptDurable(event) this.eventSource.append(entry) - return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn + return awaitingFirstTurn !== this.firstPromptPendingTurn } /** Publish a terminal background failure only while this stream still owns the Session. */ @@ -619,7 +595,6 @@ export class Session implements SessionFace { private buildSnapshot(): SessionSnapshot { return { sessionId: this.sessionId, - queue: this.queueMirror.snapshot(), running: this.running, subagent: this.address === undefined ? null diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 31fb2ff756..ddad06acab 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,7 +1,7 @@ -/** Live Session queue, jobs, and projection state with reconnect baselines. */ +/** Live Session jobs and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent, InboxState } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { JsonValue, Session, SessionId, @@ -12,7 +12,6 @@ import type { SessionJob, SessionProjectionBaseline, SessionProjectionValues, - SessionQueuedItem, } from './types.ts' /** Owns the Host-wide Session control stream. */ @@ -30,14 +29,6 @@ export class SessionControlController { value: value as JsonValue, seq, }) - if (key !== 'inbox') return - const agent = this.ctx.agents.get(session.id) - if (agent?.session !== session) return - this.broadcast({ - type: 'queue', - sessionId: session.id, - items: queueItemsFromInbox(value as InboxState), - }) }) }) ctx.inject(['jobs'], (jobsCtx) => { @@ -73,15 +64,12 @@ export class SessionControlController { private baseline(): SessionControlBaseline { const sessions = this.ctx.sessions.list() - const queues = Object.create(null) as Record const jobs = Object.create(null) as Record for (const session of sessions) { const agent = this.ctx.agents.get(session.id) - queues[session.id] = agent?.session === session ? queueItems(agent) : [] jobs[session.id] = this.jobsFor(agent) } return { - queues, jobs, projections: this.projectionBaseline(sessions), } @@ -170,28 +158,6 @@ class ControlQueue { } } -function queueItems(agent: Agent): SessionQueuedItem[] { - return queueItemsFromInbox({ - 'next-turn': agent.inbox.nextTurn, - 'next-step': agent.inbox.nextStep, - }) -} - -function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] { - return [ - ...inbox['next-turn'].map(message => ({ - id: message.id, - placement: 'queued' as const, - message: { id: message.id, content: message.content as unknown as JsonValue[] }, - })), - ...inbox['next-step'].map(message => ({ - id: message.id, - placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, - message: { id: message.id, content: message.content as unknown as JsonValue[] }, - })), - ] -} - function jobView(job: JobSnapshot): SessionJob { return { id: job.id, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c045e00707..e79fe74c1b 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -428,17 +428,6 @@ export type SessionFollowFrame = } | SessionEventEntry -/** One pending inbox occurrence in the authoritative queue snapshot. */ -export interface SessionQueuedItem { - readonly id: MessageId - readonly placement: 'queued' | 'steering' | 'context' - /** JSON-safe message fields consumed by pending-queue presentation. */ - readonly message: { - readonly id: MessageId - readonly content: readonly JsonValue[] - } -} - /** Browser-safe background-job row. */ export interface SessionJob { readonly id: JobId @@ -452,7 +441,6 @@ export interface SessionJob { /** Complete live control baseline emitted once per control stream generation. */ export interface SessionControlBaseline { - readonly queues: Readonly> readonly jobs: Readonly> readonly projections: Readonly> } @@ -468,7 +456,6 @@ export interface SessionProjectionUpdate { /** Host-wide live state stream. Each generation starts with exactly one baseline. */ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } - | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] } | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 876786a759..731e698850 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -153,7 +153,7 @@ describe('Session Controller Client apply', () => { expect(accept).toHaveBeenCalledWith({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { jobs: {}, projections: {} }, }) bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts deleted file mode 100644 index 5a7d7ce166..0000000000 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { describe, expect, it } from 'vitest' -import { SessionControlController } from '../src/control.ts' -import type { SessionControlFrame } from '../src/types.ts' - -async function harness(): Promise<{ - ctx: Context - control: SessionControlController - agent: Agent - inbox: Inbox -}> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) - const session = ctx.sessions.create(SessionId('queue-session')) - const agent = { id: session.id, session, inbox: undefined as never, status: 'running', ctx } as unknown as Agent - Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) - ctx.agents.register(agent) - return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox } -} - -function message(text: string, source: 'user' | 'plugin' = 'user') { - return createUserMessage({ - content: [{ type: 'text', text }], - source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' }, - }) -} - -describe('Session control queue projection', () => { - /** Consume frames until the next queue replacement (inbox projection frames interleave). */ - async function nextQueueFrame( - iterator: AsyncIterator, - ): Promise> { - for (;;) { - const next = await iterator.next() - if (next.done) throw new Error('stream ended before a queue frame') - if (next.value.type === 'queue') return next.value - } - } - - it('projects both pending lists in baselines and live replacement frames', async () => { - const { control, inbox } = await harness() - const queued = message('queued') - const steering = message('steering') - const context = message('context', 'plugin') - inbox.append('next-turn', queued) - inbox.append('next-step', steering) - inbox.append('next-step', context) - - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - const opened = await iterator.next() - expect(opened.value).toMatchObject({ - type: 'baseline', - value: { - queues: { - 'queue-session': [ - { id: queued.id, placement: 'queued' }, - { id: steering.id, placement: 'steering' }, - { id: context.id, placement: 'context' }, - ], - }, - }, - }) - - const replacement = message('replacement') - inbox.append('next-turn', replacement) - const replaced = await nextQueueFrame(iterator) - expect(replaced.items.map(item => item.id)).toContain(replacement.id) - inbox.remove(steering.id) - const removed = await nextQueueFrame(iterator) - expect(removed.items.map(item => item.id)).not.toContain(steering.id) - - abort.abort() - await iterator.next() - }) - - it('derives queue replacements from the completed projection regardless of registration order', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const control = new SessionControlController(ctx) - await ctx.plugin(SessionProjectionRegistry) - const session = ctx.sessions.create(SessionId('late-projection-queue')) - const agent = { id: session.id, session, inbox: undefined as never, status: 'running', ctx } as unknown as Agent - Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) }) - ctx.agents.register(agent) - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await iterator.next() - const pending = message('late projection') - - agent.inbox.append('next-turn', pending) - - await expect(iterator.next()).resolves.toMatchObject({ - value: { - type: 'projection', - key: 'inbox', - value: { 'next-turn': [{ id: pending.id }], 'next-step': [] }, - }, - }) - await expect(nextQueueFrame(iterator)).resolves.toMatchObject({ - items: [{ id: pending.id, placement: 'queued' }], - }) - - abort.abort() - await iterator.next() - }) - - it('ignores inbox events without the exact live Agent session', async () => { - const { ctx, control, agent, inbox } = await harness() - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await iterator.next() - - const unrelated = ctx.sessions.create(SessionId('unrelated-queue')) - unrelated.append('agent/inbox/spliced', { - target: 'next-turn', - start: 0, - inserted: [message('unrelated')], - }) - const replacement = ctx.sessions.create(SessionId('replacement-session')) - Object.defineProperty(agent, 'session', { configurable: true, value: replacement }) - inbox.append('next-turn', message('wrong-session')) - - abort.abort() - await iterator.next() - }) - - it('drops broadcasts after cancellation has ended its queue', async () => { - const { control, inbox } = await harness() - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await iterator.next() - const waiting = iterator.next() - await Promise.resolve() - - abort.abort() - inbox.append('next-turn', message('late')) - - await expect(waiting).resolves.toMatchObject({ done: true }) - }) - - it('ends active streams on context disposal after flushing buffered frames', async () => { - const { ctx, control, inbox } = await harness() - const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]() - await iterator.next() - const first = message('first') - const second = message('second') - inbox.append('next-turn', first) - inbox.append('next-turn', second) - - const queues: Extract[] = [] - await ctx.fiber.dispose() - for (;;) { - const next = await iterator.next() - if (next.done) break - if (next.value.type === 'queue') queues.push(next.value) - } - expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]]) - }) -}) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index a0eae81529..9ddbab6651 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -176,7 +176,6 @@ export class FakeApiClient implements IApiClient { /** Optional Host opening cursor override for stale-page and reconnect tests. */ followCursor: number | undefined controlBaseline: SessionControlBaseline = { - queues: {}, jobs: {}, projections: {}, } diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 8b311de746..e3ca7dd72a 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -185,7 +185,7 @@ describe('list lifecycle', () => { frame({ type: 'baseline', value: { - queues: {}, jobs: {}, + jobs: {}, projections: { [S1]: { asOfSeq: 2, values: {} } }, }, }) @@ -198,7 +198,7 @@ describe('list lifecycle', () => { frame({ type: 'baseline', value: { - queues: {}, jobs: {}, + jobs: {}, projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, }, }) @@ -967,7 +967,7 @@ describe('background-job mirror', () => { manager.handleControlFrame(tasksFrame(S1, [view()])) manager.handleControlFrame({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { jobs: {}, projections: {} }, }) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 64ee9d66f8..0e0d281692 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -171,7 +171,7 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - queues: {}, jobs: {}, + jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, }, }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 6fc045cf70..6e008f0f07 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -1,287 +1,100 @@ -/** - * Queue snapshot semantics: authoritative replacement after every host-side - * change, reconnect re-baselining, pre-instantiation buffering, editable-text - * projection, and snapshot reference stability. - */ -import { describe, expect, it, vi } from 'vitest' +/** Inbox projection delivery and queue-operation transport. */ + +import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' -import { Session } from '../src/client/sessions/session.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' const SID = 'fk-q1' as SessionId const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] -const rid = (id: string): RpcId => id as RpcId -const iid = (id: string): MessageId => id as MessageId -interface QueueFixture { - id: string - body: string - content?: ContentBlock[] - placement?: 'queued' | 'steering' - message?: UserMessage +let nextSeq = 1 + +function message(label: string, body: string) { + return createUserMessage({ + content: text(body), + source: { kind: 'user', rpcId: `rpc-${label}` } as never, + }) } -/** Build one authoritative queue snapshot. */ -function queueFrame(items: QueueFixture[]): Extract { +function inboxFrame(value: InboxState): Extract { return { - type: 'queue', + type: 'projection', sessionId: SID, - items: items.map(item => ({ - id: iid(item.id), - placement: item.placement ?? 'queued', - message: (item.message ?? createUserMessage({ - content: item.content ?? text(item.body), - source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, - })) as never, - })), + key: 'inbox', + seq: nextSeq++, + value: value as never, } } -function makeSession(): Session { - return makeBench().session -} +describe('Inbox projection intake', () => { + it('stores the complete Agent-owned value without adding queue state to the Session snapshot', () => { + const manager = new SessionManager(fakeRemote(new FakeApiClient())) + const queued = message('queued', 'later') + const steering = message('steering', 'now') + const value = { 'next-turn': [queued], 'next-step': [steering] } -function makeBench(): { api: FakeApiClient; session: Session } { - const api = new FakeApiClient() - return { api, session: new Session(SID, fakeRemote(api)) } -} + manager.handleControlFrame(inboxFrame(value)) + const session = manager.get(SID) -function makeManager(): SessionManager { - const api = new FakeApiClient() - return new SessionManager(fakeRemote(api)) -} + expect(session.projections.faceOf('inbox').getSnapshot()).toEqual(value) + expect(session.getSnapshot()).not.toHaveProperty('queue') + }) -describe('Session queue snapshot intake', () => { - it('projects stable ids, flat previews, and complete text', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([ - { id: 'q-1', body: '第一条 排队\n消息' }, - ])) - const queue = session.getSnapshot().queue - expect(typeof queue[0]?.messageId).toBe('string') - expect(queue).toMatchObject([ - { - id: 'q-1', placement: 'queued', - content: [{ type: 'text', text: '第一条 排队\n消息' }], - preview: '第一条 排队 消息', text: '第一条 排队\n消息', + it('drops a projection that claims events beyond a reconnect baseline', () => { + const manager = new SessionManager(fakeRemote(new FakeApiClient())) + manager.handleControlFrame(inboxFrame({ + 'next-turn': [message('stale', 'stale')], + 'next-step': [], + })) + + manager.handleControlFrame({ + type: 'baseline', + value: { + jobs: {}, + projections: { [SID]: { asOfSeq: 0, values: {} } }, }, - ]) - }) - - it('marks mixed-content messages non-editable while retaining their preview', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ - id: 'q-image', - body: '', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], - }])) - const queue = session.getSnapshot().queue - expect(typeof queue[0]?.messageId).toBe('string') - expect(queue).toMatchObject([ - { - id: 'q-image', placement: 'queued', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], - preview: 'hi [image]', text: null, - }, - ]) - }) - - it('caps previews at 200 code points and preserves the full editable text', () => { - const session = makeSession() - const body = '长'.repeat(201) - session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) - const row = session.getSnapshot().queue[0] - expect(Array.from(row?.preview ?? '')).toHaveLength(201) - expect(row?.preview.endsWith('…')).toBe(true) - expect(row?.text).toBe(body) - }) - - it('replaces content, order, and membership from each authoritative frame', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([ - { id: 'q-1', body: 'one' }, - { id: 'q-2', body: 'two' }, - ])) - session.handleControlFrame(queueFrame([ - { id: 'q-2', body: 'two edited' }, - ])) - const queue = session.getSnapshot().queue - expect(typeof queue[0]?.messageId).toBe('string') - expect(queue).toMatchObject([ - { - id: 'q-2', placement: 'queued', - content: [{ type: 'text', text: 'two edited' }], - preview: 'two edited', text: 'two edited', - }, - ]) - session.handleControlFrame(queueFrame([])) - expect(session.getSnapshot().queue).toEqual([]) - }) - - it('keeps the queue array reference stable across unrelated snapshot swaps', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) - const before = session.getSnapshot().queue - session.handleAgentError('unrelated') - expect(session.getSnapshot().queue).toBe(before) - }) - - it('retains steering placement and complete content in the same authoritative snapshot', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([ - { id: 'q-next', body: 'later' }, - { id: 's-now', body: 'interrupt now', placement: 'steering' }, - ])) - - expect(session.getSnapshot().queue.map(item => ({ - id: item.id, placement: item.placement, content: item.content, - }))).toEqual([ - { id: 'q-next', placement: 'queued', content: text('later') }, - { id: 's-now', placement: 'steering', content: text('interrupt now') }, - ]) - }) - - it('hands off exactly one current occurrence when live steering becomes durable', async () => { - const { api, session } = makeBench() - await session.open() - const message = createUserMessage({ - content: text('same message'), - source: { kind: 'user' }, - }) - session.handleControlFrame(queueFrame([ - { id: 's-first', body: '', placement: 'steering', message }, - { id: 's-second', body: '', placement: 'steering', message }, - ])) - const durable = { - seq: 0, - time: 1_700_000_000_000, - type: 'user/message', - surfaceOp: 'append', - data: message, - } as SessionEvent - - await api.pushFollow(SID, { type: 'event', event: durable as never }) - await vi.waitFor(() => { - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) }) - session.handleControlFrame(queueFrame([ - { id: 's-later', body: '', placement: 'steering', message }, - ])) - await api.pushFollow(SID, { type: 'event', event: durable as never }) - await vi.waitFor(() => { - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) - }) + expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toBeUndefined() }) - it('hands off live steering when the agent claims it as a user message', async () => { - const { api, session } = makeBench() - await session.open() - const message = createUserMessage({ - content: text('claimed steering'), - source: { kind: 'user' }, - }) - session.handleControlFrame(queueFrame([ - { id: 's-claimed', body: '', placement: 'steering', message }, - ])) + it('retains only the highest-seq value received before Session materialization', () => { + const manager = new SessionManager(fakeRemote(new FakeApiClient())) + manager.handleControlFrame(inboxFrame({ + 'next-turn': [message('old', 'old')], + 'next-step': [], + })) + const latest = { + 'next-turn': [message('latest', 'latest')], + 'next-step': [], + } + manager.handleControlFrame(inboxFrame(latest)) - await api.pushFollow(SID, { - type: 'event', - event: { - seq: 0, - time: 1_700_000_000_000, - type: 'user/message', - surfaceOp: 'append', - data: message, - } as never, - }) - - await vi.waitFor(() => { - expect(session.getSnapshot().queue).toEqual([]) - }) + expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toEqual(latest) }) }) describe('queue operation transport', () => { - it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { + it('does not mutate the Inbox projection before the Host publishes its committed value', async () => { const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) - session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) - const before = session.getSnapshot().queue + const manager = new SessionManager(fakeRemote(api)) + const pending = message('pending', 'before') + const initial = { 'next-turn': [pending], 'next-step': [] } + manager.handleControlFrame(inboxFrame(initial)) + const session = manager.get(SID) - await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) + await expect(session.updateQueue(pending.id, { kind: 'edit', content: text('after') })) .resolves.toEqual({ ok: true, value: { accepted: true } }) - await expect(session.updateQueue(iid('q-op'), { kind: 'steer' })) - .resolves.toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.updateQueue')).toEqual([ - { - sessionId: SID, - itemId: 'q-op', - action: { kind: 'edit', content: text('next') }, - }, - { - sessionId: SID, - itemId: 'q-op', - action: { kind: 'steer' }, - }, - ]) - expect(session.getSnapshot().queue).toBe(before) - }) -}) - -describe('queue reconnect semantics', () => { - it('a control baseline clears stale state before a fresh update lands', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) - session.replaceControl([]) - expect(session.getSnapshot().queue).toEqual([]) - session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }])) - expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) - }) - - it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { - const session = makeSession() - await session.open() - session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) - await session.resync() - expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) - }) - - it('running-status changes never guess at queue retirement', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) - session.handleRunning(true) - session.handleRunning(false) - expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) - }) -}) - -describe('manager buffering of queue snapshots', () => { - it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = makeManager() - manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) - manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) - expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) - }) - - it('a control baseline replaces the prior queue', () => { - const manager = makeManager() - manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) - const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items - manager.handleControlFrame({ - type: 'baseline', - value: { - queues: { [SID]: nextQueue }, - jobs: {}, - projections: {}, - }, - }) - const snapshot = manager.get(SID).getSnapshot() - expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) + expect(api.callsOf('session.updateQueue')).toEqual([{ + sessionId: SID, + itemId: pending.id, + action: { kind: 'edit', content: text('after') }, + }]) + expect(session.projections.faceOf('inbox').getSnapshot()).toBe(initial) }) }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 4e4bade67c..9c3cad2cf3 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -139,7 +139,7 @@ describe('scope tree', () => { b.svc.handleControlFrame({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { jobs: {}, projections: {} }, }) await Promise.resolve() expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 1ad1d4e858..6e0dd5488a 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -322,10 +322,10 @@ describe('Session Client stream adapters', () => { it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { jobs: {}, projections: {} }, } const update: SessionControlFrame = { - type: 'queue', sessionId: 'session-1' as never, items: [], + type: 'jobs', sessionId: 'session-1' as never, jobs: [], } const remote = new ScriptedSessionRemote([], [], [baseline, update]) const accept = vi.fn<(frame: SessionControlFrame) => void>() @@ -357,7 +357,7 @@ describe('Session Client stream adapters', () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { jobs: {}, projections: {} }, } const carrierFailed = vi.fn() const failed = vi.fn() diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 02eb11fe9c..903f2bb829 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -199,7 +199,6 @@ type FixtureControlFrame = | { readonly type: 'baseline' readonly value: { - readonly queues: Readonly> readonly jobs: Readonly> readonly approvals: readonly never[] readonly questions: readonly never[] @@ -3061,11 +3060,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const controlBaseline = (): Extract => { - const queues: Record = {} const jobs: Record = {} const projections: Record = {} for (const summary of sessions) { - queues[summary.sessionId] = [] jobs[summary.sessionId] = [] const log = logs.get(summary.sessionId) ?? [] projections[summary.sessionId] = { @@ -3076,7 +3073,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { type: 'baseline', value: { - queues, jobs, approvals: [], questions: [], diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index de8cbf42d9..015677c56a 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -97,7 +97,6 @@ type FixtureControlFrame = | { readonly type: 'baseline' readonly value: { - readonly queues: Readonly> readonly jobs: Readonly> readonly approvals: readonly unknown[] readonly questions: readonly unknown[] diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ade617c95e..ad0bd8c81e 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -2,6 +2,7 @@ // otherwise this view owns it. Each row subscribes to one stable node key. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { ConversationTimelineSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -173,7 +174,8 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, + useSession, useProjection, useChat, useSessions, useStore, renderSlot, sessionId, + openFile, loadOlder, loadImage, openView, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) @@ -183,7 +185,7 @@ export function ChatView({ // Turn enters, leaves, or changes its preview. const turnNavigationItems = useChat(s => s.navigation.items()) const timeline = useChat(s => s.timeline) - const inbox = useSession(s => s.queue) + const inbox = useProjection('inbox') as unknown as InboxState | undefined // 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) @@ -231,7 +233,7 @@ export function ChatView({ }, []) const pendingSteering = useMemo( - () => inbox.filter(item => item.placement === 'steering'), + () => inbox?.['next-step'].filter(message => message.source.kind === 'user') ?? [], [inbox], ) const renderMessageImages = useCallback( diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index ec6af626ee..e1ef4d8acc 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -13,6 +13,9 @@ import type { import type { SessionListState, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -47,10 +50,18 @@ beforeEach(() => { const SID = 's1' as SessionId type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } +function pendingMessage(id: string, text: string): UserMessage { + return { + id: id as MessageId, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + } +} + function sessionSnapshot(overrides: Partial = {}): SessionSnapshot { return { sessionId: SID, - queue: [], running: false, removed: false, openState: 'open', @@ -180,8 +191,11 @@ function makeHarness( chatSlice: ChatSlice = {}, sessionInit: Partial = {}, chatSnapshot?: ChatSnapshot, + inboxInit: InboxState = { 'next-turn': [], 'next-step': [] }, ) { const session = makeSessionSource(sessionInit) + const inbox = createSnapshotStore(inboxInit) + const useInbox = bindSnapshotSelector(inbox) const chatSource = makeChatSource(chatSlice, chatSnapshot) const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) @@ -304,7 +318,10 @@ function makeHarness( createSnapshotStore(new Map()), ), useWorkspaces: emptyWorkspaces(), - useProjection: (() => undefined), + useProjection: (( + key: string, + selector: (value: unknown) => unknown = value => value, + ) => useInbox(value => selector(key === 'inbox' ? value : undefined))), useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, @@ -333,6 +350,7 @@ function makeHarness( const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } return { setSession: session.set, setChat: chatSource.set, ChatView, props, + setInbox: (value: InboxState) => { inbox.set(value) }, openDetails, openFile, loadOlder, openView, chatScroll, forkAt, setSelection, toolOwners, } @@ -640,25 +658,13 @@ describe('ChatView', () => { configurable: true, value: { writeText }, }) - const pending = { - id: 'steer-occurrence' as never, - messageId: 'steer-message' as never, - placement: 'steering' as const, - content: [{ type: 'text' as const, text: 'interrupt now' }], - preview: 'interrupt now', - text: 'interrupt now', - } - const queued = { - id: 'queued-occurrence' as never, - messageId: 'queued-message' as never, - placement: 'queued' as const, - content: [{ type: 'text' as const, text: 'later' }], - preview: 'later', - text: 'later', - } + const pending = pendingMessage('steer-message', 'interrupt now') + const queued = pendingMessage('queued-message', 'later') const h = makeHarness( { nodes: [assistant(1, 'working')] }, - { queue: [queued, pending], running: true }, + { running: true }, + undefined, + { 'next-turn': [queued], 'next-step': [pending] }, ) const view = render() @@ -673,12 +679,12 @@ describe('ChatView', () => { & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) act(() => { - h.setSession({ queue: [queued] }) + h.setInbox({ 'next-turn': [queued], 'next-step': [] }) h.setChat({ nodes: [ assistant(1, 'working'), { - kind: 'steering', messageId: pending.messageId, + kind: 'steering', messageId: pending.id, seq: 2, time: 2_000, content: [{ type: 'text', text: 'interrupt now' }], source: null, }, @@ -708,20 +714,13 @@ describe('ChatView', () => { }) it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { - const pending = { - id: 'steer-occurrence-later' as never, - messageId: 'shared-steer-message' as never, - placement: 'steering' as const, - content: [{ type: 'text' as const, text: 'same steering' }], - preview: 'same steering', - text: 'same steering', - } + const pending = pendingMessage('shared-steer-message', 'same steering') const h = makeHarness({ nodes: [{ - kind: 'user', seq: 2, time: 2_000, + kind: 'steering', messageId: pending.id, seq: 2, time: 2_000, content: pending.content, source: null, }], - }, { queue: [pending], running: true }) + }, { running: true }, undefined, { 'next-turn': [], 'next-step': [pending] }) const view = render() expect(view.getAllByText('same steering')).toHaveLength(2) @@ -1132,14 +1131,10 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { - h.setSession({ queue: [{ - id: 'steering-occurrence' as never, - messageId: 'steering-message' as never, - placement: 'steering', - content: [{ type: 'text', text: 'also' }], - preview: 'also', - text: 'also', - }] }) + h.setInbox({ + 'next-turn': [], + 'next-step': [pendingMessage('steering-message', 'also')], + }) }) expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) }) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d4e4022e1c..f64734cad6 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -56,7 +56,6 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr function sessionSnapshot(): SessionSnapshot { return { sessionId: SID, - queue: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d7a0420bec..c1baf9a5ff 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 188a4461c1ab46e8be4f96c402c4707b1c66c415 -README.zh.md: 63356b04ef07ddd8c037d46d0ad105ed01afda80 +README.md: f33f2f585e594c4393d62c98167891eb4ad41e75 +README.zh.md: 77d22088b895f121879f2ccc47432781623231e0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 188a4461c1..f33f2f585e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ The package registers the optional-Session `conversation` shell, strict Session View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact pending messages through the scoped `ctx.conversation` service; QueueDock and the input machine read `inbox['next-turn']` from the generic Session projection store, without a second client queue mirror. Queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label), while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 63356b04ef..77d22088b8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的待处理消息;QueueDock 和 input machine 从通用 Session projection store 读取 `inbox['next-turn']`,不再维护第二份客户端 queue mirror。Queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index c86c5f335e..df608ba132 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -59,6 +59,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", @@ -87,6 +88,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 13aa8a82e8..ce13a1e34e 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -7,10 +7,10 @@ * here is the submit plane (phase, claim, attempt) alone. */ import type { Context } from '@deepseek-ai/cordis' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { Branded } from '@deepseek-ai/dsh-brand' import type { LexicalEditor } from 'lexical' -import type { QueueRow } from './queue.ts' import type { InputSubmitMode } from './composer-submission.ts' /** Pick-time draft span guarded by the input revision. */ @@ -276,9 +276,6 @@ export interface ComposerKeyboard { dismissPopup(): void } -/** One independently addressable row projected from the transient queue snapshot. */ -export type QueuedMessage = QueueRow - /** Guard union of the scoped consume-token event, checked by the shell. */ export type ConsumeTokenGuard = ConsumeTokenRequest['guard'] @@ -330,8 +327,8 @@ export interface InputState { readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] - /** Read-only transient inbox projection from Session control, including pending steering. */ - readonly queue: readonly QueuedMessage[] + /** Messages still waiting for their own turn. */ + readonly queue: InboxState['next-turn'] } /** diff --git a/packages/client/ui-conversation/src/client/contract/queue.ts b/packages/client/ui-conversation/src/client/contract/queue.ts deleted file mode 100644 index 4e54568448..0000000000 --- a/packages/client/ui-conversation/src/client/contract/queue.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Queue contracts derived from the Session Controller face. */ -import type { SessionFace, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' - -/** One address accepted by the Session Controller's queue mutation verb. */ -export type QueueItemId = Parameters[0] - -/** One mutation accepted by the Session Controller's queue mutation verb. */ -export type QueueAction = Parameters[1] - -/** One row projected by the authoritative Session queue snapshot. */ -export type QueueRow = SessionSnapshot['queue'][number] diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 0b588c4fd6..c08a9f8ab0 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -9,6 +9,7 @@ * listeners onto it. */ import type { Context } from '@deepseek-ai/cordis' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' @@ -23,7 +24,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -53,8 +54,8 @@ export interface SessionInputDeps { inputTriggers?: (() => InputTriggerController | undefined) | undefined /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ popup?: (() => PopupDismissFace | undefined) | undefined - /** Queue read face; overlaid onto InputState.queue (absent = empty). */ - queue?: ObservableSnapshot | undefined + /** Agent Inbox projection; its next-turn list is overlaid onto InputState.queue. */ + inbox?: ObservableSnapshot | undefined /** * Steer every still-pending queued message into the running turn, in FIFO * order (the empty-draft accelerated-Enter gesture); absent = unsupported. @@ -97,7 +98,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection }) } -const EMPTY_QUEUE: readonly QueuedMessage[] = [] +const EMPTY_QUEUE: InboxState['next-turn'] = [] /** No-pipeline lexicon: zero text-ref decorations. */ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() @@ -167,7 +168,7 @@ export class SessionInputShell implements SessionInput { () => { this.lexiconOff?.() }, ) this.state = createSnapshotStore(this.compose()) - deps.queue?.subscribe(() => { this.publish() }) + deps.inbox?.subscribe(() => { this.publish() }) } // ---- editor plumbing ---- @@ -792,7 +793,7 @@ export class SessionInputShell implements SessionInput { phase: core.phase, ...(core.claim !== undefined ? { claim: core.claim } : {}), occurrences: this.projection.occurrences, - queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE, + queue: this.deps.inbox?.getSnapshot()?.['next-turn'] ?? EMPTY_QUEUE, } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 9e53281302..c059422167 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -8,12 +8,13 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { Context } from '@deepseek-ai/cordis' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { ISessions, SessionBinding, SessionFace, } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' -import { queueReadFaceOf } from './queue-store.ts' import type { ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput, SubmitImageAttachment, SubmitOutcome, @@ -87,7 +88,7 @@ export class InputHub implements SessionInputResolver { actx, inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), - queue: queueReadFaceOf(session), + inbox: session.projections.faceOf('inbox') as ObservableSnapshot, defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, commandImages: { @@ -197,7 +198,7 @@ export class InputHub implements SessionInputResolver { * @param shell - the resident shell (notice outlet). */ private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise { - const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + const queued = shell.snapshot.queue if (queued.length === 0) return for (const item of queued) { const result = await session.updateQueue(item.id, { kind: 'steer' }) diff --git a/packages/client/ui-conversation/src/client/input/queue-store.ts b/packages/client/ui-conversation/src/client/input/queue-store.ts deleted file mode 100644 index 09b10da0d0..0000000000 --- a/packages/client/ui-conversation/src/client/input/queue-store.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Queue read face for the InputState.queue projection (frozen contract in - * ../contract/input.ts): a uSES-compatible observable over one session's - * transient inbox rows. The Session snapshot already keeps the queue array - * reference-stable across unrelated snapshot swaps, so this is a pure - * projection — no second store, no copy. - */ -import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { QueuedMessage } from '../contract/input.ts' - -/** - * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot). - * The wiring layer overlays this onto InputState.queue; the runtime - * QueuedMessage and the input-contract QueuedMessage are structurally - * identical. - * @param session - the resident session face. - * @returns the queue read face (snapshot reference stable while the queue is unchanged). - */ -export function queueReadFaceOf(session: SessionFace): ObservableSnapshot { - return { - getSnapshot: () => session.getSnapshot().queue, - subscribe: fn => session.subscribe(fn), - } -} diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index aadedd7d29..1ab9a35787 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,18 +1,36 @@ import type { Context } from '@deepseek-ai/cordis' -import { useEffect, useId, useMemo, useState } from 'react' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { useEffect, useId, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { QueueAction, QueueItemId } from '../contract/queue.ts' import { NS } from '../locales.ts' import css from './QueueDock.module.css' +const QUEUE_PREVIEW_CHARS = 200 +const EMPTY_QUEUE = [] as const + +function previewOf(content: InboxState['next-turn'][number]['content']): string { + const flat = content + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +} + +function textOf(content: InboxState['next-turn'][number]['content']): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + /** Queue operations injected by the session-scoped registration. */ export interface QueueDockInjected { - updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise + updateQueue: (itemId: MessageId, action: QueueAction) => Promise notify: (level: 'info' | 'error', text: string) => void } @@ -23,13 +41,13 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock * Queue strip: one item renders directly; multiple items default to a * collapsible count header; an empty queue renders nothing. */ -export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { - const inbox = useSession(s => s.queue) - const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) +export function QueueDock({ useSession, useProjection, updateQueue, notify, t }: QueueDockProps) { + const inbox = useProjection('inbox') as unknown as InboxState | undefined + const queue = inbox?.['next-turn'] ?? EMPTY_QUEUE const running = useSession(s => s.running) const queueMutable = useSession(s => s.subagent === null) - const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) - const [busy, setBusy] = useState(null) + const [editing, setEditing] = useState<{ id: MessageId; text: string } | null>(null) + const [busy, setBusy] = useState(null) const [collapsed, setCollapsed] = useState(true) const listId = useId() @@ -45,7 +63,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps const listVisible = queue.length === 1 || expanded const applyAction = async ( - itemId: QueueItemId, + itemId: MessageId, action: QueueAction, failure: string, ): Promise => { @@ -90,18 +108,19 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps )} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6b7d94829f..e115e0efae 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -14,10 +14,11 @@ import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' -import type { QueueAction, QueueItemId } from './contract/queue.ts' import type { ComposerBlocks } from './contract/composer-blocks.ts' import type { DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, @@ -49,7 +50,7 @@ export interface IConversation { * @param action - requested queue operation. * @returns completion; converged strict-steer races resolve, while other failures reject. */ - updateQueue(itemId: QueueItemId, action: QueueAction): Promise + updateQueue(itemId: MessageId, action: QueueAction): Promise /** * Cancel the scoped session's in-flight turn while preserving its pending Queue. * @returns completion; failures reject as in send. @@ -215,7 +216,7 @@ export class ConversationController extends Service implements IConversation { } /** Apply one operation to a pending queue occurrence. */ - async updateQueue(itemId: QueueItemId, action: QueueAction): Promise { + async updateQueue(itemId: MessageId, action: QueueAction): Promise { const session = this.scopedSession('updateQueue') const result = await session.updateQueue(itemId, action) if (!result.ok) { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index d6126acdfd..523159a850 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -130,7 +130,7 @@ export function InputBar({ const editorDisabled = removed || (locked && !workspaceTrigger) const editable = live && !locked && !machineBusy const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null - && input.queue.some(row => row.placement === 'queued') + && input.queue.length > 0 useEffect(() => { if (input === undefined || inputActions === undefined) return diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 20a7a0f7aa..f0ddaf1d1f 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -20,7 +20,6 @@ const SESSION_ID = 'resident' as SessionId function sessionSnapshot(): SessionSnapshot { return { sessionId: SESSION_ID, - queue: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 7fa6c189a5..305907d6de 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -14,6 +14,8 @@ import { $getRoot, $isTextNode } from 'lexical' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import { conversationSnapshot as conversationFixture, makeTranslate, sessionSnapshot as sessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' @@ -71,8 +73,8 @@ interface BenchOptions { workspacePickerOpen?: boolean onRequestWorkspace?: () => void promptError?: SessionSnapshot['promptError'] - /** Authoritative queue rows served to the machine overlay (empty = none). */ - queue?: SessionSnapshot['queue'] + /** Pending next-turn messages served to the machine overlay (empty = none). */ + queue?: InboxState['next-turn'] /** The hub's steer-all face (empty-draft accelerated Enter). */ steerQueue?: () => void variant?: 'hero' | 'composer' @@ -90,12 +92,9 @@ interface BenchOptions { toggleCommandMenu?: (selection: { start: number; end: number }) => void } -/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ -function row(id: string): SessionSnapshot['queue'][number] { - return { - id: id as never, messageId: `message-${id}` as never, placement: 'queued', - content: [{ type: 'text', text: id }], preview: id, text: id, - } +/** One pending next-turn message. */ +function row(id: string): InboxState['next-turn'][number] { + return createUserMessage({ content: [{ type: 'text', text: id }], source: { kind: 'user' } }) } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -112,17 +111,17 @@ function bench(over?: BenchOptions) { subagent: over?.subagent ?? null, removed: over?.disabled ?? false, promptError: over?.promptError ?? null, - queue: over?.queue ?? [], })) + const inbox = createSnapshotStore({ + 'next-turn': over?.queue ?? [], + 'next-step': [], + }) type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + inbox, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` }, - queue: { - getSnapshot: () => session.getSnapshot().queue, - subscribe: fn => session.subscribe(fn), - }, ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}), // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). @@ -528,7 +527,7 @@ describe('Enter semantics', () => { expect(ctrl.sink).not.toHaveBeenCalled() }) - it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => { + it('queue steering stays gated while idle, addressed to a subagent, on plain Enter, or with an empty queue', () => { // Idle: the gesture falls through to the machine's empty-draft no-op. const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() }) fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) @@ -561,15 +560,6 @@ describe('Enter semantics', () => { expect(none.steerQueue).not.toHaveBeenCalled() expect(none.sink).not.toHaveBeenCalled() - // Pending steering rows are not the queue: nothing to flush. - const steering = bench({ - running: true, - queue: [{ ...row('s-1'), placement: 'steering' }], - steerQueue: vi.fn(), - }) - fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true }) - expect(steering.steerQueue).not.toHaveBeenCalled() - expect(steering.sink).not.toHaveBeenCalled() }) it('draft content outranks the queue: accelerated Enter steers the draft only', () => { diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 6a30ed4d8a..862395aa80 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -7,8 +7,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' import type { - QueuedMessage, SessionListState, SessionSnapshot, + SessionListState, SessionSnapshot, UseProjection, } from '@deepseek-ai/dsh-api-session-controller/client' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -17,7 +20,6 @@ import { } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { QueueItemId } from '../src/client/contract/queue.ts' import type { InputState } from '../src/client/contract/input.ts' import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' @@ -25,26 +27,35 @@ import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps afterEach(cleanup) const SID = 's1' as SessionId -const iid = (id: string): QueueItemId => id as QueueItemId +const iid = (id: string): MessageId => id as MessageId -function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { +function row(id: string, text: string | null, preview = text ?? '[image]'): UserMessage { return { - id: iid(id), messageId: `message-${id}` as never, placement: 'queued', - content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }], - preview, text, + id: iid(id), role: 'user', source: { kind: 'user' }, + content: text === null + ? [ + ...(preview === '[image]' ? [] : [{ type: 'text' as const, text: preview.replace(/ \[image\]$/u, '') }]), + { type: 'image', data: 'x' } as never, + ] + : [{ type: 'text', text }], } } -function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { +interface TestSnapshot extends SessionSnapshot { + readonly testInbox: InboxState +} + +function snapshotWith(queue: UserMessage[], nextStep: UserMessage[] = []): TestSnapshot { return { - sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, + sessionId: SID, running: true, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, + testInbox: { 'next-turn': queue, 'next-step': nextStep }, } } -/** Minimal live source backing the useSession stub. */ -function liveSession(initial: SessionSnapshot) { +/** Minimal live source backing the Session and Inbox projection hooks. */ +function liveSession(initial: TestSnapshot) { let snapshot = initial const listeners = new Set<() => void>() const useSession: SnapshotSelectorHook = selector => @@ -55,9 +66,20 @@ function liveSession(initial: SessionSnapshot) { }, () => selector(snapshot), ) + const useProjection = (( + key: string, + selector: (value: InboxState | undefined) => unknown = value => value, + ) => useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => selector(key === 'inbox' ? snapshot.testInbox : undefined), + )) as UseProjection return { useSession, - push(next: SessionSnapshot): void { + useProjection, + push(next: TestSnapshot): void { snapshot = next for (const listener of [...listeners]) listener() }, @@ -95,22 +117,21 @@ describe('QueueDock', () => { it('renders null while the queue is empty', () => { const snap = snapshotWith([]) const source = liveSession(snap) - const { container } = render() + const { container } = render() expect(container.innerHTML).toBe('') }) it('leaves pending steering to the conversation flow', () => { - const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const } - const snap = snapshotWith([steering]) + const snap = snapshotWith([], [row('s-1', 'interrupt')]) const source = liveSession(snap) - const { container } = render() + const { container } = render() expect(container.innerHTML).toBe('') }) it('renders one row directly and defaults multiple rows to a collapsible count header', () => { const single = snapshotWith([row('i-1', 'one')]) const source = liveSession(single) - const view = render() + const view = render() expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull() expect(view.getByText('one')).toBeTruthy() @@ -134,7 +155,7 @@ describe('QueueDock', () => { it('keeps an active single-row editor visible when another item arrives', () => { const single = snapshotWith([row('i-edit', 'before')]) const source = liveSession(single) - const view = render() + const view = render() fireEvent.click(view.getByLabelText('编辑排队消息')) fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } }) @@ -160,7 +181,7 @@ describe('QueueDock', () => { let finishUpdate: (() => void) | undefined const updateQueue = vi.fn(() => new Promise((resolve) => { finishUpdate = resolve })) const view = render( - , + , ) fireEvent.click(view.getByLabelText('删除排队消息')) @@ -188,7 +209,7 @@ describe('QueueDock', () => { it('defaults a new multi-row queue to collapsed after the prior queue empties', () => { const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')]) const source = liveSession(first) - const view = render() + const view = render() fireEvent.click(view.getByRole('button', { name: '2 条排队消息' })) expect(view.getByText('one')).toBeTruthy() @@ -209,7 +230,9 @@ describe('QueueDock', () => { row('i-2', null, 'image [image]'), ]) const source = liveSession(snap) - const { container, getByRole } = render() + const { container, getByRole } = render( + , + ) fireEvent.click(getByRole('button', { name: '2 条排队消息' })) expect([...container.querySelectorAll('li')].map(item => item.textContent)) .toEqual(['第一条排队消息', 'image [image]']) @@ -228,7 +251,7 @@ describe('QueueDock', () => { const source = liveSession(snap) const updateQueue = vi.fn(() => Promise.resolve()) const { getByLabelText, queryByLabelText } = render( - , + , ) fireEvent.click(getByLabelText('编辑排队消息')) @@ -252,7 +275,7 @@ describe('QueueDock', () => { const source = liveSession(snap) const updateQueue = vi.fn(() => Promise.resolve()) const { getByLabelText, getByText } = render( - , + , ) fireEvent.click(getByLabelText('编辑排队消息')) @@ -271,7 +294,7 @@ describe('QueueDock', () => { const source = liveSession(snap) const updateQueue = vi.fn(() => Promise.resolve()) const { getByLabelText } = render( - , + , ) fireEvent.click(getByLabelText('编辑排队消息')) @@ -289,7 +312,7 @@ describe('QueueDock', () => { const source = liveSession(snap) const updateQueue = vi.fn(() => Promise.resolve()) const { getAllByLabelText, getByRole } = render( - , + , ) fireEvent.click(getByRole('button', { name: '2 条排队消息' })) @@ -304,7 +327,7 @@ describe('QueueDock', () => { const source = liveSession(running) const updateQueue = vi.fn(() => Promise.resolve()) const rendered = render( - , + , ) const button = rendered.getByLabelText('插话发送') @@ -333,7 +356,7 @@ describe('QueueDock', () => { } const source = liveSession(snap) const view = render( - , + , ) expect(view.getByText('pending child follow-up')).toBeTruthy() @@ -348,7 +371,7 @@ describe('QueueDock', () => { const notify = vi.fn() const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed'))) const { getByLabelText, getByText } = render( - , + , ) fireEvent.click(getByLabelText('插话发送')) @@ -367,7 +390,7 @@ describe('QueueDock', () => { const notify = vi.fn() const updateQueue = vi.fn(() => Promise.reject(new Error('not found'))) const { getByLabelText, getByText } = render( - , + , ) fireEvent.click(getByLabelText('删除排队消息')) @@ -380,7 +403,7 @@ describe('QueueDock', () => { it('follows authoritative retirement back to null', () => { const snap = snapshotWith([row('i-1', '在场')]) const source = liveSession(snap) - const { container } = render() + const { container } = render() expect(container.textContent).toContain('在场') act(() => { source.push(snapshotWith([])) }) expect(container.innerHTML).toBe('') diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 4633d07e20..e254c0acfb 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -6,7 +6,8 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' +import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts' @@ -132,20 +133,25 @@ describe('ConversationController', () => { }) describe('InputHub queue steering (empty-draft accelerated Enter)', () => { - const row = (id: string): QueuedMessage => ({ - id: id as never, - messageId: `message-${id}` as never, - placement: 'queued', + const row = (id: string): UserMessage => ({ + id: id as MessageId, + role: 'user', content: [{ type: 'text', text: id }], - preview: id, - text: id, + source: { kind: 'user' }, + }) + + const setInbox = ( + runtime: SlotTestRuntime, + nextTurn: UserMessage[], + nextStep: UserMessage[] = [], + ) => runtime.sessions.setProjection('s1', 'inbox', { + 'next-turn': nextTurn, + 'next-step': nextStep, }) it('steers every queued row in FIFO order and leaves steering rows alone', async () => { const b = await bench() - await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { - draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] - }) + await setInbox(b.runtime, [row('q-1'), row('q-3')], [row('q-2')]) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) @@ -158,9 +164,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('converges silently when the turn closes or a row is claimed mid-steer', async () => { const b = await bench() - await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { - draft.queue = [row('q-1'), row('q-2')] - }) + await setInbox(b.runtime, [row('q-1'), row('q-2')]) // The turn closes before the second row: the flush stops, silently. b.updateQueue.mockResolvedValueOnce({ ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, @@ -171,9 +175,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { // A row the host already claimed (e.g. a repeated empty-draft chord): // the duplicate strict steer is a silent no-op. - await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { - draft.queue = [row('q-3')] - }) + await setInbox(b.runtime, [row('q-3')]) b.updateQueue.mockResolvedValueOnce({ ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, } as never) @@ -185,9 +187,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('surfaces one notice on a genuine steer failure and stops', async () => { const b = await bench() - await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { - draft.queue = [row('q-1'), row('q-2')] - }) + await setInbox(b.runtime, [row('q-1'), row('q-2')]) b.updateQueue.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'broken', details: {} }, } as never) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 97946f4c1d..7b98d6732a 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -75,7 +75,6 @@ function createSessionsBench(_ctx: Context): SessionsBench { const scopeCtx = new Context() const snapshot = createSnapshotStore({ sessionId: id, - queue: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 4aa01e9ca6..302be7f495 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -110,7 +110,6 @@ function historySnapshot( function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot { return { sessionId: SID, - queue: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4b2326eb3d..d6401986d8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -27,7 +27,6 @@ type AttentionState = Parameters = H extends StoreHandle ? {\n useStore: SnapshotSelectorHook;\n actions: BakedActions;\n} : object;', }, + { + name: 'QueueAction', + declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};', + }, { name: 'RemoteStream', declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', @@ -708,7 +712,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSnapshot', - declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', + declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', }, { name: 'SessionStandardProps', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index dfae501ed9..f50a3f04ef 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4430,6 +4430,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PruneResult', declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}', }, + { + name: 'QueueAction', + declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};', + }, { name: 'ReadFileLine', declaration: 'export interface ReadFileLine {\n number: number;\n text: string;\n}', @@ -4640,11 +4644,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionControlBaseline', - declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', + declaration: 'export interface SessionControlBaseline {\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', }, { name: 'SessionControlFrame', - declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | {\n readonly type: \'jobs\';\n readonly sessionId: SessionId;\n readonly jobs: readonly SessionJob[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', + declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'jobs\';\n readonly sessionId: SessionId;\n readonly jobs: readonly SessionJob[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', }, { name: 'SessionCreateRequest', @@ -4862,10 +4866,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionPromptValue', declaration: 'export interface SessionPromptValue {\n readonly accepted: true;\n}', }, - { - name: 'SessionQueuedItem', - declaration: 'export interface SessionQueuedItem {\n readonly id: MessageId;\n readonly placement: \'queued\' | \'steering\' | \'context\';\n readonly message: {\n readonly id: MessageId;\n readonly content: readonly JsonValue[];\n };\n}', - }, { name: 'SessionRawArtifact', declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index 098e81a2a8..785e117cf9 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -65,7 +65,6 @@ export interface SessionFixture { export function sessionSnapshot(sessionId: SessionId): SessionSnapshot { return { sessionId, - queue: [], running: false, subagent: null, removed: false, diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 986bfd266d..8cc5115cc9 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -251,6 +251,17 @@ export class TestSessions implements ISessions { await this.stabilize(() => { record.snapshot.update(mutate) }) } + /** + * Publish one complete projection value through the fixture Session face. + * @param id - session id. + * @param key - registered projection key. + * @param value - complete value for that key. + */ + async setProjection(id: string, key: string, value: unknown): Promise { + const record = this.require(id) + await this.stabilize(() => { record.session.projections.set(key, value) }) + } + /** * Replace a Session's complete contiguous event window. * @param id - Session identity. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6f343d4e8..c4e72ff9cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2129,6 +2129,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes From ba51e5483e95b2f8d24bdffd04834f0f5d1337b6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 27 Aug 2026 12:40:43 +0800 Subject: [PATCH 13/88] fix(ui-conversation): dispose Inbox projection subscription --- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 ++-- .../2026-07-30-web-queue-steer-action.md | 2 ++ .../2026-07-30-web-queue-steer-action.zh.md | 2 ++ ...pec.ts => inbox-projection.client.spec.ts} | 0 .../src/client/input/facade.ts | 4 ++-- .../tests/input-matrix.client.spec.tsx | 19 +++++++++++++++++++ 6 files changed, 27 insertions(+), 4 deletions(-) rename packages/api/session-controller/tests/{queue-store.client.spec.ts => inbox-projection.client.spec.ts} (100%) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index bbc7de53b8..0e47c08be0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: 4135c465166d88873e5b4595758778839efe83ee -2026-07-30-web-queue-steer-action.zh.md: 2717c8a53b6a46c4751b1a8bb1e563b7aa37a42e +2026-07-30-web-queue-steer-action.md: 4193bd9fcb2d20def8b72ff160f3ab3fa0b3e7c4 +2026-07-30-web-queue-steer-action.zh.md: 15dd9719d449afbc5ddab38de31b91aca2ccfed7 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index 4135c46516..4193bd9fcb 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -65,3 +65,5 @@ The keyless Web steering scenario queues a message through the real composer whi `inbox` is the domain-owned durable session projection. Pending steering survives reconnect and process restart and appears immediately from `next-step`; after claim, the durable conversation node follows through the ordinary event projection. The running bit can change between rendering and the synchronous operation, so an enabled action may return `steer-unavailable` while the product continues through Queue without reporting failure. The explicit action changes delivery from an independently admitted turn to current-turn steering. Its two durable splices briefly expose the deletion and insertion as adjacent projection revisions, while client snapshot batching converges on the message moving from `next-turn` to `next-step`. + +Claim removes the pending `next-step` value before the later `user/message` reaches the independent history stream, so a client may briefly render neither representation when those notifications cross separate flushes. The product accepts this transient gap instead of retaining a queue-specific handoff mirror; the durable log still converges on exactly one conversation node. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 2717c8a53b..15dd9719d4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -65,3 +65,5 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、Inbox 投 `inbox` 是领域拥有的持久会话投影。待处理 steering 会从 `next-step` 立即出现,能在重连与进程重启后恢复;认领后,持久会话节点经普通事件投影跟进。running 标志位可能在渲染与同步操作之间发生变化,因此已启用的操作可能返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering。它的两条持久 splice 会短暂地把删除与插入暴露为相邻投影修订,而客户端快照批处理会收敛到消息从 `next-turn` 移入 `next-step`。 + +Claim 会先移除待处理的 `next-step` 值,随后 `user/message` 才到达独立的历史流;若两项通知跨越不同的客户端刷新,客户端可能短暂地两种表示都不渲染。产品接受这一瞬态空隙,以避免保留 Queue 专用的交接镜像;持久日志最终仍只产生一个会话节点。 diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/inbox-projection.client.spec.ts similarity index 100% rename from packages/api/session-controller/tests/queue-store.client.spec.ts rename to packages/api/session-controller/tests/inbox-projection.client.spec.ts diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index c08a9f8ab0..82b5962922 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -159,6 +159,7 @@ export class SessionInputShell implements SessionInput { nodes: [ReferenceChipNode, TextRefNode], onError: (error) => { throw error }, }) + this.state = createSnapshotStore(this.compose()) this.unregister = mergeRegister( registerPlainText(this.editor), registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS), @@ -166,9 +167,8 @@ export class SessionInputShell implements SessionInput { registerClaimDecoration(this.editor, () => this.activeClaimToken()), registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot(), () => this.activeClaimToken()), () => { this.lexiconOff?.() }, + deps.inbox?.subscribe(() => { this.publish() }) ?? (() => {}), ) - this.state = createSnapshotStore(this.compose()) - deps.inbox?.subscribe(() => { this.publish() }) } // ---- editor plumbing ---- diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 2133d03477..a0a74f8d62 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -115,6 +115,25 @@ function bench(over?: { } describe('matrix row: plain', () => { + it('unsubscribes from the Inbox projection when disposed', () => { + const unsubscribe = vi.fn() + const subscribe = vi.fn(() => unsubscribe) + const shell = new SessionInputShell({ + actx: SCTX, + defaultSink: () => Promise.resolve({ kind: 'success' }), + inbox: { getSnapshot: () => undefined, subscribe }, + commandImages: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: token => `${token.trim()} images-unsupported`, + }, + }) + + expect(subscribe).toHaveBeenCalledOnce() + shell.dispose() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() act(() => { shell.setDraft('普通消息') }) From 16654bee727f639c7a35e3849cef83195da2abc3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 27 Aug 2026 13:00:57 +0800 Subject: [PATCH 14/88] test(session-controller): cover control stream disposal --- .../tests/control-jobs.host.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index aad3ceb0a6..dab4417102 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -226,4 +226,32 @@ describe('Session control jobs updates', () => { expect(task.reads.count).toBe(0) }) + it('ends active streams on context disposal after flushing buffered job frames', async () => { + const { ctx, agent, control } = await harness(true) + const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) + const first = producer('first') + const second = producer('second') + ctx.jobs.start({ ...first.spec, owner: agent }) + ctx.jobs.start({ ...second.spec, owner: agent }) + first.settle({ status: 'completed' }) + second.settle({ status: 'completed' }) + await expect.poll(() => ctx.jobs.list(agent).map(job => job.status)) + .toEqual(['completed', 'completed']) + + await ctx.fiber.dispose() + + const frames: JobFrame[] = [] + for (;;) { + const next = await iterator.next() + if (next.done) break + if (next.value.type === 'jobs') frames.push(next.value) + } + expect(frames.map(frame => frame.jobs.map(job => job.status))).toEqual([ + ['running'], + ['running', 'running'], + ['completed', 'running'], + ['completed', 'completed'], + ]) + }) }) From 76d5f797a85273336386f308e92f7ef4936a14e3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 27 Aug 2026 13:01:00 +0800 Subject: [PATCH 15/88] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6e0df12c44..fddbe7bbb4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a5b70813243f87481834fa983463ec625225aa90 -module-graph.zh.md: 63bea7dfcc111fa4a9fdeae9785daefda5849220 +module-graph.md: eab6a965710ac141ba387f46f9758ed241b91beb +module-graph.zh.md: d0ca02d5fa1601185e102b7bac6f77eb8d54f2df diff --git a/docs/module-graph.md b/docs/module-graph.md index a5b7081324..eab6a96571 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1370,6 +1370,7 @@ flowchart TD pkg_cordis_client_runner --> pkg_client_ui_renderer pkg_cordis_client_runner --> pkg_client_ui_theme pkg_cordis_client_runner --> pkg_invariants + pkg_client_ui_conversation --> pkg_agent pkg_client_ui_conversation --> pkg_api_remotes pkg_client_ui_conversation --> pkg_api_session_controller pkg_client_ui_conversation --> pkg_api_workspace_controller @@ -1908,7 +1909,7 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 63bea7dfcc..d0ca02d5fa 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1372,6 +1372,7 @@ flowchart TD pkg_cordis_client_runner --> pkg_client_ui_renderer pkg_cordis_client_runner --> pkg_client_ui_theme pkg_cordis_client_runner --> pkg_invariants + pkg_client_ui_conversation --> pkg_agent pkg_client_ui_conversation --> pkg_api_remotes pkg_client_ui_conversation --> pkg_api_session_controller pkg_client_ui_conversation --> pkg_api_workspace_controller @@ -1910,7 +1911,7 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | From 72f2e71070925993edab0de8e59a20fe5df60f4c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 15:14:08 +0800 Subject: [PATCH 16/88] fix(web): reconcile durable inbox recovery with master --- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 4 +- ...-07-31-claimed-pre-step-inbox-lifecycle.md | 6 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 6 +- ...08-17-durable-web-queue-recovery.i18n.yaml | 4 +- .../2026-08-17-durable-web-queue-recovery.md | 4 +- ...026-08-17-durable-web-queue-recovery.zh.md | 4 +- apps/web/tests/cold-blank-session.e2e.ts | 75 +++++ apps/web/tsconfig.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +- docs/event-producer-consumer.zh.md | 12 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 6 +- packages/api/session-controller/README.zh.md | 6 +- .../src/client/contract/snapshot.ts | 15 - .../src/client/sessions/manager.ts | 28 +- .../src/client/sessions/queue-mirror.ts | 71 ----- .../src/client/sessions/session.ts | 64 ++-- .../api/session-controller/src/commands.ts | 13 +- .../api/session-controller/src/control.ts | 48 +-- packages/api/session-controller/src/types.ts | 15 - .../commands-queue-attachment.host.spec.ts | 38 ++- .../tests/commands-upload-file.host.spec.ts | 2 +- .../tests/control-queue.host.spec.ts | 72 ++--- .../tests/queue-store.client.spec.ts | 297 ------------------ .../tests/session-cold.host.spec.ts | 58 ++-- ...session-pending-submissions.client.spec.ts | 51 +-- .../tests/session-projections.host.spec.ts | 5 +- .../ui-chat/src/client/chat/ChatView.tsx | 11 +- .../ui-chat/tests/chat-view.client.spec.tsx | 74 ++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 3 +- .../src/client/contract/queue.ts | 11 - .../src/client/input/facade.ts | 16 +- .../ui-conversation/src/client/input/hub.ts | 8 +- .../src/client/input/queue-store.ts | 25 -- .../src/client/queue/QueueDock.tsx | 54 +++- .../ui-conversation/src/client/service.ts | 7 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../conversation-registry.client.spec.ts | 1 - .../tests/input-bar.client.spec.tsx | 21 +- .../tests/input-matrix.client.spec.tsx | 4 +- .../tests/queue-dock.client.spec.tsx | 48 +-- .../service-orchestration.client.spec.ts | 38 +-- .../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 - 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/inbox.ts | 6 +- packages/core/agent-loop/src/index.ts | 2 + packages/core/agent-loop/tests/inbox.spec.ts | 8 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 6 +- .../src/client/api-catalog.ts | 6 +- .../extensions/tool-cordis/src/api-catalog.ts | 16 +- .../client-runtime/src/fixtures.ts | 1 - pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 72 files changed, 485 insertions(+), 857 deletions(-) create mode 100644 apps/web/tests/cold-blank-session.e2e.ts delete mode 100644 packages/api/session-controller/src/client/sessions/queue-mirror.ts delete mode 100644 packages/api/session-controller/tests/queue-store.client.spec.ts delete mode 100644 packages/client/ui-conversation/src/client/contract/queue.ts delete mode 100644 packages/client/ui-conversation/src/client/input/queue-store.ts diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 1b529c379b..8e2fc94bf2 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18 +2026-07-31-claimed-pre-step-inbox-lifecycle.md: de58747423fe71a7f04cd9354d39e7eb215f048e +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 9103e5c6f5197ecb74269cde249d610c0396354f diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index 737e383526..de58747423 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -18,13 +18,13 @@ Before every proposed step, the loop's package-internal `ReactLoopInbox` atomica The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields. -`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes. +`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and reads the definition registered by `AgentLoop` at service activation. This service lifetime keeps cold Inbox reads available without any live Agent. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentLoop` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the generic projection change feed and read the complete Inbox value directly. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. -The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `AgentLoop` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index ecb731df66..9103e5c6f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -18,13 +18,13 @@ Status: implemented 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数。 +`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并读取 `AgentLoop` 在服务激活时注册的定义。该服务生命周期让没有 live Agent 时的冷 Inbox 读取仍然可用。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentLoop` 在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用通用投影变更流,直接读取完整的 Inbox 值。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 -已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `AgentLoop` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml index fd94133c62..96ad0e6d2a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md -2026-08-17-durable-web-queue-recovery.md: feea3b9c02fe5ce81bb6a8a453e83db658616ecd -2026-08-17-durable-web-queue-recovery.zh.md: 35c5bea725bb632a587aba407535192ccb5eca29 +2026-08-17-durable-web-queue-recovery.md: ffc699c7bc793da10e1f261be9ff6f7cfe079c59 +2026-08-17-durable-web-queue-recovery.zh.md: 789131c3ddfa944e43fccd71ac2ad4c39a418882 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md index feea3b9c02..ffc699c7bc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -12,9 +12,9 @@ A reconnect-only repair would retain two recovery implementations: one for a liv ## Decision -`AgentRegistry` registers the standard `inbox` projection whenever the Session projection registry is composed. The projection folds normalized `agent/inbox/spliced` operations from the complete session log into the complete `{ 'next-turn', 'next-step' }` state and exposes that same state as its value. A fork therefore projects pending Inbox operations inherited in its seed, like every other projection. Its schema and `InboxState` type have one definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator. +`AgentLoop` registers the standard `inbox` projection whenever the Session projection registry is composed. The projection folds normalized `agent/inbox/spliced` operations from the complete session log into the complete `{ 'next-turn', 'next-step' }` state and exposes that same state as its value. A fork therefore projects pending Inbox operations inherited in its seed, like every other projection. Its schema and `InboxState` type have one definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator. -The projection registry owns both durable reconstruction and live state. Its existing `session/event` drive folds each committed splice before `Session.append()` returns. Each Agent's `Inbox` command facade reads that same registry state. Inbox normalizes splice coordinates and checks pending `MessageId` uniqueness before append. It does not traverse `session.events`, copy the folded arrays, or apply the committed transition again. It emits the per-message `inserted`, `discarded`, and `claimed` live notifications owned by its mutations. +The projection registry owns both durable reconstruction and live state. Its existing `session/event` drive folds each committed splice before `Session.append()` returns. Each Agent's `ReactLoopInbox` command facade reads that same registry state. Inbox normalizes splice coordinates and checks pending `MessageId` uniqueness before append. It does not traverse `session.events`, copy the folded arrays, or apply the committed transition again. It emits the per-message `inserted`, `discarded`, and `claimed` live notifications owned by its mutations. The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block on history tail pages, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md index 35c5bea725..789131c3dd 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -12,9 +12,9 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We ## 决策 -`AgentRegistry` 会在 Session projection registry 已组合时注册标准 `inbox` 投影。该投影从完整会话日志中折叠规范化的 `agent/inbox/spliced` 操作,得到完整的 `{ 'next-turn', 'next-step' }` 状态,并直接暴露同一状态。因此 fork 会像其他投影一样,自然投影其 seed 中继承的待处理 Inbox 操作。其 schema 与 `InboxState` 类型只有一个定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。 +`AgentLoop` 会在 Session projection registry 已组合时注册标准 `inbox` 投影。该投影从完整会话日志中折叠规范化的 `agent/inbox/spliced` 操作,得到完整的 `{ 'next-turn', 'next-step' }` 状态,并直接暴露同一状态。因此 fork 会像其他投影一样,自然投影其 seed 中继承的待处理 Inbox 操作。其 schema 与 `InboxState` 类型只有一个定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。 -投影注册表同时拥有持久重建与 live 状态。其既有的 `session/event` 驱动会在 `Session.append()` 返回前折叠每个已提交 splice。每个 Agent 的 `Inbox` 命令 facade 都读取同一份注册表状态。Inbox 在 append 前规范化 splice 坐标并检查待处理 `MessageId` 唯一性。Inbox 不遍历 `session.events`,不复制已折叠数组,也不再次应用已提交 transition。它仍发出其变更拥有的逐消息 `inserted`、`discarded` 与 `claimed` live 通知。 +投影注册表同时拥有持久重建与 live 状态。其既有的 `session/event` 驱动会在 `Session.append()` 返回前折叠每个已提交 splice。每个 Agent 的 `ReactLoopInbox` 命令 facade 都读取同一份注册表状态。Inbox 在 append 前规范化 splice 坐标并检查待处理 `MessageId` 唯一性。Inbox 不遍历 `session.events`,不复制已折叠数组,也不再次应用已提交 transition。它仍发出其变更拥有的逐消息 `inserted`、`discarded` 与 `claimed` live 通知。 通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在历史尾页中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。 diff --git a/apps/web/tests/cold-blank-session.e2e.ts b/apps/web/tests/cold-blank-session.e2e.ts new file mode 100644 index 0000000000..2f6241cc05 --- /dev/null +++ b/apps/web/tests/cold-blank-session.e2e.ts @@ -0,0 +1,75 @@ +/** Pending Inbox recovery from detached persistence through the shipped Web profile. */ + +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SESSION_ID = SessionId('cold-inbox-web-e2e') +const PENDING_TEXT = 'Accepted before the Host restarted' +const EXPECTED = fileURLToPath(new URL('./expected/cold-blank-session/queue.expected.md', import.meta.url)) + +describe('web e2e: cold Inbox recovery', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const createdAt = Date.now() - 60_000 + const handle = await scaffold.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, id: SESSION_ID, createdAt, + cwd: scaffold.workspaceCwd, isSeeded: false, delegationDepth: 0, + }) + try { + await handle.append([{ + type: 'agent/inbox/spliced', seq: SessionSeq(0), time: createdAt, + data: { target: 'next-turn', start: 0, inserted: [createUserMessage({ + content: [{ type: 'text', text: PENDING_TEXT }], source: { kind: 'user' }, + })] }, + }]) + } finally { + await handle.close() + } + expect(scaffold.ctx.agents.get(SESSION_ID)).toBeUndefined() + expect(scaffold.ctx.sessions.get(SESSION_ID)).toBeUndefined() + 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('restores a pending row on opening and reload, then edits and removes it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-inbox')) + const group = page.locator('[role="treeitem"]').first() + await group.waitFor({ timeout: 15_000 }) + await group.click() + await page.locator('[role="treeitem"]').nth(1).click() + const dock = page.locator('[data-queue-dock]') + await dock.getByText(PENDING_TEXT, { exact: true }).waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden(EXPECTED, + await captureStableAria(page, '[data-queue-dock]', scaffold.workspaceCwd), webSnapshotMode()) + await page.reload({ waitUntil: 'load' }) + await dock.getByText(PENDING_TEXT, { exact: true }).waitFor({ timeout: 15_000 }) + await dock.getByRole('button', { name: 'Edit queued message', exact: true }).click() + await dock.getByRole('textbox').fill('Edited after recovery') + await dock.getByRole('button', { name: 'Save queued message', exact: true }).click() + await dock.getByText('Edited after recovery', { exact: true }).waitFor() + await dock.getByRole('button', { name: 'Remove queued message', exact: true }).click() + await dock.waitFor({ state: 'detached' }) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8001c0a9c3..e3771f09d6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -80,6 +80,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/clickable-links-gallery.e2e.ts", "tests/queue-actions.e2e.ts", + "tests/cold-blank-session.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d9d1bd54f3..dd0f61c31f 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: fed98f006471f86f02c43bcb0c6ea4dfe7850da8 -architecture.zh.md: b1ec7ddaf0278a14cd7c18d6cc272fc59c5c3781 +architecture.md: 2824b77e30f41fddf947d39448ce92879ea0c28a +architecture.zh.md: 6218f3cefae8fd22f3dc2a5c7fdc27f505233177 diff --git a/docs/architecture.md b/docs/architecture.md index fed98f0064..2824b77e30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,7 @@ turn/end `turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `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. The AgentLoop service owns the durable `inbox` projection registration, making pending input readable before any Agent starts and after all Agents unload. `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b1ec7ddaf0..6218f3cefa 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -108,7 +108,7 @@ turn/end `turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 -输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 +输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。AgentLoop 服务拥有持久 `inbox` 投影的注册,因此待处理输入在任何 Agent 启动前以及所有 Agent 卸载后都可读取。 `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bfc5221538..a05964a72d 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: 50626a7d85aa54f6d2e3cdb6140b3af8081d1461 -config-catalog.zh.md: d2ae35b91a4a83da882e8ec5af82a505aa5d5bd1 +config-catalog.md: 72ab9a775077896e6f42af2574cf3b699160219c +config-catalog.zh.md: a3cabba2637e13e63388e712150a33f9d5f77968 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 50626a7d85..72ab9a7750 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -111,7 +111,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -Source: [`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:319`](../packages/core/agent-loop/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d2ae35b91a..a3cabba263 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -113,7 +113,7 @@ export interface Config { 依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) -来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) +来源:[`packages/core/agent-loop/src/index.ts:319`](../packages/core/agent-loop/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b1f10816f3..50577512df 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: 449b7d8f0fb55515e7f1f028e151ce1b26862e92 -event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2 +event-producer-consumer.md: 0adb3ce073c6b22f0c2934db499a4d88612631d3 +event-producer-consumer.zh.md: e6555a780ee071504a062fbe814da55d39da83ab diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 449b7d8f0f..0adb3ce073 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../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/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../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) | @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../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:277`](../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:391`](../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:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:562`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:568`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:575`](../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:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8120df6de6..e6555a780e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -9,7 +9,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | -| `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-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../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/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../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) | @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../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:277`](../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:391`](../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:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:562`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:568`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:575`](../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:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 07faf5ab83..f0fe8aa811 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: 27ff30a9e63c86ffb54ccf57dea18ebf3fe39846 -core.zh.md: cedd113de5d5d551b8b558f9f33c7cacf6aa4953 +core.md: 271c2ddfb80186d1ac80244a48ccd3511b7755c3 +core.zh.md: 059304f5c6cbc8f4a8fe24aa9bf6519ec2abf8a0 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 27ff30a9e6..271c2ddfb8 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -272,7 +272,7 @@ interface Inbox { type InboxTarget = 'next-turn' | 'next-step' ``` -Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods 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. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. Each `ReactLoopInbox` constructor contributes the standard `inbox` projection from its agent scope; the registry shares that definition across agents by reference count, and its cell is the sole live state while the same fold serves cold consumers. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods 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. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. The `AgentLoop` service registers the standard `inbox` projection before publishing its factory; its cell is the sole live state, and the same fold serves cold consumers even when no Agent exists. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index cedd113de5..059304f5c6 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -276,7 +276,7 @@ interface Inbox { type InboxTarget = 'next-turn' | 'next-step' ``` -每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 会通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。每个 `ReactLoopInbox` 构造函数都从其 agent 作用域贡献标准 `inbox` 投影;注册表通过引用计数在多个 agent 之间共享该定义,其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 会通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。`AgentLoop` 服务在发布工厂之前注册标准 `inbox` 投影;其 cell 是唯一 live 状态,同一份折叠在没有 Agent 时也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 91888af26a..398d37f80e 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: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea -README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8 +README.md: dc1b2a4cf8c322e9cfd9981fd4adc29d1dc544d5 +README.zh.md: 118786581b2611f2dae19cdbb6b07dee91df48d0 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ef6ffb3563..dc1b2a4cf8 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -28,11 +28,11 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provenance, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. 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. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog 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 reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. 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. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -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`, `append`, and `settle-assistant` 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. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. 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. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +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`, `append`, and `settle-assistant` 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. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. 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 jobs and projection state instead of treating transient values as durable events. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. -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. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and 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, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. 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. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and 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`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 97949e0137..118786581b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -28,11 +28,11 @@ kind: "package-reference" Client journal 在发布 follow snapshot、live entry 或历史页之前验证精确的 V3 event envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 来源、request header 可选字段的省略规则以及 tool error 一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 会在解析 Agent 或追加 Session event 前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 会在解析 Agent 或追加 Session event 前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 -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`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index aba257f893..c9ce1be4f5 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -1,24 +1,10 @@ /** Session-owned observable state excluding Conversation target data. */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRequestId } from '../../types.ts' -/** One transient inbox occurrence from the authoritative queue snapshot. */ -export interface QueuedMessage { - readonly id: MessageId - readonly messageId: MessageId - readonly placement: 'queued' | 'steering' | 'context' - /** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */ - readonly rpcId?: SessionRequestId - readonly content: readonly ContentBlock[] - readonly preview: string - readonly text: string | null -} - /** One image displayed by a local submission echo before durable admission. */ export interface PendingSubmissionImage { /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ @@ -82,7 +68,6 @@ export interface PromptError { /** Immutable Session lifecycle and control snapshot. */ export interface SessionSnapshot { readonly sessionId: SessionId - readonly queue: readonly QueuedMessage[] /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ readonly pendingSubmissions: readonly PendingSubmission[] readonly running: boolean diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index fd39fefedf..1ef550fb08 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -8,7 +8,6 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, SessionControlFrame, - SessionQueuedItem, SessionSummary, SessionJob as JobView, } from '../../types.ts' @@ -96,8 +95,6 @@ export class SessionManager { private readonly sessions = new Map() /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ private readonly sessionDisposals = new Set>() - /** Latest transient queues, retained independently of Session object materialization. */ - private readonly queues = new Map() /** * Sessions that finished running while not selected — the sidebar's green * "done" reminder (manager-owned, survives connection generations; cleared @@ -290,11 +287,6 @@ export class SessionManager { if (session === undefined) { session = this.createSession(sessionId) this.sessions.set(sessionId, session) - // Install the latest control baseline before the running-bit sync: a - // not-running summary must sweep replayed queue - // rows the same way a live status flip would (their retirement events dropped - // while the session was uninstantiated). - session.replaceControl(this.queues.get(sessionId) ?? []) // Sync the running and blank bits from the list snapshot into the new // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) @@ -669,22 +661,12 @@ export class SessionManager { this.notifier.markDirty() return } - if (frame.type === 'jobs') { - if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) - else this.jobsBySession.set(frame.sessionId, frame.jobs) - this.notifier.markDirty() - return - } - this.queues.set(frame.sessionId, frame.items) - this.sessions.get(frame.sessionId)?.handleControlFrame(frame) + if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) + else this.jobsBySession.set(frame.sessionId, frame.jobs) + this.notifier.markDirty() } private replaceControlBaseline(baseline: SessionControlBaseline): void { - this.queues.clear() - for (const [sessionId, items] of Object.entries(baseline.queues)) { - this.queues.set(sessionId as SessionId, items) - } - this.jobsBySession.clear() for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) @@ -696,9 +678,6 @@ export class SessionManager { store.truncate(asOfSeq) store.seed({ ...block, asOfSeq }) } - for (const [sessionId, session] of this.sessions) { - session.replaceControl(this.queues.get(sessionId) ?? []) - } this.notifier.markDirty() } @@ -738,7 +717,6 @@ export class SessionManager { this.updateCatalogActivity(sessionId, false) if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) else this.sessions.get(sessionId)?.handleRemoved() - this.queues.delete(sessionId) this.jobsBySession.delete(sessionId) if (!durableSubagent) this.projectionStores.delete(sessionId) const inflightCatalog = this.catalogInflight.get(sessionId) diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts deleted file mode 100644 index f0ed979c2b..0000000000 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionQueuedItem } from '../../types.ts' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { QueuedMessage } from '../contract/snapshot.ts' - -const QUEUE_PREVIEW_CHARS = 200 - -// Attachment blocks are excluded: queue presentation renders them from -// `content`, so the text preview covers only what has no visual form. -function previewOf(content: readonly ContentBlock[]): string { - const flat = content - .filter(block => block.type !== 'image' && block.type !== 'file') - .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) - .join(' ').replace(/\s+/g, ' ').trim() - const chars = Array.from(flat) - return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat -} - -function textOf(content: readonly ContentBlock[]): string | null { - if (!content.every(block => block.type === 'text')) return null - return content.map(block => block.text).join('') -} - -type QueueItems = readonly SessionQueuedItem[] - -/** Authoritative transient queue projection and durable steering handoff. */ -export class SessionQueueMirror { - private current: readonly QueuedMessage[] = [] - - /** - * Return the current immutable queue projection. - * @returns current queue rows. - */ - snapshot(): readonly QueuedMessage[] { - return this.current - } - - /** - * Replace from one authoritative stream queue frame. - * @param items - complete host queue snapshot. - */ - replace(items: QueueItems): void { - this.current = items.map((item) => { - const content = item.message.content as unknown as readonly ContentBlock[] - return { - id: item.id, - messageId: item.message.id, - placement: item.placement, - ...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }), - content, - preview: previewOf(content), - text: textOf(content), - } - }) - } - - /** - * Retire a transient steering row once its durable message enters the log. - * @param event - newly contiguous durable Session event. - * @returns whether the projection changed. - */ - acceptDurable(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const messageId = event.data.id - const index = this.current.findIndex(item => - item.placement === 'steering' && item.messageId === messageId) - if (index < 0) return false - this.current = this.current.filter((_item, candidate) => candidate !== index) - return true - } -} diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 2945f0b5c4..3c401cc3e7 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -1,6 +1,7 @@ // Sessions remain resident after creation so their open Remote sources keep running off-screen. import type { Context } from '@deepseek-ai/cordis' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' @@ -13,9 +14,7 @@ import type { QueueAction, SessionAddress, SessionAssistantStreamBaseline, - SessionControlFrame, SessionProjectionBaseline, - SessionQueuedItem, SessionRequestId, } from '../../types.ts' import type { @@ -36,7 +35,6 @@ import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' import { resolvedClientTimeZone } from '../time-zone.ts' -import { SessionQueueMirror } from './queue-mirror.ts' import { ClientAssistantStream, type ClientAssistantStreamResult, @@ -99,8 +97,7 @@ export class Session implements SessionFace { private jumpTargetSeq: SessionSeq | null = null /** The running jump loop's completion, shared by retargeting callers. */ private jumpPromise: Promise | null = null - /** Authoritative stream-only inbox snapshot; pending work never hits history. */ - private readonly queueMirror = new SessionQueueMirror() + private readonly stopObservingInbox: () => void private readonly assistantStream = new ClientAssistantStream() private running = false private address: SubagentAddress | undefined @@ -121,7 +118,7 @@ export class Session implements SessionFace { /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ private pendingSubmissions: readonly PendingSubmission[] = [] /** Per-echo settlement state; `retiring` latches the first observation so a - * queue frame and its durable event cannot both retire one echo. */ + * Inbox projection and its durable event cannot both retire one echo. */ private readonly submissionSettlements = new Map void) | undefined retiring: boolean @@ -173,6 +170,9 @@ export class Session implements SessionFace { this.snapshotCache = this.buildSnapshot() }) this.snapshotCache = this.buildSnapshot() + this.stopObservingInbox = this.projections.faceOf('inbox').subscribe(() => { + this.observeSubmissionInbox() + }) } /** @@ -453,7 +453,7 @@ export class Session implements SessionFace { } /** Rebuild an opened history source after address replacement. - * Invalidates any in-flight open first; queue state belongs to the independently + * Invalidates any in-flight open first; projection state belongs to the independently * reconnecting control stream and remains untouched. */ async resync(): Promise { if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) @@ -491,26 +491,6 @@ export class Session implements SessionFace { // ---- Manager-only entry points (@internal; never called by the UI) ---- - /** - * Replace every transient control value for this Session from one stream baseline. - * @param queue - complete pending queue for this Session. - */ - replaceControl(queue: readonly SessionQueuedItem[]): void { - this.queueMirror.replace(queue) - this.observeSubmissionQueue(queue) - this.notifier.markDirty() - } - - /** - * Apply one Session-addressed live control update. - * @param frame - queue replacement addressed to this Session. - */ - handleControlFrame(frame: Extract): void { - this.queueMirror.replace(frame.items) - this.observeSubmissionQueue(frame.items) - this.notifier.markDirty() - } - /** * Running-bit relay from the host stream (list entry and snapshot stay consistent). * @param running - the new running state. @@ -588,6 +568,7 @@ export class Session implements SessionFace { * @returns when the Remote iterator has completed teardown. */ async dispose(): Promise { + this.stopObservingInbox() // Unsettled echoes retire as failed so their owners can restore or // release browser resources; echoes already scheduled as observed keep // that settlement. @@ -713,18 +694,25 @@ export class Session implements SessionFace { const event = entry.event const awaitingFirstTurn = this.firstPromptPendingTurn if (event.type === 'turn/start') this.firstPromptPendingTurn = false - const queueChanged = this.queueMirror.acceptDurable(event) this.eventSource.append(entry) // After the feed append: the conversation assembly's animation frame is // registered by the feed subscribers above, so the echo-retirement frame // scheduled here always runs after the durable node became renderable. this.observeSubmissionEvent(event) - return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn + return awaitingFirstTurn !== this.firstPromptPendingTurn } - /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ + /** Observe durable acceptance even when insertion and claim share one projection notification. */ private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { - if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return + if (this.submissionSettlements.size === 0) return + if (event.type === 'agent/inbox/spliced') { + const splice = event.data as { readonly inserted?: unknown } | undefined + if (Array.isArray(splice?.inserted)) { + for (const message of splice.inserted) this.observeSubmissionEvent({ type: 'user/message', data: message }) + } + return + } + if (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 // assembly matchers). @@ -734,12 +722,15 @@ export class Session implements SessionFace { this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content)) } - /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ - private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void { + /** Retire local echoes when their accepted messages appear in the durable Inbox projection. */ + private observeSubmissionInbox(): void { if (this.submissionSettlements.size === 0) return - for (const item of items) { - if (item.rpcId !== undefined) { - this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content)) + const inbox = this.projections.get('inbox') as InboxState | undefined + if (inbox === undefined) return + for (const message of [...inbox['next-turn'], ...inbox['next-step']]) { + const source = message.source + if (source.kind === 'user' && 'rpcId' in source) { + this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content)) } } } @@ -795,7 +786,6 @@ export class Session implements SessionFace { private buildSnapshot(): SessionSnapshot { return { sessionId: this.sessionId, - queue: this.queueMirror.snapshot(), pendingSubmissions: this.pendingSubmissions, running: this.running, subagent: this.address === undefined diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 35fc113408..3aa12ba5c8 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -417,11 +417,11 @@ export class SessionCommandController { } /** - * Mutate one still-pending queue occurrence without resuming a cold Agent. + * Mutate one pending Inbox occurrence, restoring an ordinary cold Agent when needed. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ - updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + async updateQueue(request: SessionUpdateQueueRequest): Promise { if (request.action.kind === 'edit') { if (request.action.content.some(block => block.type !== 'text')) { throw new RemoteError( @@ -438,9 +438,14 @@ export class SessionCommandController { ) } } - const agent = this.ctx.agents.get(request.sessionId) + let agent = this.ctx.agents.get(request.sessionId) if (agent === undefined) { - throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + const found = await this.agents.resolveAgent(request.sessionId) + if ('error' in found) { + if (found.error.code !== 'session/not-found') throw found.error + throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + } + agent = found.agent } if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { const identity = this.ctx.sessionProjections diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 9bdd5a05bf..3fb8d0032e 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,11 +1,11 @@ -/** Live Session queue, jobs, and projection state with reconnect baselines. */ +/** Live Session jobs and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent, InboxState } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { - Session, SessionId, UserMessage, + Session, SessionId, } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { @@ -14,7 +14,6 @@ import type { SessionJob, SessionProjectionBaseline, SessionProjectionValues, - SessionQueuedItem, } from './types.ts' /** Owns the Host-wide Session control stream. */ @@ -31,14 +30,6 @@ export class SessionControlController { value: value as JsonValue, seq, }) - if (key !== 'inbox') return - const agent = this.ctx.agents.get(session.id) - if (agent?.session !== session) return - this.broadcast({ - type: 'queue', - sessionId: session.id, - items: queueItemsFromInbox(value as InboxState), - }) }) ctx.inject(['jobs'], (jobsCtx) => { jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) @@ -73,15 +64,12 @@ export class SessionControlController { private baseline(): SessionControlBaseline { const sessions = this.ctx.sessions.list() - const queues = Object.create(null) as Record const jobs = Object.create(null) as Record for (const session of sessions) { const agent = this.ctx.agents.get(session.id) - queues[session.id] = agent?.session === session ? queueItems(agent) : [] jobs[session.id] = this.jobsFor(agent) } return { - queues, jobs, projections: this.projectionBaseline(sessions), } @@ -167,36 +155,6 @@ class ControlQueue { } } -function queueItems(agent: Agent): SessionQueuedItem[] { - return queueItemsFromInbox({ - 'next-turn': agent.inbox.nextTurn, - 'next-step': agent.inbox.nextStep, - }) -} - -function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] { - return [ - ...inbox['next-turn'].map(message => ({ - id: message.id, - placement: 'queued' as const, - ...promptRpcId(message), - message: { id: message.id, content: message.content as unknown as JsonValue[] }, - })), - ...inbox['next-step'].map(message => ({ - id: message.id, - placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, - ...promptRpcId(message), - message: { id: message.id, content: message.content as unknown as JsonValue[] }, - })), - ] -} - -/** Prompt-RPC identity carried by a browser-submitted message's user source. */ -function promptRpcId(message: UserMessage): Pick { - const source = message.source - return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} -} - function jobView(job: JobSnapshot): SessionJob { return { id: job.id, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 607baed3cf..eec5a27085 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -521,19 +521,6 @@ export type SessionFollowFrame = | SessionEventEntry | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame } -/** One pending inbox occurrence in the authoritative queue snapshot. */ -export interface SessionQueuedItem { - readonly id: MessageId - readonly placement: 'queued' | 'steering' | 'context' - /** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */ - readonly rpcId?: SessionRequestId - /** JSON-safe message fields consumed by pending-queue presentation. */ - readonly message: { - readonly id: MessageId - readonly content: readonly JsonValue[] - } -} - /** Browser-safe background-job row. */ export interface SessionJob { readonly id: JobId @@ -547,7 +534,6 @@ export interface SessionJob { /** Complete live control baseline emitted once per control stream generation. */ export interface SessionControlBaseline { - readonly queues: Readonly> readonly jobs: Readonly> readonly projections: Readonly> } @@ -563,7 +549,6 @@ export interface SessionProjectionUpdate { /** Host-wide live state stream. Each generation starts with exactly one baseline. */ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } - | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] } | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index d7559bc047..3103350ad8 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -1,3 +1,4 @@ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent' @@ -93,7 +94,9 @@ async function commandHarness( assembled: undefined, } const agents = { - resolveAgent: () => Promise.resolve({ agent }), + resolveAgent: (id: SessionId) => Promise.resolve(id === agent.id + ? { agent } + : { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }), selectionFor: () => selection, serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), composeAgent: () => Promise.resolve({ setup: () => {} }), @@ -113,6 +116,23 @@ async function expectFailure(operation: Promise, code: string): Promise } describe('Session queue commands', () => { + it('preserves the cold Agent resolver rejection', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' }) + const controller = new SessionCommandController(ctx, { + resolveAgent: () => Promise.resolve({ error }), + } as unknown as ApiSessionAgentController, '/workspace') + try { + await expect(controller.updateQueue({ + sessionId: SessionId('cold-child'), itemId: MessageId('pending'), action: { kind: 'remove' }, + })).rejects.toBe(error) + } finally { + await ctx.fiber.dispose() + } + }) + it('edits, removes, steers, and rejects stale queue occurrences', async () => { const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness() const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) @@ -155,7 +175,7 @@ describe('Session queue commands', () => { await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, })), 'session/steer-unavailable') - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, @@ -164,14 +184,14 @@ describe('Session queue commands', () => { // An edit rewrites content in place, so the occurrence a client addressed // by id stays addressable. expect(inbox.nextTurn[0]?.id).toBe(queued.id) - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' }, })).toEqual({ accepted: true }) Object.assign(agent, { status: 'running' }) const steered = inbox.nextTurn[0] if (steered === undefined) throw new Error('missing edited queue item') - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' }, })).toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(steered) @@ -184,7 +204,7 @@ describe('Session queue commands', () => { source: { kind: 'user', rpcId: 'file-rpc' as never }, }) inbox.append('next-turn', queuedFile) - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' }, })).toEqual({ accepted: true }) expect(steer).toHaveBeenLastCalledWith(queuedFile) @@ -214,7 +234,7 @@ describe('Session queue commands', () => { inbox.append('next-turn', queued) inbox.append('next-step', context) - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: context.id, action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] }, @@ -226,10 +246,10 @@ describe('Session queue commands', () => { }) expect(editedContext?.id).toBe(context.id) if (editedContext === undefined) throw new Error('missing edited context') - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' }, })).toEqual({ accepted: true }) - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, })).toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(queued) @@ -251,7 +271,7 @@ describe('Session queue commands', () => { // command must accept whichever boundary `Agent.steer()` selects. steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) }) - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: agent.id, itemId: first.id, action: { kind: 'steer' }, })).toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(first) diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index a6073c5f2a..c4d4d17e1e 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -426,7 +426,7 @@ describe('Session file uploads', () => { await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) const queued = followup.mock.calls[0]?.[0] as UserMessage agent.inbox.append('next-turn', queued) - expect(controller.updateQueue({ + expect(await controller.updateQueue({ sessionId: SESSION, itemId: queued.id, action: { kind: 'remove' }, diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index b3f1d9b19d..3a44506ea6 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { afterEach, describe, expect, it } from 'vitest' @@ -37,15 +37,15 @@ function message(text: string, source: 'user' | 'plugin' = 'user') { }) } -describe('Session control queue projection', () => { - /** Consume frames until the next queue replacement (inbox projection frames interleave). */ - async function nextQueueFrame( +describe('Session control Inbox projection', () => { + /** Consume frames until the next durable Inbox value. */ + async function nextInboxFrame( iterator: AsyncIterator, - ): Promise> { + ): Promise> { for (;;) { const next = await iterator.next() - if (next.done) throw new Error('stream ended before a queue frame') - if (next.value.type === 'queue') return next.value + if (next.done) throw new Error('stream ended before an Inbox value') + if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value } } @@ -64,23 +64,21 @@ describe('Session control queue projection', () => { expect(opened.value).toMatchObject({ type: 'baseline', value: { - queues: { - 'queue-session': [ - { id: queued.id, placement: 'queued' }, - { id: steering.id, placement: 'steering' }, - { id: context.id, placement: 'context' }, - ], + projections: { + 'queue-session': { values: { inbox: { + 'next-turn': [queued], 'next-step': [steering, context], + } } }, }, }, }) const replacement = message('replacement') inbox.append('next-turn', replacement) - const replaced = await nextQueueFrame(iterator) - expect(replaced.items.map(item => item.id)).toContain(replacement.id) + const replaced = await nextInboxFrame(iterator) + expect(replaced.value).toMatchObject({ 'next-turn': [queued, replacement] }) inbox.remove(steering.id) - const removed = await nextQueueFrame(iterator) - expect(removed.items.map(item => item.id)).not.toContain(steering.id) + const removed = await nextInboxFrame(iterator) + expect(removed.value).toMatchObject({ 'next-step': [context] }) abort.abort() await iterator.next() @@ -108,9 +106,6 @@ describe('Session control queue projection', () => { value: { 'next-turn': [{ id: pending.id }], 'next-step': [] }, }, }) - await expect(nextQueueFrame(iterator)).resolves.toMatchObject({ - items: [{ id: pending.id, placement: 'queued' }], - }) abort.abort() await iterator.next() @@ -129,33 +124,28 @@ describe('Session control queue projection', () => { const iterator = control.control(abort.signal)[Symbol.asyncIterator]() const opened = await iterator.next() if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') - const items = opened.value.value.queues['queue-session' as SessionId] ?? [] - expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([ - { id: identified.id, placement: 'queued', rpcId: 'req-42' }, - { id: items[1]?.id, placement: 'steering', rpcId: undefined }, - ]) - expect('rpcId' in (items[1] ?? {})).toBe(false) + const inboxValue = opened.value.value.projections['queue-session' as SessionId]?.values.inbox as unknown as InboxState + expect(inboxValue['next-turn'][0]?.source).toMatchObject({ kind: 'user', rpcId: 'req-42' }) + expect(inboxValue['next-step'][0]?.source).toEqual({ kind: 'user' }) abort.abort() await iterator.next() }) - it('ignores inbox events without the exact live Agent session', async () => { - const { ctx, control, agent, inbox } = await harness() + it('publishes Inbox values for sessions without a live Agent', async () => { + const { ctx, control } = await harness() const abort = new AbortController() const iterator = control.control(abort.signal)[Symbol.asyncIterator]() await iterator.next() - - const unrelated = ctx.sessions.create(SessionId('unrelated-queue')) - unrelated.append('agent/inbox/spliced', { - target: 'next-turn', - start: 0, - inserted: [message('unrelated')], + const session = ctx.sessions.create(SessionId('unattached-inbox')) + const pending = message('unattached') + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, inserted: [pending], + }) + expect(ctx.agents.get(session.id)).toBeUndefined() + await expect(nextInboxFrame(iterator)).resolves.toMatchObject({ + sessionId: session.id, value: { 'next-turn': [pending], 'next-step': [] }, }) - const replacement = ctx.sessions.create(SessionId('replacement-session')) - Object.defineProperty(agent, 'session', { configurable: true, value: replacement }) - inbox.append('next-turn', message('wrong-session')) - abort.abort() await iterator.next() }) @@ -183,14 +173,14 @@ describe('Session control queue projection', () => { inbox.append('next-turn', first) inbox.append('next-turn', second) - const queues: Extract[] = [] + const values: InboxState[] = [] ownedContexts.delete(ctx) await ctx.fiber.dispose() for (;;) { const next = await iterator.next() if (next.done) break - if (next.value.type === 'queue') queues.push(next.value) + if (next.value.type === 'projection' && next.value.key === 'inbox') values.push(next.value.value as unknown as InboxState) } - expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]]) + expect(values.map(value => value['next-turn'].map(item => item.id))).toEqual([[first.id], [first.id, second.id]]) }) }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts deleted file mode 100644 index 573e4cfa7f..0000000000 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Queue snapshot semantics: authoritative replacement after every host-side - * change, reconnect re-baselining, pre-instantiation buffering, editable-text - * projection, and snapshot reference stability. - */ -import { describe, expect, it, vi } from 'vitest' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' -import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' -import { Session } from '../src/client/sessions/session.ts' -import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' - -const SID = 'fk-q1' as SessionId -const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] -const rid = (id: string): RpcId => id as RpcId -const iid = (id: string): MessageId => id as MessageId - -interface QueueFixture { - id: string - body: string - content?: ContentBlock[] - placement?: 'queued' | 'steering' - message?: UserMessage -} - -/** Build one authoritative queue snapshot. */ -function queueFrame(items: QueueFixture[]): Extract { - return { - type: 'queue', - sessionId: SID, - items: items.map(item => ({ - id: iid(item.id), - placement: item.placement ?? 'queued', - message: (item.message ?? createUserMessage({ - content: item.content ?? text(item.body), - source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, - })) as never, - })), - } -} - -function makeSession(): Session { - return makeBench().session -} - -function makeBench(): { api: FakeApiClient; session: Session } { - const api = new FakeApiClient() - return { api, session: new Session(SID, fakeRemote(api)) } -} - -function makeManager(): SessionManager { - const api = new FakeApiClient() - return new SessionManager(fakeRemote(api)) -} - -describe('Session queue snapshot intake', () => { - it('projects stable ids, flat previews, and complete text', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([ - { id: 'q-1', body: '第一条 排队\n消息' }, - ])) - const queue = session.getSnapshot().queue - expect(typeof queue[0]?.messageId).toBe('string') - expect(queue).toMatchObject([ - { - id: 'q-1', placement: 'queued', - content: [{ type: 'text', text: '第一条 排队\n消息' }], - preview: '第一条 排队 消息', text: '第一条 排队\n消息', - }, - ]) - }) - - it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ - id: 'q-image', - body: '', - content: [ - { type: 'text', text: 'hi' }, - { type: 'image', data: 'x' } as never, - { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never, - ], - }])) - const queue = session.getSnapshot().queue - expect(typeof queue[0]?.messageId).toBe('string') - expect(queue).toMatchObject([ - { - id: 'q-image', placement: 'queued', - content: [ - { type: 'text', text: 'hi' }, - { type: 'image', data: 'x' }, - { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } }, - ], - // Attachment blocks render from `content`, so the preview carries - // only text; other foreign blocks keep their marker. - preview: 'hi', text: null, - }, - ]) - }) - - it('caps previews at 200 code points and preserves the full editable text', () => { - const session = makeSession() - const body = '长'.repeat(201) - session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) - const row = session.getSnapshot().queue[0] - expect(Array.from(row?.preview ?? '')).toHaveLength(201) - expect(row?.preview.endsWith('…')).toBe(true) - expect(row?.text).toBe(body) - }) - - it('replaces content, order, and membership from each authoritative frame', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([ - { id: 'q-1', body: 'one' }, - { id: 'q-2', body: 'two' }, - ])) - session.handleControlFrame(queueFrame([ - { id: 'q-2', body: 'two edited' }, - ])) - const queue = session.getSnapshot().queue - expect(typeof queue[0]?.messageId).toBe('string') - expect(queue).toMatchObject([ - { - id: 'q-2', placement: 'queued', - content: [{ type: 'text', text: 'two edited' }], - preview: 'two edited', text: 'two edited', - }, - ]) - session.handleControlFrame(queueFrame([])) - expect(session.getSnapshot().queue).toEqual([]) - }) - - it('keeps the queue array reference stable across unrelated snapshot swaps', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) - const before = session.getSnapshot().queue - session.handleAgentError('unrelated') - expect(session.getSnapshot().queue).toBe(before) - }) - - it('retains steering placement and complete content in the same authoritative snapshot', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([ - { id: 'q-next', body: 'later' }, - { id: 's-now', body: 'interrupt now', placement: 'steering' }, - ])) - - expect(session.getSnapshot().queue.map(item => ({ - id: item.id, placement: item.placement, content: item.content, - }))).toEqual([ - { id: 'q-next', placement: 'queued', content: text('later') }, - { id: 's-now', placement: 'steering', content: text('interrupt now') }, - ]) - }) - - it('hands off exactly one current occurrence when live steering becomes durable', async () => { - const { api, session } = makeBench() - await session.open() - const message = createUserMessage({ - content: text('same message'), - source: { kind: 'user' }, - }) - session.handleControlFrame(queueFrame([ - { id: 's-first', body: '', placement: 'steering', message }, - { id: 's-second', body: '', placement: 'steering', message }, - ])) - const durable = { - seq: SessionSeq(0), - time: 1_700_000_000_000, - type: 'user/message', - surfaceOp: 'append', - data: message, - } satisfies SessionEvent - - await api.pushFollow(SID, { type: 'event', event: durable as never }) - await vi.waitFor(() => { - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - }) - - session.handleControlFrame(queueFrame([ - { id: 's-later', body: '', placement: 'steering', message }, - ])) - await api.pushFollow(SID, { type: 'event', event: durable as never }) - await vi.waitFor(() => { - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) - }) - }) - - it('hands off live steering when the agent claims it as a user message', async () => { - const { api, session } = makeBench() - await session.open() - const message = createUserMessage({ - content: text('claimed steering'), - source: { kind: 'user' }, - }) - session.handleControlFrame(queueFrame([ - { id: 's-claimed', body: '', placement: 'steering', message }, - ])) - - await api.pushFollow(SID, { - type: 'event', - event: { - seq: 0, - time: 1_700_000_000_000, - type: 'user/message', - surfaceOp: 'append', - data: message, - } as never, - }) - - await vi.waitFor(() => { - expect(session.getSnapshot().queue).toEqual([]) - }) - }) -}) - -describe('queue operation transport', () => { - it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) - session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) - const before = session.getSnapshot().queue - - await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) - .resolves.toEqual({ ok: true, value: { accepted: true } }) - await expect(session.updateQueue(iid('q-op'), { kind: 'steer' })) - .resolves.toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.updateQueue')).toEqual([ - { - sessionId: SID, - itemId: 'q-op', - action: { kind: 'edit', content: text('next') }, - }, - { - sessionId: SID, - itemId: 'q-op', - action: { kind: 'steer' }, - }, - ]) - expect(session.getSnapshot().queue).toBe(before) - }) -}) - -describe('queue reconnect semantics', () => { - it('a control baseline clears stale state before a fresh update lands', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) - session.replaceControl([]) - expect(session.getSnapshot().queue).toEqual([]) - session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }])) - expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) - }) - - it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { - const session = makeSession() - await session.open() - session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) - await session.resync() - expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) - }) - - it('running-status changes never guess at queue retirement', () => { - const session = makeSession() - session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) - session.handleRunning(true) - session.handleRunning(false) - expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) - }) -}) - -describe('manager buffering of queue snapshots', () => { - it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = makeManager() - manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) - manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) - expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) - }) - - it('a control baseline replaces the prior queue', () => { - const manager = makeManager() - manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) - const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items - manager.handleControlFrame({ - type: 'baseline', - value: { - queues: { [SID]: nextQueue }, - jobs: {}, - projections: {}, - }, - }) - const snapshot = manager.get(SID).getSnapshot() - expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) - }) -}) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7bfe9732eb..94e82c6556 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -8,13 +8,13 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek- import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createInboxStub, mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import AttachmentStore from '@deepseek-ai/dsh-attachment' @@ -22,6 +22,7 @@ import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, type SessionPersistenceSnapshot, + type SessionHandle, SessionAccess, } from '@deepseek-ai/dsh-session-persistence' import { createSessionTestRemote, @@ -273,8 +274,8 @@ describe('cold history recovery view', () => { describe('Remote Agent and Session lookup policy', () => { it('resumes a cold session before mutating a restored queue row', async () => { const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) const sessionId = sid('session-cold-queue-mutation') const meta = header(sessionId, 1000) const message = createUserMessage({ @@ -290,35 +291,19 @@ describe('Remote Agent and Session lookup policy', () => { providePersistence(ctx, { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events }), - locate: () => undefined, - }) - let resumedAgent: Agent | undefined - const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { - const session = ctx.sessions.create(sessionId, { - seed: events, - meta: { cwd: '/proj', createdAt: meta.createdAt }, - }) - resumedAgent = { - id: session.id, - options: {}, - session, - inbox: undefined as never, - status: 'idle', - ctx, - send() {}, - followup() {}, - steer() {}, - inject() {}, - cancel() {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } satisfies Agent - Object.assign(resumedAgent, { - inbox: new Inbox(ctx, resumedAgent.session, agentEvents(ctx, resumedAgent)), - }) - ctx.agents.register(resumedAgent) - return { agent: resumedAgent, dispose: () => Promise.resolve() } + open: (_id: SessionId, access: SessionAccess): Promise => Promise.resolve({ + id: sessionId, + header: meta, + inheritedEventCount: SessionLogOffset(0), + access, + read: () => Promise.resolve({ eventState: 'detached', events: structuredClone(events) }), + append: (appended) => { events.push(...appended); return Promise.resolve() }, + flush: () => Promise.resolve(), + close: () => Promise.resolve(), + [Symbol.asyncDispose]: () => Promise.resolve(), + }), }) + const resume = vi.spyOn(ctx.agents, 'resume') const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', @@ -332,11 +317,13 @@ describe('Remote Agent and Session lookup policy', () => { expect(response).toEqual({ ok: true, value: { accepted: true } }) expect(resume).toHaveBeenCalledOnce() + const resumedAgent = ctx.agents.get(sessionId) expect(resumedAgent?.inbox.nextTurn).toEqual([]) - expect(resumedAgent?.session.events.at(-1)).toMatchObject({ + expect(resumedAgent?.session.snapshotEvents().at(-1)).toMatchObject({ type: 'agent/inbox/spliced', data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, }) + await ctx.fiber.dispose() }) it('keeps queue-item-not-found for a cold session when no persistence backend is composed', async () => { @@ -355,7 +342,7 @@ describe('Remote Agent and Session lookup policy', () => { })) expect(response.ok).toBe(false) - if (!response.ok) expect(response.error.code).toBe('queue-item-not-found') + if (!response.ok) expect(response.error.code).toBe('session/queue-item-not-found') }) it('deduplicates a cold resume across Agent and Session parameters', async () => { @@ -473,8 +460,7 @@ describe('Remote Agent and Session lookup policy', () => { const resolution = lookup.resolve(sessionId) - await expect(resolution).rejects.toBeInstanceOf(TypertLookupFailure) - await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + await expect(resolution).rejects.toMatchObject({ code: 'session/agent-busy' }) }) }) 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 46cfe1c049..8eed0d1a9b 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 @@ -7,7 +7,7 @@ import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh- import { RemoteError } 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 type { SessionRequestId } from '../src/types.ts' import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' import { historyValue } from './event-script.client.ts' @@ -60,21 +60,17 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly At } as unknown as SessionEvent } -function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem { - return { - id: 'm-queued' as SessionQueuedItem['id'], - placement: 'queued', - rpcId, - message: { - id: 'm-queued' as SessionQueuedItem['id'], - content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'], - }, - } +function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) { + return createUserMessage({ + source: { kind: 'user', rpcId }, + content: refs.map(attachmentBlock), + }) } /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ -function settleFrames(): Promise { - return new Promise(resolve => setTimeout(resolve, 0)) +async function settleFrames(): Promise { + await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 0)) } describe('beginSubmission', () => { @@ -185,6 +181,25 @@ describe('observed retirement', () => { expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) }) + it('retires an accepted echo when a claim clears the projection before its notification', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const onRetire = vi.fn() + const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire }) + const refs = [imageRef('claimed-image')] + const message = queuedItem(handle.requestId, refs) + session.projections.apply('inbox', { 'next-turn': [], 'next-step': [message] }, SessionSeq(0)) + session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(1)) + await api.pushFollow(SID, { type: 'event', event: { + type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, + data: { target: 'next-step', start: 0, inserted: [message] }, + } as never }) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs }) + }) + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { const { session } = makeSession() const retirements: PendingSubmissionRetirement[] = [] @@ -196,12 +211,14 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] - session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, refs)], 'next-step': [] }, SessionSeq(1)) await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) // The queue projection keeps the correlation id for render-time dedupe. - expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) + expect(session.projections.get('inbox')).toMatchObject({ + 'next-turn': [{ source: { rpcId: handle.requestId } }], + }) }) it('retires a mixed echo with durable references in original selection order', async () => { @@ -263,9 +280,7 @@ describe('observed retirement', () => { attachments: [], onRetire: retirement => retirements.push(retirement), }) - session.handleControlFrame({ - type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])], - }) + session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, [])], 'next-step': [] }, SessionSeq(1)) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never, }) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 90ffb10456..3339a97d14 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -236,7 +236,10 @@ describe('session.history projections block', () => { }) it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => { - const { ctx } = await harness(true) + const ctx = new Context() + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + await mountAgentLoopTestHarness(ctx) const coldId = SessionId('cold-persisted-queue') const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 1, cwd: '/tmp', isSeeded: false } const message = createUserMessage({ diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 8ff8eca3e0..85949e7490 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -6,6 +6,7 @@ import type { ConversationTimelineSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' @@ -139,7 +140,7 @@ function openFailureMessage(error: unknown, fallback: string): string { function observedRpcIds( order: readonly string[], nodes: ChatSnapshot['nodes'], - queue: readonly { readonly rpcId?: string }[], + inbox: InboxState | undefined, ): ReadonlySet { const observed = new Set() for (const key of order) { @@ -150,8 +151,8 @@ function observedRpcIds( | undefined if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) } - for (const item of queue) { - if (item.rpcId !== undefined) observed.add(item.rpcId) + for (const { source } of [...inbox?.['next-turn'] ?? [], ...inbox?.['next-step'] ?? []]) { + if (source.kind === 'user' && 'rpcId' in source) observed.add(source.rpcId) } return observed } @@ -233,7 +234,7 @@ export function ChatView({ [turnNavigationItems, turnOutline], ) const timeline = useChat(s => s.timeline) - const inbox = useSession(s => s.queue) + const inbox = useProjection('inbox') as unknown as InboxState | undefined // 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) @@ -281,7 +282,7 @@ export function ChatView({ }, []) const pendingSteering = useMemo( - () => inbox.filter(item => item.placement === 'steering'), + () => inbox?.['next-step'].filter(message => message.source.kind === 'user') ?? [], [inbox], ) const pendingSubmissions = useSession(s => s.pendingSubmissions) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 5e9033bdd0..7da7ba213e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' @@ -60,10 +61,13 @@ beforeEach(() => { const SID = 's1' as SessionId type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } -function sessionSnapshot(overrides: Partial = {}): SessionSnapshot { +interface TestSessionSnapshot extends SessionSnapshot { + readonly testInbox?: InboxState +} + +function sessionSnapshot(overrides: Partial = {}): TestSessionSnapshot { return { sessionId: SID, - queue: [], pendingSubmissions: [], running: false, removed: false, @@ -82,11 +86,11 @@ function sessionSnapshot(overrides: Partial = {}): SessionSnaps } /** Scripted Session source: set() swaps the top-level object like the real Controller binding. */ -function makeSessionSource(init: Partial = {}) { +function makeSessionSource(init: Partial = {}) { let snap = sessionSnapshot(init) const subs = new Set<() => void>() return { - set: (next: Partial) => { + set: (next: Partial) => { snap = { ...snap, ...next } for (const fn of [...subs]) fn() }, @@ -103,7 +107,7 @@ function makeSessionSource(init: Partial = {}) { type ChatSlice = Partial & { readonly turnUsages?: NonNullable[0]>['turnUsages'] } -type HarnessUpdate = ChatSlice & Partial & { readonly chat?: ChatSnapshot } +type HarnessUpdate = ChatSlice & Partial & { readonly chat?: ChatSnapshot } /** Scripted Chat target source, independent from Session lifecycle state. */ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { @@ -226,7 +230,7 @@ function bindKeyedSnapshotSelector( function makeHarness( init: HarnessUpdate = {}, - sessionOverrides: Partial = {}, + sessionOverrides: Partial = {}, chatSnapshot?: ChatSnapshot, ) { const { @@ -242,6 +246,7 @@ function makeHarness( ...(turnUsages === undefined ? {} : { turnUsages }), } const session = makeSessionSource({ ...sessionInit, ...sessionOverrides }) + const useTestSession = bindSnapshotSelector(session.source) const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot) const useChatNode = bindKeyedSnapshotSelector( key => chatSource.source.getSnapshot().nodes.source(key), @@ -378,7 +383,10 @@ function makeHarness( createSnapshotStore(new Map()), ), useWorkspaces: emptyWorkspaces(), - useProjection: () => outlineValue, + useProjection: (key: string) => { + const inbox = useTestSession(snapshot => snapshot.testInbox) + return key === 'inbox' ? inbox : outlineValue + }, useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, @@ -949,23 +957,23 @@ describe('ChatView', () => { }) const pending = { id: 'steer-occurrence' as never, - messageId: 'steer-message' as never, - placement: 'steering' as const, + role: 'user' as const, + source: { kind: 'user' as const }, content: [{ type: 'text' as const, text: 'interrupt now' }], preview: 'interrupt now', text: 'interrupt now', } const queued = { id: 'queued-occurrence' as never, - messageId: 'queued-message' as never, - placement: 'queued' as const, + role: 'user' as const, + source: { kind: 'user' as const }, content: [{ type: 'text' as const, text: 'later' }], preview: 'later', text: 'later', } const h = makeHarness( { nodes: [assistant(1, 'working')] }, - { queue: [queued, pending], running: true }, + { testInbox: { 'next-turn': [queued], 'next-step': [pending] }, running: true }, ) const view = render() @@ -980,12 +988,12 @@ describe('ChatView', () => { & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) act(() => { - h.setSession({ queue: [queued] }) + h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } }) h.setChat({ nodes: [ assistant(1, 'working'), { - kind: 'steering', messageId: pending.messageId, + kind: 'steering', messageId: pending.id, seq: 2, time: 2_000, content: [{ type: 'text', text: 'interrupt now' }], source: null, }, @@ -1017,8 +1025,8 @@ describe('ChatView', () => { it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { const pending = { id: 'steer-occurrence-later' as never, - messageId: 'shared-steer-message' as never, - placement: 'steering' as const, + role: 'user' as const, + source: { kind: 'user' as const }, content: [{ type: 'text' as const, text: 'same steering' }], preview: 'same steering', text: 'same steering', @@ -1028,7 +1036,7 @@ describe('ChatView', () => { kind: 'user', seq: 2, time: 2_000, content: pending.content, source: null, }], - }, { queue: [pending], running: true }) + }, { testInbox: { 'next-turn': [], 'next-step': [pending] }, running: true }) const view = render() expect(view.getAllByText('same steering')).toHaveLength(2) @@ -1094,15 +1102,12 @@ describe('ChatView', () => { act(() => { h.setSession({ - queue: [{ + testInbox: { 'next-turn': [], 'next-step': [{ id: 'steer-occurrence' as never, - messageId: 'steer-message' as never, - placement: 'steering', - rpcId: 'req-steer' as never, content: [{ type: 'text', text: '带图纠偏' }], - preview: '带图纠偏', - text: '带图纠偏', - }], + + role: 'user', source: { kind: 'user', rpcId: 'req-steer' as never }, + }] }, }) }) expect(view.getAllByText('带图纠偏')).toHaveLength(1) @@ -1127,15 +1132,12 @@ describe('ChatView', () => { expect(view.queryByText('排队中')).toBeNull() act(() => { h.setSession({ - queue: [{ + testInbox: { 'next-step': [], 'next-turn': [{ id: 'q-occurrence' as never, - messageId: 'q-message' as never, - placement: 'queued' as const, - rpcId: 'req-q' as never, content: [{ type: 'text' as const, text: '排队中' }], - preview: '排队中', - text: '排队中', - }], + + role: 'user', source: { kind: 'user', rpcId: 'req-q' as never }, + }] }, }) }) // The queued occurrence and its local predecessor both belong to the @@ -2152,14 +2154,12 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { - h.setSession({ queue: [{ + h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{ id: 'steering-occurrence' as never, - messageId: 'steering-message' as never, - placement: 'steering', content: [{ type: 'text', text: 'also' }], - preview: 'also', - text: 'also', - }] }) + + role: 'user', source: { kind: 'user' }, + }] } }) }) expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a1eea2e97a..cbcb952003 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 19a210bb200dae359920e85ff944b68242f45b4b -README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b +README.md: 7d2b81362964793020581a09c5ff43e8a76129f5 +README.zh.md: 05eadc5e9d022d5b6876f788602fd1e700ce2f94 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 19a210bb20..7d2b813629 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -44,7 +44,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c5a56871eb..05eadc5e9d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -44,7 +44,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 987aa9574f..63959771be 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -93,7 +93,8 @@ "@lexical/utils": "^0.49.0", "@lexical/text": "^0.49.0", "@lexical/plain-text": "^0.49.0", - "@lexical/history": "^0.49.0" + "@lexical/history": "^0.49.0", + "@deepseek-ai/dsh-agent": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/contract/queue.ts b/packages/client/ui-conversation/src/client/contract/queue.ts deleted file mode 100644 index 4e54568448..0000000000 --- a/packages/client/ui-conversation/src/client/contract/queue.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Queue contracts derived from the Session Controller face. */ -import type { SessionFace, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' - -/** One address accepted by the Session Controller's queue mutation verb. */ -export type QueueItemId = Parameters[0] - -/** One mutation accepted by the Session Controller's queue mutation verb. */ -export type QueueAction = Parameters[1] - -/** One row projected by the authoritative Session queue snapshot. */ -export type QueueRow = SessionSnapshot['queue'][number] diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 1cacd83812..7dc12bc259 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -9,6 +9,7 @@ * listeners onto it. */ import type { Context } from '@deepseek-ai/cordis' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' @@ -23,7 +24,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment, + Occurrence, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -53,8 +54,8 @@ export interface SessionInputDeps { inputTriggers?: (() => InputTriggerController | undefined) | undefined /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ popup?: (() => PopupDismissFace | undefined) | undefined - /** Queue read face; overlaid onto InputState.queue (absent = empty). */ - queue?: ObservableSnapshot | undefined + /** Agent Inbox projection; its next-turn list is overlaid onto InputState.queue. */ + inbox?: ObservableSnapshot | undefined /** * Steer every still-pending queued message into the running turn, in FIFO * order (the empty-draft accelerated-Enter gesture); absent = unsupported. @@ -97,7 +98,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection }) } -const EMPTY_QUEUE: readonly QueuedMessage[] = [] +const EMPTY_QUEUE: InboxState['next-turn'] = [] /** No-pipeline lexicon: zero text-ref decorations. */ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map() @@ -170,6 +171,8 @@ export class SessionInputShell implements SessionInput { readonly attachmentIds: readonly DraftAttachmentId[] }>() + private readonly unsubscribeInbox: (() => void) | undefined + constructor(private readonly deps: SessionInputDeps) { this.editor = createEditor({ namespace: 'dsh-composer', @@ -185,7 +188,7 @@ export class SessionInputShell implements SessionInput { () => { this.lexiconOff?.() }, ) this.state = createSnapshotStore(this.compose()) - deps.queue?.subscribe(() => { this.publish() }) + this.unsubscribeInbox = deps.inbox?.subscribe(() => { this.publish() }) } // ---- editor plumbing ---- @@ -579,6 +582,7 @@ export class SessionInputShell implements SessionInput { } this.disposed = true this.dispatchRun(({ type: 'release' })) + this.unsubscribeInbox?.() this.unregister() this.editor.setRootElement(null) this.detachedDrafts.clear() @@ -909,7 +913,7 @@ export class SessionInputShell implements SessionInput { phase: core.phase, ...(core.claim !== undefined ? { claim: core.claim } : {}), occurrences: this.projection.occurrences, - queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE, + queue: this.deps.inbox?.getSnapshot()?.['next-turn'] ?? EMPTY_QUEUE, } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index f255877ddb..d9e8f8fe52 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -13,7 +13,8 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' -import { queueReadFaceOf } from './queue-store.ts' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController, SessionInputResolver, SessionInput, SubmitOutcome, @@ -87,7 +88,7 @@ export class InputHub implements SessionInputResolver { actx, inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), - queue: queueReadFaceOf(session), + inbox: session.projections.faceOf('inbox') as ObservableSnapshot, defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, commandAttachments: { @@ -199,7 +200,8 @@ export class InputHub implements SessionInputResolver { * @param shell - the resident shell (notice outlet). */ private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise { - const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + const inbox = session.projections.faceOf('inbox').getSnapshot() as InboxState | undefined + const queued = inbox?.['next-turn'] ?? [] if (queued.length === 0) return for (const item of queued) { const result = await session.updateQueue(item.id, { kind: 'steer' }) diff --git a/packages/client/ui-conversation/src/client/input/queue-store.ts b/packages/client/ui-conversation/src/client/input/queue-store.ts deleted file mode 100644 index 09b10da0d0..0000000000 --- a/packages/client/ui-conversation/src/client/input/queue-store.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Queue read face for the InputState.queue projection (frozen contract in - * ../contract/input.ts): a uSES-compatible observable over one session's - * transient inbox rows. The Session snapshot already keeps the queue array - * reference-stable across unrelated snapshot swaps, so this is a pure - * projection — no second store, no copy. - */ -import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { QueuedMessage } from '../contract/input.ts' - -/** - * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot). - * The wiring layer overlays this onto InputState.queue; the runtime - * QueuedMessage and the input-contract QueuedMessage are structurally - * identical. - * @param session - the resident session face. - * @returns the queue read face (snapshot reference stable while the queue is unchanged). - */ -export function queueReadFaceOf(session: SessionFace): ObservableSnapshot { - return { - getSnapshot: () => session.getSnapshot().queue, - subscribe: fn => session.subscribe(fn), - } -} diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 0dcbdda890..d6be0140c7 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -8,26 +8,45 @@ import { DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import { NS } from '../locales.ts' import css from './QueueDock.module.css' +const EMPTY_QUEUE = [] as const +const QUEUE_PREVIEW_CHARS = 200 + +function previewOf(content: InboxState['next-turn'][number]['content']): string { + const flat = content + .filter(block => block.type !== 'image' && block.type !== 'file') + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +} + +function textOf(content: InboxState['next-turn'][number]['content']): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + /** Queue operations injected by the session-scoped registration. */ export interface QueueDockInjected { - updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise + updateQueue: (itemId: MessageId, action: QueueAction) => Promise notify: (level: 'info' | 'error', text: string) => void /** Resolve one durable queued image into a session-scoped browser URL. */ loadImage: (attachment: ImageAttachmentRef) => Promise } /** - * Durable references carried by one queued row. Queue frames are wire data + * Durable references carried by one queued row. Inbox projections are wire data * despite their typed face, so an image block without a reference is skipped * rather than trusted. * @param content - the row's wire content blocks. * @returns the row's durable image references in block order. */ -function queueAttachments(content: QueueRow['content']): Array< +function queueAttachments(content: InboxState['next-turn'][number]['content']): Array< | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } | { readonly type: 'file'; readonly attachment: FileAttachmentRef } > { @@ -87,12 +106,14 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock * collapsible count header; an empty queue renders nothing. Local submissions * show sending status and disabled actions until their Host queue rows arrive. */ -export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) { - const inbox = useSession(s => s.queue) - const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) +export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, t }: QueueDockProps) { + const inbox = useProjection('inbox') as unknown as InboxState | undefined + const queue = inbox?.['next-turn'] ?? EMPTY_QUEUE const pendingSubmissions = useSession(s => s.pendingSubmissions) const pendingQueue = useMemo(() => { - const admitted = new Set(queue.flatMap(row => row.rpcId === undefined ? [] : [row.rpcId])) + const admitted = new Set(queue.flatMap(({ source }) => ( + source.kind === 'user' && 'rpcId' in source ? [source.rpcId] : [] + ))) return pendingSubmissions.filter(submission => ( submission.placement === 'queued' && !admitted.has(submission.requestId) )) @@ -100,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que const rowCount = queue.length + pendingQueue.length const running = useSession(s => s.running) const queueMutable = useSession(s => s.subagent === null || s.subagent.address.mode === 'continuable') - const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) - const [busy, setBusy] = useState(null) + const [editing, setEditing] = useState<{ id: MessageId; text: string } | null>(null) + const [busy, setBusy] = useState(null) const [collapsed, setCollapsed] = useState(true) const listId = useId() @@ -117,7 +138,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que const listVisible = rowCount === 1 || expanded const applyAction = async ( - itemId: QueueItemId, + itemId: MessageId, action: QueueAction, failure: string, ): Promise => { @@ -167,6 +188,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que