Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining

# Conflicts:
#	apps/web/tests/snapshots/queue-actions/layout.expected.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/goal.i18n.yaml
#	docs/core-data-structures/goal.md
#	docs/core-data-structures/goal.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-report/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/context/time-context/src/index.ts
#	packages/context/tmux-context/src/index.ts
#	packages/core/agent-loop/src/agent.ts
#	packages/core/system-prompt/src/index.ts
#	packages/goal/goal/src/domain.ts
#	packages/goal/goal/src/index.ts
#	packages/goal/goal/src/render.ts
#	packages/plan/plan-mode/src/index.ts
This commit is contained in:
creatixchu
2026-08-06 11:49:03 +08:00
1357 changed files with 27674 additions and 18803 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/README.md
README.md: 63ca0f7711c2c9deb193d5a9a574ff909a04602e
README.zh.md: d4c1a85ef7dd33be5419245464d83da24756443a
README.md: d51ef73f7f545920f8cd527b2b0d597305ae86d0
README.zh.md: 729f41fafb6b9585a66e58fe8d6ac63c1f235acc

View File

@@ -6,15 +6,13 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
| Package | Role | ctx key |
|---|---|---|
| `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) |
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` |
| [`scope/`](scope/README.md) | Scoped-context registration primitive | library — no ctx key |
| [`session/`](session/README.md) | Event-sourced session log and in-memory store | `ctx.sessions` |
| [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` |
| [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` |
| [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` |
| [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` |
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
`scope` supplies the shared scoping primitive. `agent` owns the public seam, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable.
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + fallback session titles + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces.

View File

@@ -1,20 +1,18 @@
# core/产品 API 主干
# core/产品 API 主干
[English](README.md) | 中文
会话日志、系统提示词组装、工具注册表、agent智能体词汇,以及构成 harness 默认控制主干的具体循环。这些是 **产品** 包,插件和消费方以其稳定接口为基础构建。
构成 harness 默认控制主干的会话日志、系统提示词组装、工具注册表、agent智能体词汇具体循环。这些是**产品**包,插件和消费方构建所依赖的稳定 surface
| 包 | 角色 | ctx |
| 包 | 职责 | ctx key |
|---|---|---|
| `scope/` | 作用域上下文注册原语(作用域标签、按作用域筛选的分发) | 库,没有 ctx 键) |
| `session/` | 事件溯源会话日志内存存储 | `ctx.sessions` |
| `system-prompt/` | 提示词段与工具 schema 组装注册表 | `ctx.systemPrompt` |
| `tools/` | 作用域工具注册表,以及前置策略、守卫、环绕分发、后置策略与最终结果观测 | `ctx.tools` |
| `agent/` | Agent 接口、实时注册表、进程本地发起方作用域、`agent/*` 事件词汇 | `ctx.agents` |
| `agent-loop/` | 实现公开 `Agent` 契约并拥有循环驱动器的具体插件 | `ctx.agentLoop` |
| [`scope/`](scope/README.md) | 作用域上下文注册原语 | 库,不使用 ctx key |
| [`session/`](session/README.md) | 事件溯源会话日志内存存储 | `ctx.sessions` |
| [`system-prompt/`](system-prompt/README.md) | 提示词工具 schema 组装注册表 | `ctx.systemPrompt` |
| [`tools/`](tools/README.md) | 作用域工具注册表和执行流水线 | `ctx.tools` |
| [`agent/`](agent/README.md) | Agent 接口、注册表事件词汇 | `ctx.agents` |
| [`agent-loop/`](agent-loop/README.md) | 默认具体 agent 驱动器 | `ctx.agentLoop` |
`scope/` 是此处唯一的非服务包:它是不含依赖的库(`createScope`/`scopeOf`/`scopeTarget`),注册表和循环基于它实现按 agent 分域。它在模块图中位于 `session/``system-prompt/` 之下,正是为了让二者可以消费它而不形成环
`scope` 提供共享作用域原语。`agent` 负责公开 seam`agent-loop` 是其默认实现;扩展插件依赖该 seam从而保持驱动器可替换
`agent-loop``agent` seam 的唯一具体实现,位于此处是因为它就是 harness 的默认产品循环。它在 `ctx.agents.withInitiator()` 中运行每个驱动器。扩展插件依赖 `agent`,即使需要发起调用的 Agent 也是如此;它们绝不直接依赖 `agent-loop`,因此循环保持可替换。
将这条主干接成可运行 agent 的默认组合位于 [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md):一个 bundle组合包插件加载控制主干及所选默认能力`timer` + `llm` + 会话 + 后备会话标题 + 系统提示词 + 工具 + agent + 不变式 + 本地 [skill技能系列](../skill/README.md) + `tool-bash` + 工作区上下文 + `agent-loop`),并将 `agent-loop``agents` 列表作为自身配置转发。它位于 `examples/`,即开箱可运行的演示/参考组合包,而不是 `core/``core/` 交付可替换的主干组件,演示组合包则选定其中一种具体组合并添加一个对外交互入口。
可运行组合属于 [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md);该分组只负责可替换的主干组件。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 2ce85071c4b7408adb4ee05291c499ec642be114
README.zh.md: d0ef7f3d9bf3a2fe297171c4015f8500210e0155
README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f
README.zh.md: 5828da301b35c95719286fb942ac239769539b67

View File

@@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
@@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
@@ -53,13 +53,11 @@ Configured agents start automatically. A model call requires both `provider` and
### Internal concrete driver
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it.
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and strict steer transfers the immutable message into an open next-step window as a new steering occurrence. A closed window returns `steer-unavailable` without mutation; pending steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
### Loop lifecycle (`agent.ts`)
@@ -69,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results.
@@ -77,7 +75,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute` → definition-owned `finalizeContent``tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error`
- Compaction: pressure on `agent/pre-step`; canonical overflow repair on `agent/request-error`
- Model-request recovery: `dsh-llm-retry` records and waits exact-provider normal or unbounded backoff on `agent/request-error`, emits non-surface `llm/retry` status, then returns a retry action
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.

View File

@@ -10,7 +10,7 @@
### 公开 API
创建与恢复属于同一个受回滚保护的事务:构造私有会话、体 agent 和带作用域的上下文;等待可选 setup同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
创建与恢复属于同一个受回滚保护的事务:构造私有会话、体 agent 和带作用域的上下文;等待可选 setup进入两个注册表依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)``resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose资源释放或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。
@@ -20,8 +20,8 @@
`AgentLoop` 还实现 `AgentFactory` seam并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表`meta` 携带 cwd谱系seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)同一 id 重建历史,针对全新且尚未发布的 agent 作用域等待 setup调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回`meta` 携带 cwd谱系seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)使用同一 id 注册 agent重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup再执行受回滚保护发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`
配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent该路径会丢弃 handle。对于以编程方式创建的 agenthandle 持有者是唯一面向消费方的 teardown 能力AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。
@@ -53,13 +53,11 @@ interface Config {
### 包内部具体驱动器
`ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
`ReactLoopAgent`、其 inbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox但不唤醒驱动器。在轮次边界驱动器会先打开持久轮次再原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除,并关闭不含步骤的轮次;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、为派生历史创建快照并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected``cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交包括被中断批次中已最终确认的上下文steering 则保持待准入,直到请求接纳它
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard严格 steering 会把不可变消息作为新的 steering 单次入队项转移到开放的 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不做任何变更;待处理 steering 和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
每次 inbox 变更都会先发布一条规范化的 `agent/inbox/spliced` 事件,再修改实时投影。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }``MessageId` 在两个待处理列表之间保持唯一,同步持久事件观察方可以从 splice 前投影重建被移除的值
### 循环生命周期(`agent.ts`
@@ -69,7 +67,7 @@ interface Config {
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的字段并填入配置的推理reasoning强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall瀑布式事件循环会从提议中移除这些带标记字段使当前精确路由重新填入自身默认值未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`中间件、结果处理、工具及其他扩展失败直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。
@@ -77,7 +75,7 @@ interface Config {
超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件:
- 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute``tools/execute``tools/post-execute` → 定义拥有的 `finalizeContent``tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md)
- 压缩compaction`agent/step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复
- 压缩compaction`agent/pre-step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复
- 模型请求恢复:`dsh-llm-retry``agent/request-error` 上记录并等待按确切提供方配置的 normal 或无界退避,发出不进入表层的 `llm/retry` 状态,然后返回重试动作
- 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测
- subagent在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ import type {
SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
@@ -104,6 +104,30 @@ async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal,
}
}
/** Start an abortable operation and release a value that arrives after cancellation. */
async function raceAbortCall<T>(
operation: () => PromiseLike<T> | T,
signal: AbortSignal,
id: SessionId,
releaseAbandoned?: (value: T) => void,
): Promise<T> {
if (signal.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
const pending = Promise.resolve().then(operation)
try {
return await raceAbort(pending, signal, id)
} catch (error: unknown) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
if (signal.aborted && releaseAbandoned !== undefined) {
void pending.then(releaseAbandoned, () => undefined)
}
throw error
}
}
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
function resolveMaxParallelToolCalls(value: number | undefined): number {
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
@@ -442,18 +466,7 @@ export class AgentLoop extends Service implements AgentFactory {
if (machine === undefined) await machineReady.promise
if (machine !== undefined) {
machine.cancel({ kind: 'disposed' })
// Drain to TRUE quiescence: cancel's own synchronous event chain
// (running→idle) can legitimately re-enter through an automation
// listener (goal-session's idle drive) and replace `done` with a
// fresh admission before this await captures it. The replacement
// work is cancelled and drained in turn until the slot stabilizes.
let done = machine.done
while (true) {
await Promise.allSettled([done])
if (machine.done === done) break
done = machine.done
machine.cancel({ kind: 'disposed' })
}
await machine.whenIdle()
await machine.scope.dispose()
}
} finally {
@@ -510,7 +523,7 @@ export class AgentLoop extends Service implements AgentFactory {
loopCtx.agents.announce(agent)
assertLive()
// A synchronous announce/session-start listener may have started
// teardown; the machine is already live (send() works from the
// teardown; the machine is already live (delivery works from the
// session-start seam), so only the liveness recheck is owed.
emitAgentEvent(loopCtx, agent, 'agent/session-start', source)
assertLive()
@@ -535,8 +548,8 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the published running agent.
*/
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
const session = this.runtime.ctx.sessions.prepare(id, { meta })
const prepared = this.prepare(this.ctx, id, options, session)
using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
const prepared = this.prepare(this.ctx, id, options, preparation.session)
try {
return prepared.publish('startup').agent
} catch (error: unknown) {
@@ -552,14 +565,14 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the published handle.
*/
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
}))
const published = this.setupAndPublish(
ownerCtx,
options.sessionId,
session,
preparation,
options.agentOptions ?? {},
options.setup,
options.signal,
@@ -573,12 +586,14 @@ export class AgentLoop extends Service implements AgentFactory {
private async setupAndPublish(
ownerCtx: Context,
id: SessionId,
session: Session,
preparation: SessionPreparation,
agentOptions: AgentOptions,
setup: AgentSetup | undefined,
signal: AbortSignal | undefined,
source: SessionStartSource,
): Promise<AgentHandle> {
using ownedPreparation = preparation
const session = ownedPreparation.session
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
try {
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
@@ -624,26 +639,31 @@ export class AgentLoop extends Service implements AgentFactory {
ownerAbort.signal,
this.ownership.signal,
])
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
let preparation: SessionPreparation | undefined
try {
loaded = await raceAbort(persistence.load(id), fused, id)
try {
preparation = await raceAbortCall(
() => persistence.prepare(id, fused),
fused,
id,
(abandoned) => { abandoned[Symbol.dispose]() },
)
} finally {
await unfollowOwner()
}
ownerCtx.fiber.assertActive()
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
return await this.setupAndPublish(
ownerCtx,
id,
preparation,
options.agentOptions ?? {},
options.setup,
options.signal,
'resume',
)
} finally {
await unfollowOwner()
}
ownerCtx.fiber.assertActive()
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
const session = this.runtime.ctx.sessions.prepare(id, {
seed: loaded.events,
meta: loaded.meta,
})
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
try {
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
setupCommit?.commit()
return prepared.publish('resume')
} catch (error: unknown) {
await prepared.dispose()
throw error
preparation?.[Symbol.dispose]()
}
})()
this.ownership.trackWrapper(published)

View File

@@ -6,7 +6,7 @@
import type { Context } from 'cordis'
import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import { foldRequestHeader } from '@deepseek-ai/dsh-session'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
@@ -17,8 +17,7 @@ export const inject = ['invariants']
/** Install the request-reconstruction contribution into its child registration fiber. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
// Prepend prevents a short-circuiting replay listener from silencing the
// check; correctness itself comes from the sequence-bounded reconstruction.
// Prepend prevents a short-circuiting replay listener from silencing the check.
ctx.on('llm/stream', (options: GenerateOptions, next) => {
if (!isAgentLoopRequest(options)) return next()
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
@@ -30,27 +29,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
}
const events = session.events
let boundary = -1
for (let index = events.length - 1; index >= 0; index -= 1) {
if (events[index]?.type === 'step/start') {
boundary = index
break
}
}
if (boundary === -1) {
if (!events.some(event => event.type === 'step/start')) {
return fail('a loop-built request with no step/start in its session log')
}
const header = foldRequestHeader(events)
if (header === undefined) {
return fail('a loop-built request with no request/header event in its session log')
}
const rebuilt = Session.create(
SessionId(`${String(session.id)}-invariant-rebuild`),
structuredClone(events.slice(0, boundary)),
)
const expected = rebuilt.deriveMessages()
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
const headerMatches = options.model === header.config.model

View File

@@ -0,0 +1,76 @@
/**
* Durable projection state for dynamic runtime context.
* @module @deepseek-ai/dsh-agent-loop/runtime-context
*/
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
const SOURCE = '@deepseek-ai/dsh-system-prompt'
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
function isOwned(message: UserMessage): boolean {
return message.source.kind === 'plugin' && message.source.plugin === SOURCE
}
function textOf(message: UserMessage): string | undefined {
const [block] = message.content
return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
}
/** Tracks the last retained runtime-context snapshot without owning its commit. */
export class RuntimeContextProjection {
/** `undefined` means no snapshot ever existed; `null` means none is retained. */
private retained: { seq: number; text: string | undefined } | null | undefined
/**
* Restore projection state once, then follow authoritative session events.
* @param ctx - agent-scoped event context.
* @param session - session receiving projected messages.
*/
constructor(ctx: Context, session: Session) {
const surface = new Set(session.surface.nodes)
for (let index = session.events.length - 1; index >= 0; index -= 1) {
const event = session.events[index]
if (event?.type !== 'user/message' || !isOwned(event.data)) continue
this.retained ??= null
if (surface.has(event.seq)) {
this.retained = { seq: event.seq, text: textOf(event.data) }
break
}
}
ctx.on('session/event', (subject, event) => {
if (subject !== session) return
if (event.type === 'user/message' && isOwned(event.data)) {
this.retained = { seq: event.seq, text: textOf(event.data) }
} else if (this.retained
&& isReplacementSurfaceEvent(event)
&& event.sourceEventSeqs?.includes(this.retained.seq) === true) {
this.retained = null
}
})
}
/**
* Create an uncommitted snapshot only when the retained value differs.
* @param current - fully rendered dynamic context.
* @param sections - named contributions that formed the current snapshot.
* @returns a candidate user message, or `undefined` when no update is needed.
*/
project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined {
if (this.retained === undefined && current.length === 0) return
const snapshot = current.length === 0 ? CLEARED : current
if (this.retained?.text === snapshot) return
return createUserMessage({
content: [{ type: 'text', text: snapshot }],
// The cleared marker has no contributions left to attribute.
source: sections.length === 0
? { kind: 'plugin', plugin: SOURCE }
: { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections },
})
}
}

View File

@@ -42,7 +42,7 @@ interface GroupOutcome {
* Ordinary completion and abort commit started-call results in order. Abort
* drains them, records synthetic results for unstarted calls, and returns with
* the signal still aborted after accepting started-call context through the
* caller-supplied acceptor (the machine stages it on its outbox for the next
* caller-supplied acceptor (the machine stages it in its next-step inbox for the
* step boundary). An internal scheduler failure stops new dispatches, drains
* already-started dispatches, and rejects with the first failure without
* fabricating tool results.

View File

@@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => {
const { ctx } = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
let signals: AbortSignal[] = []
let admissionSignals: AbortSignal[] = []
let preStepSignals: AbortSignal[] = []
const capture = (signal: AbortSignal | undefined): void => {
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
expect(ctx.agents.requireInitiator()).toBe(agent)
@@ -164,16 +164,13 @@ describe('AgentLoop initiator scope', () => {
if (context.agent === agent) capture(context.signal)
return next()
})
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
if (subject === agent) {
expect(ctx.agents.requireInitiator()).toBe(agent)
admissionSignals.push(signal)
preStepSignals.push(signal)
}
return next()
})
ctx.on('agent/step', (subject, _turn, _step, signal) => {
if (subject === agent) capture(signal)
})
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
if (subject === agent) capture(signal)
return next()
@@ -197,19 +194,19 @@ describe('AgentLoop initiator scope', () => {
const firstSignal = signals[0]
expect(firstSignal).toBeDefined()
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).not.toBe(firstSignal)
expect(preStepSignals).toHaveLength(2)
expect(new Set(preStepSignals)).toEqual(new Set([firstSignal]))
signals = []
admissionSignals = []
preStepSignals = []
const secondIdle = waitForIdle(ctx, agent)
send(agent, 'second')
await secondIdle
const secondSignal = signals[0]
expect(secondSignal).toBeDefined()
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).not.toBe(secondSignal)
expect(preStepSignals).toHaveLength(1)
expect(preStepSignals[0]).toBe(secondSignal)
expect(secondSignal).not.toBe(firstSignal)
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()

View File

@@ -26,51 +26,17 @@ function send(agent: Agent, text: string): void {
}
describe('Agent', () => {
it('does not echo caller-owned message identities from delivery methods', async () => {
const adapter = new MockAdapter([
textResponse('one'),
textResponse('two'),
textResponse('three'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const message = (text: string) => createUserMessage({
content: [{ type: 'text' as const, text }],
source: { kind: 'user' as const },
})
const call = (method: 'send' | 'inject' | 'followup' | 'steer', args: unknown[]): unknown => {
const implementation: unknown = Reflect.get(agent, method)
if (typeof implementation !== 'function') throw new Error(`missing Agent.${method}`)
return Reflect.apply(implementation, agent, args)
}
expect(call('send', [message('quiet'), {
target: 'next-turn',
wakeup: false,
}])).toBeUndefined()
expect(call('inject', [message('context')])).toBeUndefined()
expect(call('followup', [message('followup')])).toBeUndefined()
const receipt = agent.steer(message('steering'))
await agent.whenIdle()
expect(adapter.requests).toHaveLength(3)
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 })
})
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
it('idle inject() durably stages context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }))
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
await agent.whenIdle()
expect(flushes).toBe(0)
})
it('inject() preserves an explicitly empty plugin source', async () => {
@@ -80,11 +46,49 @@ describe('Agent', () => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }))
const injected = agent.session.events.at(-1)
expect(injected?.type === 'user/message' && injected.data.source)
expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source)
.toEqual({ kind: 'plugin', plugin: '' })
})
it('idle inject() rejects invalid input before append', async () => {
it('emits exact inserted, claimed, and discarded inbox messages', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' })
const inserted: unknown[] = []
const claimed: unknown[] = []
const discarded: unknown[] = []
const lifecycle: string[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start')
})
ctx.on('agent/inbox/inserted', (subject, event) => {
if (subject === agent) inserted.push(event)
})
ctx.on('agent/inbox/claimed', (subject, event) => {
if (subject === agent) {
lifecycle.push('agent/inbox/claimed')
claimed.push(event)
}
})
ctx.on('agent/inbox/discarded', (subject, event) => {
if (subject === agent) discarded.push(event)
})
const context = createUserMessage({
content: [{ type: 'text', text: 'discard me' }],
source: { kind: 'plugin', plugin: 'test' },
})
agent.inject(context)
agent.inbox.remove(context.id)
const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } })
agent.followup(prompt)
await agent.whenIdle()
expect(inserted).toEqual([{ message: context }, { message: prompt }])
expect(discarded).toEqual([{ message: context }])
expect(claimed).toEqual([{ message: prompt, turn: 1 }])
expect(lifecycle).toEqual(['turn/start', 'agent/inbox/claimed'])
})
it('idle inject() rejects invalid input before enqueue', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -120,79 +124,6 @@ describe('Agent', () => {
expect(statuses).toEqual(['running', 'idle'])
})
it('awaits the turn-end checkpoint before claiming the next queued turn', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const flushedTurns: number[] = []
ctx.on('session/flush', async (session) => {
const turnEnd = session.events.findLast(event => event.type === 'turn/end')
flushedTurns.push(turnEnd?.data.turn ?? 0)
if (turnEnd?.data.turn === 1) await firstFlush.promise
})
send(agent, 'first')
send(agent, 'second')
await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) })
expect(adapter.requests).toHaveLength(1)
firstFlush.resolve(undefined)
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(flushedTurns).toEqual([1, 2])
})
it('keeps whenIdle pending through the final turn checkpoint', async () => {
const ctx = await harness(new MockAdapter([textResponse('done')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const flush = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', () => {
flushStarted = true
return flush.promise
})
send(agent, 'go')
await vi.waitFor(() => { expect(flushStarted).toBe(true) })
let idleSettled = false
const idle = agent.whenIdle().then(() => { idleSettled = true })
await Promise.resolve()
expect(idleSettled).toBe(false)
flush.resolve(undefined)
await idle
expect(agent.status).toBe('idle')
})
it('reports a rejected turn-end checkpoint and continues queued work', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const failure = new Error('disk unavailable')
const errors: { turn: number; step: number; error: unknown }[] = []
let flushes = 0
ctx.on('session/flush', () => {
flushes += 1
if (flushes === 1) throw failure
})
ctx.on('agent/error', (subject, turn, step, error) => {
if (subject === agent) errors.push({ turn, step, error })
})
send(agent, 'first')
send(agent, 'second')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(flushes).toBe(2)
expect(errors).toEqual([{ turn: 1, step: 1, error: failure }])
expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable'))
warning.mockRestore()
})
it('whenIdle() resolves immediately without active work', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })

View File

@@ -1,13 +1,13 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/**
* Tests for the queue-aware `Agent.cancel()` primitive. The default clears
* queued and steering work, while `keepInbox` preserves pending input and
* resumes waking turns after the active turn reaches quiescence. The suite
* queued and steering work, while `keepInbox` preserves pending input for a
* later wake after the active turn reaches quiescence. The suite
* covers every landing window plus signal reset and `whenIdle()` quiescence.
* @module dsh-agent-loop/tests/cancel
*/
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] {
}
describe('Agent.cancel()', () => {
it('notifies every observer before clearing work and contains listener failures', async () => {
const adapter = new MockAdapter([textResponse('must remain unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${cause.kind}`)
subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } }))
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject === agent) seen.push(`second:${cause.kind}`)
})
send(agent, 'drop me')
agent.cancel({ kind: 'user' })
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'parent' })
expect(seen).toEqual(['first:user', 'second:user'])
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
})
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
@@ -99,77 +72,76 @@ describe('Agent.cancel()', () => {
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
})
it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
const adapter = new MockAdapter([textResponse('reply')])
it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => {
const adapter = new MockAdapter([textResponse('wake reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const discards: unknown[] = []
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
const cancelRequests: unknown[] = []
ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
// keepInbox cancel: no active turn, work preserved, no discard event. With
// nothing to abort and nothing discarded, the call is a documented no-op,
// so it emits no cancel-requested either.
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'preserved' }],
source: { kind: 'user' },
}))
// A waking send starts and claims synchronously, so keepInbox has no
// pending item to preserve by the time this cancellation runs.
agent.cancel({ kind: 'user' }, { keepInbox: true })
expect(discards).toEqual([])
expect(cancelRequests).toEqual([])
// The preserved item still runs once the driver is woken by a later send.
send(agent, 'wake it')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
})
it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
// resolves (the agent is quiescent), leaving the item queued.
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
expect(agent.session.events.some(event =>
event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false)
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
// A later waking send drives the loop, and the quiet item rides along first.
send(agent, 'wake')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
})
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
const idle = waitForIdle(ctx, agent)
send(agent, 'wake it')
await idle
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(userTexts(agent)).toEqual(['wake it'])
expect(adapter.requests).toHaveLength(1)
})
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
it('cancel({ keepInbox: true }) parks queued work after an active turn aborts', async () => {
const adapter = new MockAdapter([
'hang',
textResponse('preserved reply'),
textResponse('wake reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('keep-after-abort'), { provider: 'mock', model: 'mock' })
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
send(agent, 'preserved')
agent.cancel({ kind: 'user' }, { keepInbox: true })
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['active'])
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(adapter.requests).toHaveLength(1)
const idle = waitForIdle(ctx, agent)
send(agent, 'wake it')
await idle
expect(userTexts(agent)).toEqual(['active', 'preserved', 'wake it'])
expect(adapter.requests).toHaveLength(3)
})
it('cancel after waking send closes its synchronously opened turn without a step', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// send() queues synchronously (status still idle, loop microtask not yet
// resumed). Cancel in that pre-step window: the queued turn must not run.
send(agent, 'drop me first')
send(agent, 'drop me second')
agent.cancel({ kind: 'user' })
// Give the loop a chance to wake and process the cancel.
await new Promise(r => setTimeout(r, 30))
// No turn was opened — the queued prompt was dropped, never recorded.
expect(userTexts(agent)).toEqual([])
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0)
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
expect(agent.status).toBe('idle')
})
@@ -247,7 +219,7 @@ describe('Agent.cancel()', () => {
await expect(Promise.race([
replacementObservation,
new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 2 })
const idle = waitForIdle(ctx, agent)
send(agent, 'later')
@@ -256,8 +228,12 @@ describe('Agent.cancel()', () => {
expect(userTexts(agent)).toEqual(['first', 'later'])
})
it('replacement work queued after idle-listener cancellation still runs', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => {
const adapter = new MockAdapter([
textResponse('first reply'),
textResponse('replacement reply'),
textResponse('wake reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' })
@@ -277,8 +253,15 @@ describe('Agent.cancel()', () => {
if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
await replacementIdle
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
expect(adapter.requests).toHaveLength(1)
expect(userTexts(agent)).toEqual(['first'])
expect(agent.inbox.nextTurn).toHaveLength(1)
const idle = waitForIdle(ctx, agent)
send(agent, 'wake it')
await idle
expect(adapter.requests).toHaveLength(3)
expect(userTexts(agent)).toEqual(['first', 'surviving replacement', 'wake it'])
})
it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => {
@@ -296,47 +279,12 @@ describe('Agent.cancel()', () => {
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
expect(userTexts(agent)).toEqual(['go'])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(adapter.requests).toHaveLength(1)
})
it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => {
const adapter = new MockAdapter([
'hang',
textResponse('second reply'),
textResponse('third reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
const discards: unknown[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items)
})
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
send(agent, 'queued second')
send(agent, 'queued third')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' }, { keepInbox: true })
await idle
expect(discards).toEqual([])
expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third'])
expect(reasons).toEqual([
{ kind: 'aborted' },
{ kind: 'completed' },
{ kind: 'completed' },
])
expect(adapter.requests).toHaveLength(3)
})
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'danger', {}),
@@ -368,7 +316,7 @@ describe('Agent.cancel()', () => {
dispose()
expect(executions).toBe(0)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
const call = agent.session.events.find(event => event.type === 'tool/call')
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
@@ -387,7 +335,7 @@ describe('Agent.cancel()', () => {
.find(block => block.type === 'tool-result')
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
expect(reasons).toEqual([
{ kind: 'aborted' },
{ kind: 'aborted', reason: { kind: 'user' } },
{ kind: 'completed' },
])
})
@@ -414,33 +362,6 @@ describe('Agent.cancel()', () => {
expect(reasons.length).toBe(2)
})
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A turn/start listener fires before a step controller exists, so the
// turn-scoped marker—not step abort—must drop the pending step.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' })
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
dispose()
// No step streamed (the model never ran), and the turn ended aborted with
// the caller's cause — the marker carries `cancel(cause)` through even
// though no AbortController observed it in this window.
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
@@ -466,12 +387,12 @@ describe('Agent.cancel()', () => {
// No step streamed, the turn ended with the coarse aborted outcome, and the
// log is balanced (the open step was closed by the cancel branch).
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => {
it('disposal from a synchronous step/start session-event listener stops before adapter dispatch', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -501,8 +422,7 @@ describe('Agent.cancel()', () => {
expect(streamed).toBe(false)
expect(adapter.requests).toHaveLength(0)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false)
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
@@ -533,7 +453,7 @@ describe('Agent.cancel()', () => {
// Only ONE step ran (the second was cancelled in the stopping window),
// and the shared turn signal classified the durable outcome as aborted.
expect(steps).toBe(1)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
@@ -541,8 +461,8 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// `agent/status` is synchronous, so cancellation can land after the first
// pre-step check; the second check must drop the now-empty turn.
// `agent/status` is synchronous, so cancellation can land before the
// durable turn-start commit and must drop the reserved work.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -559,8 +479,7 @@ describe('Agent.cancel()', () => {
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
// Cancellation must not settle idle while replacement work remains queued.
it('a running-listener cancellation parks replacement work until another wakeup', async () => {
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -578,32 +497,35 @@ describe('Agent.cancel()', () => {
await idle
dispose()
// whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end
// are in the log, and A was dropped.
expect(userTexts(agent)).toContain('B')
expect(userTexts(agent)).not.toContain('A')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
expect(userTexts(agent)).toEqual([])
expect(agent.inbox.nextTurn).toHaveLength(1)
const replacementIdle = waitForIdle(ctx, agent)
send(agent, 'C')
await replacementIdle
expect(userTexts(agent)).toEqual(['B', 'C'])
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2)
})
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A;
// prompt B is queued before the loop resumes from the idle wait.
it('a prompt queued during pre-step cancellation waits for another wakeup', async () => {
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'A') // queues A (status still idle, loop microtask pending)
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
agent.cancel({ kind: 'user' }) // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
send(agent, 'A')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
send(agent, 'B')
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
// and a turn/end are in the log.
await idle
expect(userTexts(agent)).toContain('B')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
// A was dropped (never ran); only B's turn is recorded.
expect(userTexts(agent)).not.toContain('A')
expect(userTexts(agent)).toEqual([])
expect(agent.inbox.nextTurn).toHaveLength(1)
const replacementIdle = waitForIdle(ctx, agent)
send(agent, 'C')
await replacementIdle
expect(userTexts(agent)).toEqual(['B', 'C'])
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3)
})
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
@@ -628,14 +550,18 @@ describe('Agent.cancel()', () => {
expect(turnStarts.length).toBe(1) // only the original (cancelled) turn
// The steering text was dropped — it never reached the log.
const flat = agent.session.events
.filter(e => e.type === 'steering/message')
.flatMap(e => e.type === 'steering/message' ? e.data.message.content : [])
.filter(e => e.type === 'user/message')
.flatMap(e => e.data.content)
.flatMap(b => b.type === 'text' ? [b.text] : [])
expect(flat).not.toContain('steer text')
})
it('keeps replacement work queued synchronously by an abort observer', async () => {
const adapter = new MockAdapter(['hang', textResponse('replacement reply')])
it('parks replacement work queued synchronously by an abort observer', async () => {
const adapter = new MockAdapter([
'hang',
textResponse('replacement reply'),
textResponse('wake reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' })
@@ -644,7 +570,7 @@ describe('Agent.cancel()', () => {
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true })
const idle = waitForIdle(ctx, agent)
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
await Promise.race([
idle,
@@ -660,15 +586,22 @@ describe('Agent.cancel()', () => {
}),
])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['original', 'replacement'])
expect(adapter.requests).toHaveLength(1)
expect(userTexts(agent)).toEqual(['original'])
expect(agent.inbox.nextTurn).toHaveLength(1)
const reasons = agent.session.events
.filter(event => event.type === 'turn/end')
.map(event => event.type === 'turn/end' ? event.data.reason : undefined)
expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
const replacementIdle = waitForIdle(ctx, agent)
send(agent, 'wake it')
await replacementIdle
expect(adapter.requests).toHaveLength(3)
expect(userTexts(agent)).toEqual(['original', 'replacement', 'wake it'])
})
it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => {
it('keeps the first typed cause for an active turn', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' })
@@ -677,16 +610,17 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await expect.poll(() => adapter.requests.length).toBe(1)
agent.cancel(supplied)
supplied.kind = 'user'
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
const runtimeReason: unknown = adapter.requests[0]?.signal?.reason
expect(runtimeReason).toEqual({ kind: 'parent' })
expect(runtimeReason).not.toBe(supplied)
expect(Object.isFrozen(runtimeReason)).toBe(true)
expect(runtimeReason).toBe(supplied)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
kind: 'aborted',
reason: { kind: 'parent' },
})
})
it('preserves the first user cancellation when lifecycle teardown races it', async () => {
@@ -704,13 +638,12 @@ describe('Agent.cancel()', () => {
await handle.dispose()
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
})
it.each([
'prompt-submit',
'pre-step',
'system-prompt',
'step',
'request',
'stopping',
'tool',
@@ -730,8 +663,8 @@ describe('Agent.cancel()', () => {
}
switch (stage) {
case 'prompt-submit':
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
case 'pre-step':
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
@@ -745,11 +678,6 @@ describe('Agent.cancel()', () => {
return next()
})
break
case 'step':
ctx.on('agent/step', async (subject, _turn, _step, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'request':
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
@@ -781,11 +709,8 @@ describe('Agent.cancel()', () => {
agent.cancel({ kind: 'user' })
await idle
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
if (stage === 'prompt-submit') {
expect(turnEnd).toBeUndefined()
} else {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
}
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
await ctx.fiber.dispose()
})
})

View File

@@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
@@ -146,8 +146,9 @@ describe('config-driven session id', () => {
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('saved')]))
const sessionId = SessionId('config-exact-overlap')
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const config = { agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as Agent
@@ -158,7 +159,9 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } }))
const idle = waitForIdle(ctx, first)
first.followup(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'user' } }))
await idle
await ctx.sessions.flush(first.session)
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
.toContain('persist before replacement')
@@ -293,14 +296,15 @@ describe('config-driven session id', () => {
})
it.each(['resolve', 'reject'] as const)(
'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts',
'abandons an exact-id preparation that later %s when AgentLoop disposal starts',
async (outcome) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const loading = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.load>>>()
vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise)
const preparing = Promise.withResolvers<SessionPreparation>()
vi.spyOn(ctx.sessionPersistence, 'prepare').mockReturnValue(preparing.promise)
const released = vi.fn()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
@@ -310,18 +314,15 @@ describe('config-driven session id', () => {
})
await loop.dispose()
if (outcome === 'resolve') {
loading.resolve({
meta: {
id: SessionId('config-exact-dispose'),
version: 0,
createdAt: Date.now(),
},
events: [],
})
preparing.resolve(SessionPreparation.create(
ctx.sessions.prepare(SessionId('config-exact-dispose')),
{ release: released },
))
} else {
loading.reject(new Error('startup cancelled by teardown'))
preparing.reject(new Error('startup cancelled by teardown'))
}
await Promise.resolve()
if (outcome === 'resolve') await expect.poll(() => released).toHaveBeenCalledOnce()
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
expect(failures).toEqual([])
expect(warn).not.toHaveBeenCalled()

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { ReactLoopAgent } from '../src/agent.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -53,211 +53,12 @@ function send(agent: Agent, text: string) {
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
}
function inboxText(item: InboxItem): string {
return item.message.content
function inboxText(message: UserMessage): string {
return message.content
.flatMap(block => block.type === 'text' ? [block.text] : [])
.join('')
}
describe('addressable inbox operations', () => {
it('edits in place and removes exactly one queued item', async () => {
const adapter = new MockAdapter([
textResponse('first reply'),
textResponse('edited reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
const admission = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => {
if (message.content[0]?.type === 'text' && message.content[0].text === 'first') {
admission.resolve(undefined)
await release.promise
}
return next()
})
const pending: InboxItem[] = []
const updates: { id: string; text: string }[] = []
const discards: string[][] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent && inboxText(item) !== 'first') pending.push(item)
})
ctx.on('agent/inbox/update', (subject, item) => {
if (subject === agent) updates.push({ id: item.id, text: inboxText(item) })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items.map(item => item.id))
})
send(agent, 'first')
await admission.promise
send(agent, 'remove me')
send(agent, 'edit me')
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
const remove = pending[0]!
const edit = pending[1]!
expect(agent.updateInbox(edit.id, {
kind: 'edit',
content: [{ type: 'text', text: 'edited' }],
})).toBe('applied')
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
expect(updates).toEqual([{ id: edit.id, text: 'edited' }])
expect(discards).toEqual([[remove.id]])
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
await idle
expect(agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
: ''))
.toEqual(['first', 'edited'])
expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found')
})
it('does not mutate steering occurrences', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const pending: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent && item.placement === 'steering') pending.push(item)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'admitted prompt')
await entered.promise
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } }))
expect(pending.map(inboxText)).toEqual(['keep me'])
const steering = pending[0]!
expect(agent.updateInbox(steering.id, {
kind: 'edit',
content: [{ type: 'text', text: 'edited' }],
})).toBe('not-found')
expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found')
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events
.filter(event => event.type === 'steering/message')
.map(event => event.type === 'steering/message'
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
: ''))
.toEqual(['keep me'])
})
it('strictly transfers a queued occurrence into the open turn', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) enqueued.push(item)
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discarded.push(...items)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'open the turn')
const receipt = agent.steer(createUserMessage({
content: [{ type: 'text', text: 'steer this message' }],
source: { kind: 'user' },
}))
await entered.promise
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
const steering = enqueued.find(item => item.placement === 'steering')!
expect(steering.id).not.toBe(queued.id)
expect(steering.message).toBe(queued.message)
expect(discarded).toEqual([queued])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events.flatMap(event =>
event.type === 'steering/message' ? [event.data.message] : [],
)).toEqual([queued.message])
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
})
it('keeps a queued occurrence when the next-step window is closed', () => {
const ctx = new Context()
const session = Session.create(SessionId('queue-to-steer-closed'))
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
agent.send(
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
{ target: 'next-turn', wakeup: false },
)
const queued = enqueued[0]!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
expect(discarded).toEqual([])
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
})
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject !== agent) return
enqueued.push(item)
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discarded.push(...items)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'open the turn')
await entered.promise
send(agent, 'cancel during conversion')
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
const steering = enqueued.find(item => item.placement === 'steering')!
expect(discarded).toEqual([steering, queued])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
})
})
describe('assistant replay provenance', () => {
it('records adapter replay state with the assembled assistant content', async () => {
const response = textResponse('unchanged')
@@ -281,8 +82,11 @@ describe('assistant replay provenance', () => {
})
describe('abort during tool execution ends the turn', () => {
it('records context accepted before a tool-step abort in the same turn', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
it('parks context finalized after a tool-step abort until another wakeup', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'aborter', {}),
textResponse('after wake'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineContentToolFixture({
@@ -306,14 +110,20 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
expect(events
expect(agent.session.events
.filter(event => event.type === 'tool/result'
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.toEqual(['tool/result', 'user/message', 'step/end', 'turn/end'])
expect(events
.toEqual(['tool/result', 'step/end', 'turn/end'])
expect(agent.inbox.nextStep.map(inboxText))
.toEqual(['accepted result context after abort'])
const idle = waitForIdle(ctx, agent)
send(agent, 'wake')
await idle
expect(agent.session.events
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
: []))
@@ -377,7 +187,26 @@ describe('abort during tool execution ends the turn', () => {
.toBeUndefined()
})
it('records result context finalized after disposal cancellation', async () => {
it('closes an empty admitted batch as a turn without a step', async () => {
const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (subject !== agent) return next()
return Promise.resolve({ kind: 'enter', messages: [] })
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start'
|| event.type === 'step/start' || event.type === 'turn/end').map(event => event.type))
.toEqual(['turn/start', 'turn/end'])
expect(agent.session.events.find(event => event.type === 'turn/end')?.data)
.toEqual({ turn: 1, reason: { kind: 'completed' } })
expect(agent.inbox.nextTurn).toHaveLength(0)
})
it('parks result context finalized after disposal cancellation without opening another turn', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
const ctx = await harness(adapter)
const started = Promise.withResolvers<undefined>()
@@ -417,11 +246,13 @@ describe('abort during tool execution ends the turn', () => {
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
: []))
.toEqual([
[{ type: 'text', text: 'accepted result context during disposal' }],
])
.toEqual([])
expect(agent.inbox.nextStep.map(inboxText))
.toEqual(['accepted result context during disposal'])
expect(agent.session.events.filter(event => event.type === 'turn/start'))
.toHaveLength(1)
expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'disposed' })
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
})
it('limits injection deferral to the current tool batch', async () => {
@@ -457,10 +288,19 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'leave an unmatched historical call')
await waitForIdle(ctx, agent)
ctx.on('agent/step', (subject, turn) => {
if (subject === agent && turn === 2) {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => {
const decision = await next()
if (subject === agent && turn === 2 && decision.kind === 'enter') {
disposeInjection()
return {
kind: 'enter' as const,
messages: [...decision.messages, createUserMessage({
content: [{ type: 'text', text: 'new turn context' }],
source: { kind: 'plugin', plugin: 'test' },
})],
}
}
return decision
})
send(agent, 'start a text-only turn')
await waitForIdle(ctx, agent)
@@ -499,65 +339,6 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steeredOnce = false
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
steeredOnce = true
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } }))
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
// Same-turn steering precedes the second step.
const steeringIdx = events.findIndex(e => e.type === 'steering/message')
const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
expect(steeringIdx).toBeGreaterThanOrEqual(0)
expect(steeringIdx).toBeLessThan(step2Idx)
// and it reached the next model request.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
})
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const turns: number[] = []
let steeredOnce = false
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && !steeredOnce) {
steeredOnce = true
expect(agent.acceptsNextStep).toBe(false)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } }))
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// the loop chains directly into turn 2 (status never returns to idle in
// between), so the first idle transition means both turns are complete
expect(turns).toEqual([1, 2])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
})
})
describe('plugin exceptions are contained', () => {
@@ -574,14 +355,11 @@ describe('plugin exceptions are contained', () => {
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } },
})
// the loop is still alive: a second send works normally
send(agent, 'second')
@@ -614,7 +392,7 @@ describe('disposal leaves the two-state status contract balanced', () => {
await driverDone(agent)
expect(statuses).toEqual(['running', 'idle'])
expect(reasons).toEqual([{ kind: 'disposed' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const messages = agent.session.events
.filter(event => event.type === 'user/message')
@@ -663,16 +441,15 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('has no provider/model')
expect(errors[0]!.message).toContain('agent/request')
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error.message
: undefined).toContain('has no provider/model')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error.message
: undefined).toContain('agent/request')
})
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
@@ -690,7 +467,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
})
it('agent/inbox/enqueue carries the exact message; steering/message records its source', async () => {
it('durable inbox splices carry exact messages and the claimed steer preserves its source', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -704,30 +481,32 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
},
}))
const queuedSources: MessageSource[] = []
const queuedShapes: string[][] = []
const placements: InboxPlacement[] = []
ctx.on('agent/inbox/enqueue', (_agent, item) => {
queuedSources.push(item.message.source)
queuedShapes.push(Object.keys(item.message).sort())
placements.push(item.placement)
const insertedSources: MessageSource[] = []
const insertedShapes: string[][] = []
const targets: string[] = []
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
for (const message of event.data.inserted) {
insertedSources.push(message.source)
insertedShapes.push(Object.keys(message).sort())
targets.push(event.data.target)
}
})
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
await waitForIdle(ctx, agent)
expect(queuedSources).toEqual([
expect(insertedSources).toEqual([
{ kind: 'user' },
{ kind: 'plugin', plugin: 'goal' },
])
expect(queuedShapes).toEqual([
expect(insertedShapes).toEqual([
['content', 'id', 'role', 'source'],
['content', 'id', 'role', 'source'],
])
expect(placements).toEqual(['queued', 'steering'])
// The drain appends the durable steering/message with the caller's source
// intact — the log, not a transient emit, is where consumers read it.
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.message.source] : [])
expect(targets).toEqual(['next-turn', 'next-step'])
const steeringSources = agent.session.events.flatMap(e =>
e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : [])
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
})
@@ -806,18 +585,24 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
const errors: unknown[] = []
ctx.on('agent/error', (_agent, turn, step, error) => {
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
errors.push(error)
})
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
expect(reasons).toEqual([{ kind: 'error', error: failure }])
expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(LlmError)
expect((errors[0] as LlmError).failure).toEqual(failure)
const events = [...agent.session.events]
// The durable failure lives on turn/end.reason (with the failing step), not
// a standalone error event.
const turnEnd = events.find(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } })
// A failed step must not synthesize an assistant message.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -836,7 +621,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }])
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -854,7 +639,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
expect(reasons).toEqual([{ kind: 'error', error: { message: 'codeless failure', code: 'UNKNOWN' } }])
})
})
@@ -944,8 +729,8 @@ describe('turn and step boundary recovery', () => {
expect(stepEndIdx).toBeLessThan(turnEndIdx)
})
it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => {
const adapter = new MockAdapter([textResponse('after recovery')])
it('a pre-commit turn/start rejection leaves no durable turn state', async () => {
const adapter = new MockAdapter([])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' })
let rejected = false
@@ -965,23 +750,11 @@ describe('turn and step boundary recovery', () => {
send(agent, 'rejected')
await waitForIdle(ctx, agent)
// The rejected turn left nothing behind: no events, no admitted prompt.
expect(agent.session.events).toEqual([])
expect(agent.session.events.some(event => event.type === 'turn/start'
|| event.type === 'user/message')).toBe(false)
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit'])
// The next prompt reuses the never-committed turn number and carries only
// its own admitted content — invariants (mounted) accept the log.
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 })
const turnStart = agent.session.events.find(event => event.type === 'turn/start')
expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1)
const prompts = agent.session.events.filter(event => event.type === 'user/message')
expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([
[{ type: 'text', text: 'go' }],
])
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(0)
})
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
@@ -997,11 +770,6 @@ describe('turn and step boundary recovery', () => {
throw new Error('reject step-start before commit')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1013,10 +781,12 @@ describe('turn and step boundary recovery', () => {
stepEnd: 0,
errors: 1,
})
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } },
})
})
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
it('a step/end validation failure surfaces the resulting open-step invariant', async () => {
const adapter = new MockAdapter([textResponse('completed before close validation')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' })
@@ -1038,13 +808,16 @@ describe('turn and step boundary recovery', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
expect(errors.map(error => error.message)).toEqual([
'reject first step-end',
'invariant violated by "@deepseek-ai/dsh-session": turn/end 1 while step 1 is still open',
])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
turnEnd: 0,
stepStart: 1,
stepEnd: 1,
errors: 1,
stepEnd: 0,
errors: 0,
})
})
@@ -1068,9 +841,9 @@ describe('turn and step boundary recovery', () => {
expect(c.stepStart).toBe(c.stepEnd)
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
kind: 'error',
step: 1,
failure: { message: 'provider 500', code: 'SERVER' },
error: { message: 'provider 500', code: 'SERVER' },
})
expect(threw).toBe(true)
// loop survives: a second turn runs to completion (invariants oracle would
// throw on its turn/start if turn 1 had been left open).
@@ -1106,13 +879,12 @@ describe('turn and step boundary recovery', () => {
const turnEnds = e.filter(x => x.type === 'turn/end').length
expect(turnStarts).toBe(1)
expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal
expect(reasons).toEqual([{ kind: 'disposed' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
// no error reason: disposal is not a failure.
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Disposal remains authoritative when the listener also throws.
it('contains a pre-step throw after disposal inside a balanced no-step turn', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: Agent
@@ -1121,8 +893,8 @@ describe('turn and step boundary recovery', () => {
}, { inject: ['agentLoop'] }))
let threw = false
ctx.on('agent/step', () => {
if (threw) return
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => {
if (threw) return next()
threw = true
void fiber.dispose()
throw new Error('boom pre-step during disposal')
@@ -1136,13 +908,10 @@ describe('turn and step boundary recovery', () => {
await agent.whenIdle()
const e = [...agent.session.events]
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
// No step opened (the throw was before step/start) and disposal is not a
// failure, so no agent/error for the contained throw.
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.find(x => x.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(errorEmits).toHaveLength(0)
})
@@ -1243,7 +1012,9 @@ describe('turn and step boundary recovery', () => {
expect(e.some(x => x.type === 'step/end')).toBe(true)
expect(e.some(x => x.type === 'turn/end')).toBe(true)
expect(e.at(-1)?.type).toBe('turn/end')
expect(errors.map(error => error.message)).toEqual(['provider 500'])
expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(LlmError)
expect((errors[0] as LlmError).failure).toEqual({ message: 'provider 500', code: 'SERVER' })
// loop survives.
send(agent, 'again')
@@ -1327,7 +1098,7 @@ describe('tool result call identity', () => {
})
describe('disposal and cancellation during pre-step assembly', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
it('disposal during system-prompt assembly closes a no-step turn', { timeout: 30000 }, async () => {
// Start disposal, then release assembly. Do not await disposal first: it
// waits for the blocked driver to exit.
const adapter = new MockAdapter(['hang'])
@@ -1359,7 +1130,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
// Give the loop time to enter the step and reach assemble().
// Give the loop time to reach pre-step assembly.
await new Promise(r => setTimeout(r, 50))
// Release assembly before awaiting disposal because disposal joins the blocked driver.
@@ -1370,17 +1141,16 @@ describe('disposal and cancellation during pre-step assembly', () => {
await driverDone(agent)
unlisten()
// Turn boundaries are durable rows; there is no `agent/*` mirror to assert.
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/end')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
})
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
it('cancel during system-prompt assembly closes a no-step turn', { timeout: 30000 }, async () => {
const adapter = new MockAdapter([textResponse('should not appear')])
let releaseAssemble!: () => void
const blocker = new Promise<void>(r => void (releaseAssemble = r))
@@ -1419,20 +1189,18 @@ describe('disposal and cancellation during pre-step assembly', () => {
unlisten()
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/end')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})
it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => {
// Start disposal, then release pre-step; awaiting disposal first would
// deadlock on the blocked driver.
it('disposal during pre-step closes a no-step turn', { timeout: 15000 }, async () => {
// Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
@@ -1447,8 +1215,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/step', async () => {
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
await blocker
return next()
})
let agent!: Agent
@@ -1467,22 +1236,17 @@ describe('disposal and cancellation during pre-step assembly', () => {
await disposalDone
await driverDone(agent)
// After the agent/step listeners finish, the post-listener cancel/dispose check
// catches disposal. The step was never opened, no LLM call was made.
// The post-listener cancellation check catches disposal before any step or LLM call.
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
// Disposal wins the post-listener check — reason is `disposed`.
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// The durable turn/end record is the authoritative turn-boundary signal
// (turn boundaries have no agent/* mirror).
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
})
it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => {
// Release agent/step after cancellation to exercise the post-listener check.
it('cancel during pre-step closes a no-step turn', { timeout: 15000 }, async () => {
// Release pre-step after cancellation to exercise the post-listener check.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
const blocker = new Promise<void>(r => void (releasePreStep = r))
@@ -1497,8 +1261,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/step', async () => {
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
await blocker
return next()
})
let agent!: Agent
@@ -1519,13 +1284,11 @@ describe('disposal and cancellation during pre-step assembly', () => {
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
@@ -1565,14 +1328,12 @@ describe('disposal and cancellation during pre-step assembly', () => {
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
// The critical assertions: after disposal, the turn has no assistant
// artifacts — the turn ended disposed before the model was invoked.
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.find(x => x.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
// The durable turn/end reason is the authoritative turn-boundary record
// (turn boundaries have no agent/* mirror).
})
})

View File

@@ -127,19 +127,14 @@ describe('thrown-value propagation', () => {
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]).toBe('naked string error')
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(0)
const starts = agent.session.events.filter(event => event.type === 'turn/start')
const ends = agent.session.events.filter(event => event.type === 'turn/end')
const messages = agent.session.events.filter(event => event.type === 'user/message')
expect(starts).toHaveLength(1)
// The rejected turn/start committed nothing, so the survivor reuses turn 1
// and the rejected prompt does not leak into it.
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
expect(ends).toHaveLength(1)
expect(messages).toHaveLength(1)
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
{ type: 'text', text: 'survives as the next item' },
])
expect(starts).toHaveLength(0)
expect(ends).toHaveLength(0)
expect(messages).toHaveLength(0)
expect(agent.inbox.nextTurn).toHaveLength(2)
})
it('preserves non-Error throws from the agent/request waterfall', async () => {
@@ -156,22 +151,17 @@ describe('thrown-value propagation', () => {
return next()
})
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]).toEqual({ code: 500 })
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code))
.toBeUndefined()
? turnEnd.data.reason.error.message
: undefined).toBe('[object Object]')
})
})
describe('coded error data emission', () => {
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
describe('durable error rendering', () => {
it('renders a coded error thrown from a plugin', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -185,20 +175,16 @@ describe('coded error data emission', () => {
return next()
})
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errorChain(errors[0])).toBe('server overloaded')
// turn-end error reason includes the code
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)
.toBe('RATE_LIMIT')
expect(turnEnd.data.reason.error).toEqual({
message: 'server overloaded',
code: 'RATE_LIMIT',
})
}
})
})
@@ -221,7 +207,7 @@ describe('disposed vs aborted branching', () => {
await driverDone(agent)
// Disposal wins abort classification because the error path checks it first.
expect(reasons).toContainEqual({ kind: 'disposed' })
expect(reasons).toContainEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
})
})
@@ -285,9 +271,7 @@ describe('request-error action edges', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (
subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, signal, next,
) => {
ctx.on('agent/request-error', async (subject, _context, signal, next) => {
await next()
subject.cancel({ kind: 'user' })
expect(signal.aborted).toBe(true)
@@ -480,48 +464,44 @@ describe('unrenderable failure settlement', () => {
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
// The durable failure keeps the adapter facts' message, not the
// unrenderable chain.
expect(end.data.reason.failure?.message).not.toBe('<unrenderable value>')
expect(errorChain(end.data.reason.error.message)).not.toBe('<unrenderable value>')
}
})
})
describe('driver bookkeeping edges', () => {
it('a deferred wake settles when replacement activity rejects', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('rejected-deferred-wake'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent) return
subject.cancel({ kind: 'user' })
const mutable = subject as Agent & { done: Promise<void> }
mutable.done = Promise.reject(new Error('replacement rejected'))
})
it('rejects a direct turn invocation without a driver reservation', async () => {
const ctx = await harness(new MockAdapter([]))
const agent = ctx.agentLoop.create(SessionId('turn-without-reservation'), { provider: 'mock', model: 'mock' })
send(agent, 'cancel before wake')
await expect(agent.whenIdle()).resolves.toBeUndefined()
expect(agent.session.events).toEqual([])
await expect((agent as unknown as { turn(): Promise<boolean> }).turn())
.rejects.toThrow('turn without driver reservation')
expect(agent.status).toBe('idle')
})
it('a whenIdle waiter survives a rejected driver promise', async () => {
const adapter = new MockAdapter([textResponse('ok')])
it('closes an entered turn as blocked when its next step is rejected', async () => {
const adapter = new MockAdapter([textResponse('first step')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' })
// A throwing terminal-notification listener rejects the driver promise
// (the run's containment covers only session appends); the waiter's
// catch arm must treat that rejection as quiescence instead of
// propagating it.
ctx.on('agent/settled', (subject) => {
if (subject === agent) throw new Error('settled listener exploded')
const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' })
let proposals = 0
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
proposals += 1
return proposals === 2 ? { kind: 'reject' } : next()
})
ctx.on('agent/turn-stopping', (subject) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'do not enter the next step' }],
source: { kind: 'plugin', plugin: 'test' },
}))
})
send(agent, 'one')
// Entered while the run owns the abort slot, the waiter awaits the
// driver promise; its rejection must count as quiescence and resolve.
await expect(agent.whenIdle()).resolves.toBeUndefined()
send(agent, 'go')
await agent.whenIdle()
expect(proposals).toBe(2)
expect(adapter.requests).toHaveLength(1)
const end = agent.session.events.findLast(event => event.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' })
})
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {

View File

@@ -11,8 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, {
type Agent,
type InboxPlacement,
type PromptDecision,
type PreStepDecision,
type SessionStartSource,
} from '@deepseek-ai/dsh-agent'
@@ -20,7 +19,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* The interception seams introduced by the hooks taxonomy: `agent/pre-step`,
* `agent/session-start`, `agent/turn-stopping`, and the
* `tools/pre-execute` / `tools/post-execute`
* split with `additionalContexts` buffering. These verify the canonical event
@@ -59,15 +58,15 @@ function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}
describe('agent/prompt-submit', () => {
it('allow (default via next) records the user/message unchanged', async () => {
describe('agent/pre-step', () => {
it('enter (default via next) records the user/message unchanged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
seen.push(message.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
@@ -79,16 +78,44 @@ describe('agent/prompt-submit', () => {
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
})
it('reports the request coordinates for initial and tool-continuation prompts', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'hi' }),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string', required: true } },
execute: async ({ text }) => [{ type: 'text', text }],
}))
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
const seen: Array<{ turn: number; step: number; messages: number }> = []
ctx.on('agent/pre-step', async (_agent, messages, context, next) => {
seen.push({ turn: context.turn, step: context.step, messages: messages.length })
return next()
})
send(agent, 'hello')
await waitForIdle(ctx, agent)
expect(seen).toEqual([
{ turn: 1, step: 1, messages: 1 },
{ turn: 1, step: 2, messages: 0 },
])
})
it('publishes frozen input without replacing its identity', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const decision = Promise.withResolvers<PreStepDecision>()
const observed: UserMessage[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject !== agent) return
const message = item.message
ctx.on('agent/pre-step', async (subject, messages) => {
if (subject !== agent) return { kind: 'enter', messages }
const message = messages[0]!
expect(Object.isFrozen(message)).toBe(true)
expect(Object.isFrozen(message.content)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
@@ -97,11 +124,7 @@ describe('agent/prompt-submit', () => {
const block = message.content[0]
if (block?.type === 'text') block.text = 'listener mutation'
}).toThrow()
})
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) observed.push(item.message)
})
ctx.on('agent/prompt-submit', async () => {
observed.push(message)
entered.resolve(undefined)
return decision.promise
})
@@ -120,11 +143,11 @@ describe('agent/prompt-submit', () => {
expect(() => {
if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
}).toThrow(TypeError)
decision.resolve({ kind: 'allow' })
decision.resolve({ kind: 'enter', messages: [input] })
await idle
expect(observed).toHaveLength(1)
expect(observed[0]).toBe(input)
expect(observed[0]).not.toBe(input)
expect(observed[0]).toMatchObject({
content: [{ type: 'text', text: 'accepted text' }],
source: { kind: 'plugin', plugin: 'accepted source' },
@@ -133,13 +156,16 @@ describe('agent/prompt-submit', () => {
expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input)
})
it('allow with content REWRITES the prompt before it is recorded', async () => {
it('enter with content rewrites the prompt before it is recorded', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
({
kind: 'enter',
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
}))
send(agent, 'original')
await waitForIdle(ctx, agent)
@@ -151,15 +177,15 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
it('enter with additional messages records separately sourced context in the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
({
kind: 'allow',
additionalContexts: [createUserMessage({
kind: 'enter',
messages: [...messages, createUserMessage({
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
source: { kind: 'plugin', plugin: 'test' },
})],
@@ -178,41 +204,39 @@ describe('agent/prompt-submit', () => {
expect(sent).toContain('extra ctx')
})
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
const adapter = new MockAdapter([textResponse('ok')])
it('does not open another step when a completed turn rewrites pending input to empty', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
additionalContexts: [createUserMessage({
content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' },
})],
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/turn-stopping', (subject) => {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'pending context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
let preStepDerived: string | undefined
ctx.on('agent/step', (subject, _turn, step) => {
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
})
ctx.on('agent/pre-step', async (_subject, _messages, context, next) => {
const decision = await next()
return context.step === 1 || decision.kind === 'reject'
? decision
: { kind: 'enter', messages: [] }
})
send(agent, 'ORIGINAL prompt')
await waitForIdle(ctx, agent)
send(agent, 'finish once')
await agent.whenIdle()
expect(preStepDerived).toBeDefined()
expect(preStepDerived).toContain('REWRITTEN prompt')
expect(preStepDerived).toContain('injected ctx')
expect(preStepDerived).not.toContain('ORIGINAL prompt')
expect(adapter.requests).toHaveLength(1)
expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1)
})
it('block drops the claimed prompt before any turn or model call', async () => {
it('reject closes the claimed prompt turn without a step or model call', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'block', reason: 'blocked by policy' }))
ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' }))
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -223,74 +247,81 @@ describe('agent/prompt-submit', () => {
// the model was never called
expect(adapter.requests).toHaveLength(0)
const log = events(agent)
expect(log.some(e => e.type === 'turn/start')).toBe(false)
expect(log.some(e => e.type === 'turn/end')).toBe(false)
expect(log.filter(e => e.type === 'turn/start' || e.type === 'turn/end').map(e => e.type))
.toEqual(['turn/start', 'turn/end'])
expect(log.some(e => e.type === 'user/message')).toBe(false)
expect(log.some(e => e.type === 'step/start')).toBe(false)
expect(reasons).toEqual([])
expect(reasons).toEqual([{ kind: 'blocked' }])
})
it('stages inject and steer during admission for the admitted turn', async () => {
it('stages inject and steer during pre-step for the entered turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const placements: InboxPlacement[] = []
ctx.on('agent/prompt-submit', async () => {
const decision = Promise.withResolvers<PreStepDecision>()
let claimed: UserMessage[] = []
let firstProposal = true
ctx.on('agent/pre-step', async (_agent, messages) => {
if (!firstProposal) return { kind: 'enter', messages }
firstProposal = false
claimed = messages
entered.resolve(undefined)
return decision.promise
})
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) placements.push(item.placement)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'admitted prompt')
send(agent, 'entered prompt')
await entered.promise
expect(agent.status).toBe('running')
expect(agent.acceptsNextStep).toBe(true)
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(events(agent).some(event => event.type === 'turn/start')).toBe(true)
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'attached context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } }))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } }))
expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
expect(placements).toEqual(['queued', 'steering'])
expect(agent.inbox.nextStep.map(message => message.content[0]))
.toEqual([
{ type: 'text', text: 'attached context' },
{ type: 'text', text: 'pre-step steering' },
])
decision.resolve({ kind: 'allow' })
decision.resolve({ kind: 'enter', messages: claimed })
await idle
expect(agent.acceptsNextStep).toBe(false)
expect(agent.inbox.hasPending).toBe(false)
const staged = events(agent).filter(event =>
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
event.type === 'turn/start' || event.type === 'user/message')
expect(staged.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'user/message',
'steering/message',
'user/message',
])
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
.toEqual([{ type: 'text', text: 'admitted prompt' }])
.toEqual([{ type: 'text', text: 'entered prompt' }])
expect(staged[2]?.type === 'user/message' && staged[2].data.content)
.toEqual([{ type: 'text', text: 'attached context' }])
expect(staged[3]?.type === 'steering/message' && staged[3].data.message.content)
.toEqual([{ type: 'text', text: 'admission steering' }])
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('admitted prompt')
expect(request).toContain('attached context')
expect(request).toContain('admission steering')
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
.toEqual([{ type: 'text', text: 'pre-step steering' }])
const firstRequest = JSON.stringify(adapter.requests[0]?.messages)
expect(firstRequest).toContain('entered prompt')
expect(firstRequest).not.toContain('attached context')
expect(firstRequest).not.toContain('pre-step steering')
const nextRequest = JSON.stringify(adapter.requests[1]?.messages)
expect(nextRequest).toContain('attached context')
expect(nextRequest).toContain('pre-step steering')
})
it('keeps admission-time outbox input staged when admission is blocked', async () => {
it('preserves input staged after the blocked batch was claimed', async () => {
const adapter = new MockAdapter([textResponse('retried')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
const decision = Promise.withResolvers<PreStepDecision>()
const disposeBlock = ctx.on('agent/pre-step', async () => {
entered.resolve(undefined)
return decision.promise
})
@@ -298,17 +329,21 @@ describe('agent/prompt-submit', () => {
const blockedIdle = waitForIdle(ctx, agent)
send(agent, 'blocked prompt')
await entered.promise
expect(agent.acceptsNextStep).toBe(true)
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'staged context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))
decision.resolve({ kind: 'block', reason: 'policy' })
decision.resolve({ kind: 'reject' })
await blockedIdle
expect(agent.acceptsNextStep).toBe(false)
expect(events(agent)).toEqual([])
expect(agent.inbox.nextStep.map(message => message.content[0]))
.toEqual([
{ type: 'text', text: 'staged context' },
{ type: 'text', text: 'staged steering' },
])
expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end')
.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
expect(adapter.requests).toEqual([])
disposeBlock()
@@ -316,10 +351,10 @@ describe('agent/prompt-submit', () => {
await waitForIdle(ctx, agent)
const staged = events(agent).filter(event =>
event.type === 'user/message' || event.type === 'steering/message')
event.type === 'user/message')
expect(staged.map(event => event.type)).toEqual([
'user/message',
'steering/message',
'user/message',
'user/message',
])
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
@@ -327,21 +362,26 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
})
it('orders rejected-admission outbox input before a later admitted prompt', async () => {
const adapter = new MockAdapter([textResponse('continued')])
it('preserves later queued work when a step is rejected', async () => {
const adapter = new MockAdapter([
textResponse('continued'),
textResponse('wake reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), {
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
const decision = await next()
return message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')
? { kind: 'block', reason: 'policy' }
return messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
? { kind: 'reject' as const }
: decision
})
ctx.on('agent/prompt-submit', async (subject, message, _signal, next) => {
if (message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) {
ctx.on('agent/pre-step', async (subject, messages, _signal, next) => {
if (messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'earlier state change' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -359,29 +399,34 @@ describe('agent/prompt-submit', () => {
send(agent, 'later prompt')
await idle
const staged = events(agent).filter(event =>
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
expect(staged.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'steering/message',
'user/message',
])
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
.toEqual([{ type: 'text', text: 'earlier state change' }])
expect(staged[2]?.type === 'steering/message' && staged[2].data.message.content)
.toEqual([{ type: 'text', text: 'earlier steering' }])
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end')
.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
expect(agent.inbox.nextStep.map(message => message.content[0]))
.toEqual([
{ type: 'text', text: 'earlier state change' },
{ type: 'text', text: 'earlier steering' },
])
expect(agent.inbox.nextTurn.map(message => message.content[0]))
.toEqual([{ type: 'text', text: 'later prompt' }])
expect(adapter.requests).toEqual([])
const resumed = waitForIdle(ctx, agent)
send(agent, 'wake')
await resumed
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('earlier state change')
expect(request).toContain('earlier steering')
expect(request).toContain('later prompt')
expect(request).not.toContain('blocked prompt')
})
it('commits context-only injection when admission closes without a turn', async () => {
const adapter = new MockAdapter([])
it('preserves context-only injection staged after pre-step began', async () => {
const adapter = new MockAdapter([textResponse('continued')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
ctx.on('agent/prompt-submit', async () => {
const decision = Promise.withResolvers<PreStepDecision>()
const disposeBlock = ctx.on('agent/pre-step', async () => {
entered.resolve(undefined)
return decision.promise
})
@@ -393,91 +438,90 @@ describe('agent/prompt-submit', () => {
content: [{ type: 'text', text: 'independent context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
decision.resolve({ kind: 'block', reason: 'policy' })
decision.resolve({ kind: 'reject' })
await idle
const log = events(agent)
expect(log.map(event => event.type)).toEqual(['user/message'])
expect(log[0]?.type === 'user/message' && log[0].data.content)
expect(log.some(event => event.type === 'user/message')).toBe(false)
expect(agent.inbox.nextStep.map(message => message.content[0]))
.toEqual([{ type: 'text', text: 'independent context' }])
expect(adapter.requests).toEqual([])
disposeBlock()
const resumed = waitForIdle(ctx, agent)
send(agent, 'wake')
await resumed
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('independent context')
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
})
it('retains rejected-admission context when its idle append fails', async () => {
const adapter = new MockAdapter([textResponse('retried')])
it('leaves inbox state unchanged when its durable append fails', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), {
provider: 'mock',
model: 'mock',
})
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
vi.spyOn(agent.session, 'append').mockImplementationOnce(() => {
throw new Error('append unavailable')
})
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } }))
await entered.promise
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'retained context' }],
source: { kind: 'plugin', plugin: 'test' },
}))
decision.resolve({ kind: 'block', reason: 'policy' })
await agent.whenIdle()
expect(() => {
send(agent, 'blocked prompt')
}).toThrow('append unavailable')
expect(events(agent)).toEqual([])
expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
disposeBlock()
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(events(agent).some(event => event.type === 'user/message'
&& JSON.stringify(event.data.content).includes('retained context'))).toBe(true)
expect(agent.inbox.hasPending).toBe(false)
expect(agent.status).toBe('idle')
})
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
const adapter = new MockAdapter([textResponse('ran once')])
it('a blocked prompt preserves adjacent queued prompts', async () => {
const adapter = new MockAdapter([
textResponse('safe reply'),
textResponse('wake reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => {
const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret'
? { kind: 'reject' }
: next()
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
// The rejected admission is dropped; the allowed prompt owns the only turn.
send(agent, 'secret')
send(agent, 'safe')
await waitForIdle(ctx, agent)
const log = events(agent)
// The allowed prompt became a user/message and drove exactly one model call.
const userMsgs = log.filter(e => e.type === 'user/message')
expect(userMsgs).toHaveLength(1)
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
expect(log.filter(e => e.type === 'user/message')).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'blocked' }])
expect(agent.inbox.nextTurn.map(message => message.content[0]))
.toEqual([{ type: 'text', text: 'safe' }])
const resumed = waitForIdle(ctx, agent)
send(agent, 'wake')
await resumed
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('safe')
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret')
})
it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => {
it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false
ctx.on('agent/prompt-submit', async () => {
ctx.on('agent/pre-step', async (_agent, messages) => {
if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'allow' as const }
return { kind: 'enter' as const, messages }
})
const errors: Error[] = []
const reasons: TurnEndReason[] = []
@@ -494,14 +538,18 @@ describe('agent/prompt-submit', () => {
send(agent, 'first')
send(agent, 'second')
await idle
expect(errors).toEqual([])
expect(errors).toEqual([expect.objectContaining({ message: 'prompt hook broke' })])
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(reasons).toEqual([{
kind: 'error',
error: { message: 'prompt hook broke', code: 'UNKNOWN' },
}])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(1)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
expect(adapter.requests).toHaveLength(0)
expect(agent.inbox.nextTurn.map(message => message.content[0]))
.toEqual([{ type: 'text', text: 'second' }])
})
})
@@ -679,10 +727,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
ctx.on('agent/session-start', (agent, source) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => {
const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
// 2. PreStep: reject a forbidden prompt, annotate the rest.
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) {
return { kind: 'reject' }
}
return next()
})
// 3. PreToolUse: deny a dangerous tool by name.
@@ -730,7 +781,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
})
it('the same plugin blocks a destructive prompt before a turn or model call', async () => {
it('the same plugin blocks a destructive prompt inside a no-step turn', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
@@ -743,7 +794,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([])
expect(reasons).toEqual([{ kind: 'blocked' }])
})
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
@@ -756,7 +807,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
// the prompt ran (not rejected) — proving the pre-step listener was disposed
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
})

View File

@@ -25,7 +25,7 @@ function loopRequest<T extends object>(options: T): Readonly<T> {
async function requestSetup() {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -42,12 +42,16 @@ describe('request-reconstruction invariant', () => {
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('uses the step boundary rather than content appended afterward', async () => {
const { ctx, session, boundary } = await requestSetup()
it('includes context appended inside the open step before dispatch', async () => {
const { ctx, session } = await requestSetup()
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' },
content: [{ type: 'text', text: '[step context]' }], source: { kind: 'plugin', plugin: 'x' },
}), { surfaceOp: 'append' })
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
const options = loopRequest({
model: 'm',
messages: Object.freeze(session.deriveMessages()),
sessionId: session.id,
})
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
@@ -57,16 +61,16 @@ describe('request-reconstruction invariant', () => {
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
.not.toThrow()
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
.toThrow(/diverges from the dispatch-time durable derivation/)
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
.toThrow(/diverges from the dispatch-time durable derivation/)
})
it('rejects message and header divergence', async () => {
const { ctx, session, boundary } = await requestSetup()
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
.toThrow(/diverges from the dispatch-time durable derivation/)
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
.toThrow(/diverges from the folded request header/)
})
@@ -74,7 +78,7 @@ describe('request-reconstruction invariant', () => {
it('rejects loop requests with no boundary or header', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
session.append('step/start', { turn: 1, step: 1 })
@@ -122,7 +126,7 @@ describe('request-reconstruction invariant', () => {
await ctx.plugin(InvariantService)
await ctx.plugin(AgentLoopInvariant)
const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -133,6 +137,6 @@ describe('request-reconstruction invariant', () => {
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
sessionId: session.id,
})
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the dispatch-time durable derivation/)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -25,11 +25,7 @@ async function harness(adapter: MockAdapter, persona = '') {
return ctx
}
/**
* Wait for the agent's NEXT transition to idle. Always event-based: callers
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
/** Wait for the agent's next transition to idle after a waking send. */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -73,6 +69,35 @@ describe('agent loop', () => {
expect(adapter.requests[0]?.maxTokens).toBe(256)
})
it('cancels queued wakeup work together with an active maintenance task', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), {
provider: 'mock',
model: 'mock',
})
const started = Promise.withResolvers<undefined>()
const maintenance = agent.runMaintenance(async (signal) => {
started.resolve(undefined)
await new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new Error('maintenance aborted', { cause: signal.reason }))
}, { once: true })
})
})
await started.promise
send(agent, 'discard this wakeup')
agent.cancel({ kind: 'user' })
send(agent, 'park after cancellation')
await expect(maintenance).rejects.toThrow('maintenance aborted')
await agent.whenIdle()
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(adapter.requests).toEqual([])
agent.cancel({ kind: 'user' })
})
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)
@@ -94,11 +119,10 @@ describe('agent loop', () => {
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
const types = agent.session.events.map(e => e.type)
// turn/start opens the turn, THEN the queued user message is recorded inside
// it (every event is turn-enclosed), then the assembled message (carrying the
// step's usage).
expect(types[0]).toBe('turn/start')
expect(types[1]).toBe('user/message')
// Durable inbox receipt precedes the turn-owned transcript.
expect(types[0]).toBe('agent/inbox/spliced')
expect(types).toContain('turn/start')
expect(types).toContain('user/message')
expect(types).toContain('assistant/message')
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length })
@@ -201,9 +225,14 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0) // the request was never sent
expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true)
expect(errors.map(error => error.message)).toEqual([
'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")',
])
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error.message
: '').toContain('no value for this assembly')
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
// agent completes a real model turn.
@@ -470,10 +499,7 @@ describe('agent loop', () => {
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.send(
createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }),
{ target: 'next-step', wakeup: true },
)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }))
return [{ type: 'text', text: 'tool done' }]
},
}))
@@ -481,13 +507,15 @@ describe('agent loop', () => {
send(agent, 'start')
await waitForIdle(ctx, agent)
const types = agent.session.events.map(e => e.type)
expect(types).toContain('steering/message')
// steering recorded before the second step's request derived its history
const steeringSeq = agent.session.events.find(e => e.type === 'steering/message')!.seq
const steering = agent.session.events.find(e =>
e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans'))
expect(steering).toBeDefined()
// The entered batch is appended after the second step opens and before its
// request derives history.
const steeringSeq = steering!.seq
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
expect(secondStepStart).toBeDefined()
expect(steeringSeq).toBeLessThan(secondStepStart!.seq)
expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq)
// the second model request saw the steering content
const secondRequest = adapter.requests[1]
@@ -495,40 +523,41 @@ describe('agent loop', () => {
expect(flat).toContain('change of plans')
})
it('same-tick idle steering preserves one turn per send', async () => {
it('starts idle steering synchronously and enters later steering at the next step', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } }))
expect(agent.status).toBe('running')
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } }))
await idle
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)).toEqual([
[{ type: 'text', text: 'first idle steer' }],
[{ type: 'text', text: 'second idle steer' }],
])
expect(agent.session.events.filter(event => event.type === 'steering/message')).toEqual([])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first idle steer')
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('second idle steer')
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer')
})
it('keeps steering staged after a failed step until the next admitted turn', async () => {
it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true
ctx.on('agent/step', (subject) => {
if (subject !== agent || !fail) return
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (subject !== agent || !fail) return next()
fail = false
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
throw new Error('step failed')
throw new Error('pre-step failed')
})
send(agent, 'prompt')
@@ -536,132 +565,18 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1)
expect(agent.inbox.nextStep).toHaveLength(1)
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
it('rejects failed steering commits while preserving later context', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'rejected steering' }],
source: { kind: 'user' },
}))
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'preserved context' }],
source: { kind: 'plugin', plugin: 'loop-test' },
}))
})
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
if (event.type === 'steering/message' && !rejected) {
rejected = true
throw new Error('reject steering commit')
}
})
send(agent, 'first prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
send(agent, 'recover')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('preserved context')
expect(request).not.toContain('rejected steering')
})
it('rejects committed steering when the step boundary fails', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'committed steering' }],
source: { kind: 'user' },
}))
})
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
if (event.type === 'step/start') throw new Error('reject step boundary')
})
send(agent, 'prompt')
await waitForIdle(ctx, agent)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
})
it('retries context and steering after a context commit fails', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'preserved context' }],
source: { kind: 'plugin', plugin: 'loop-test' },
}))
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'preserved steering' }],
source: { kind: 'user' },
}))
})
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string; data?: { source?: { kind: string } } }
if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) {
rejected = true
throw new Error('reject context commit')
}
})
send(agent, 'first prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
send(agent, 'recover')
await waitForIdle(ctx, agent)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 })
expect(adapter.requests).toHaveLength(1)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('preserved context')
expect(request).toContain('preserved steering')
})
it('inject() while idle appends context without opening a turn', async () => {
it('inject() while idle durably stages context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -671,11 +586,14 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'user/message',
type: 'agent/inbox/spliced',
data: {
role: 'user',
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
target: 'next-step',
inserted: [{
role: 'user',
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}],
},
})
@@ -736,8 +654,6 @@ describe('agent loop', () => {
// split the assistant tool call from the provider's tool-result message.
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const ts0 = turnStarts[0]!
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
const result = agent.session.events.find(e => e.type === 'tool/result')!
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(contexts).toHaveLength(2)
@@ -831,31 +747,20 @@ describe('agent loop', () => {
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
})
it('a concluding tool result beats steering that arrived during the same step', async () => {
it('continues for steering that arrived during a concluding tool step', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'finalize', {}),
textResponse('next turn reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
let contextInjected = false
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'step/end' || contextInjected) return
contextInjected = true
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'final context' }],
source: { kind: 'plugin', plugin: 'finalize' },
}))
})
ctx.tools.register(defineContentToolFixture({
name: 'finalize',
description: '',
parameters: {},
async execute(_args, exec) {
// Steering lands while the concluding tool is still executing; the
// step/end listener adds ordinary context after the normal result drain.
receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
// Steering lands while the concluding tool is still executing.
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
exec.concludeTurn()
return [{ type: 'text', text: 'final' }]
},
@@ -864,26 +769,15 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
// The terminal result stands: no extra request reopens the concluded turn.
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(2)
const events = agent.session.events.map(event => event.type)
expect(events.filter(type => type === 'turn/end')).toHaveLength(1)
if (receipt === undefined) throw new Error('concluding tool did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(events).not.toContain('steering/message')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true)
send(agent, 'follow up')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('late steering')
const texts = adapter.requests[1]!.messages
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
expect(texts).toContain('final context')
expect(texts).not.toContain('late steering')
expect(texts).toContain('late steering')
})
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
@@ -909,7 +803,7 @@ describe('agent loop', () => {
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
})
it('agent/step fires once per step before the step is opened', async () => {
it('agent/pre-step fires once per proposed step before the step is opened', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
@@ -922,8 +816,9 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/step', (subject, turn, step, signal) => {
ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => {
if (subject === agent) fires.push({ turn, step, signal })
return next()
})
send(agent, 'go')
@@ -936,50 +831,33 @@ describe('agent loop', () => {
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
})
it('agent/step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// The append lands before step/start, yet derive happens afterwards and the
// same step's request must include it.
it('agent/pre-step fires before its step boundary opens and before the request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/step', (subject) => {
if (subject === agent && !injected) {
injected = true
subject.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
source: { kind: 'plugin', plugin: 'test' },
}), { surfaceOp: 'append' })
}
let boundaryOpen = true
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// The adapter's request includes the node injected during pre-step (derive
// reflects it).
const text = JSON.stringify(adapter.requests[0]!.messages)
expect(text).toContain('INJECTED-IN-PRE-STEP')
// And the injected event sits BEFORE the first step/start in the log —
// the seam fired outside the step.
const events = agent.session.events
const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
expect(boundaryOpen).toBe(false)
expect(adapter.requests).toHaveLength(1)
})
it('a throwing agent/step listener ends the turn (error), not the loop', async () => {
// Before step/start, a pre-step throw reaches the turn catch: no step needs
// closing, the turn records error, and the loop remains available.
it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => {
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true
ctx.on('agent/step', () => {
ctx.on('agent/pre-step', (_agent, _messages, _context, next) => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
return next()
})
const errors: Error[] = []
@@ -989,16 +867,11 @@ describe('agent loop', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
// The first turn failed at step 1 (no model call happened), surfaced via
// agent/error, with the durable failure on turn/end.reason.
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('boom in pre-step')
// The first proposal failed inside a balanced turn without calling the model.
expect(errors.map(error => error.message)).toEqual(['boom in pre-step'])
expect(adapter.requests.length).toBe(0)
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The step opened-and-closed count stays balanced even though it never ran.
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true)
// The loop survived: a second prompt runs a normal completed turn.
send(agent, 'second')
@@ -1023,7 +896,7 @@ describe('agent loop', () => {
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
@@ -1094,6 +967,8 @@ describe('agent loop', () => {
source: { kind: 'plugin', plugin: 'max-tokens-test' },
},
])
// A max-token step is sticky: the later completed step must not
// downgrade the turn outcome.
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
@@ -1311,14 +1186,15 @@ describe('agent loop', () => {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
})
it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
it('contains a reentrant send attempted during durable inbox publication', async () => {
const adapter = new MockAdapter([textResponse('first')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let nested = false
ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent || nested) return
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced'
|| event.data.inserted.length === 0 || nested) return
nested = true
send(agent, 'queued listener message')
})
@@ -1331,11 +1207,8 @@ describe('agent loop', () => {
const messages = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)
expect(turns).toHaveLength(2)
expect(messages).toEqual([
[{ type: 'text', text: 'outer message' }],
[{ type: 'text', text: 'queued listener message' }],
])
expect(turns).toHaveLength(1)
expect(messages).toEqual([[{ type: 'text', text: 'outer message' }]])
})
it('preserves independent turn sources across an adjacent microtask send', async () => {
@@ -1349,16 +1222,11 @@ describe('agent loop', () => {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } }))
await idle
const triggers = agent.session.events
.filter(event => event.type === 'turn/start')
.map(event => event.data.trigger)
const turns = agent.session.events.filter(event => event.type === 'turn/start')
const sources = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.source)
expect(triggers).toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
])
expect(turns).toHaveLength(2)
expect(sources).toEqual([
{ kind: 'user' },
{ kind: 'plugin', plugin: 'test' },
@@ -1379,7 +1247,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_s, event) => {
if (event.type === 'assistant/chunk' && !queued) {
queued = true
send(agent, 'second message')
queueMicrotask(() => { send(agent, 'second message') })
}
})
@@ -1421,15 +1289,15 @@ describe('agent loop', () => {
])
})
it('errors from the model surface as agent/error and end the turn', async () => {
it('records normalized model errors on the turn boundary', async () => {
const adapter = new MockAdapter([]) // script exhausted → throws
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
const errors: unknown[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
errors.push(error)
})
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -1437,12 +1305,15 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('script exhausted')
expect(errors[0]).toBeInstanceOf(LlmError)
expect((errors[0] as LlmError).failure).toEqual({
message: 'MockAdapter: script exhausted',
code: 'UNKNOWN',
})
expect(reasons[0]).toMatchObject({ kind: 'error' })
// The durable failure lives entirely on turn/end.reason (with the failing
// step), not a standalone error event.
// The durable failure and live relay describe the same failed turn.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' })
})
it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => {

View File

@@ -78,7 +78,7 @@ function userMessageTexts(agent: Agent): string[] {
function turnNumbers(agent: Agent): number[] {
return agent.session.events
.filter(e => e.type === 'turn/start')
.map(e => (e.data as { turn: number }).turn)
.map(e => e.data.turn)
}
function turnEndNumbers(agent: Agent): number[] {

View File

@@ -59,26 +59,15 @@ describe('agent/request-error', () => {
turn: number
step: number
failure: LlmFailure
priorFailures: readonly LlmFailure[]
retryPolicy: ResolvedRetryPolicy | undefined
}[] = []
const statuses: string[] = []
const settledTurns: number[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('agent/settled', (subject, turn) => {
if (subject === agent) settledTurns.push(turn)
})
ctx.on('agent/request-error', async (
subject, turn, step, _error, failure, priorFailures, retryPolicy,
) => {
ctx.on('agent/request-error', async (subject, context) => {
expect(subject).toBe(agent)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'step/end',
data: { turn, step },
})
seen.push({ turn, step, failure, priorFailures, retryPolicy })
seen.push(context)
return { kind: 'retry' }
})
@@ -96,25 +85,17 @@ describe('agent/request-error', () => {
code: 'RATE_LIMIT',
},
{
turn: 2,
turn: 1,
step: 1,
code: 'SERVICE_UNAVAILABLE',
},
])
expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger))
.toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'retry' },
{ kind: 'retry' },
])
expect(seen.map(item => item.priorFailures.map(failure => failure.code)))
.toEqual([[], ['RATE_LIMIT']])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(seen.map(item => item.retryPolicy)).toEqual([
expect.objectContaining({ mode: 'normal' }),
expect.objectContaining({ mode: 'normal' }),
])
expect(statuses).toEqual(['running', 'idle'])
expect(settledTurns).toEqual([3])
})
it('lets cancellation win over a retry action', async () => {
@@ -133,7 +114,7 @@ describe('agent/request-error', () => {
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted' } },
data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
})
})

View File

@@ -350,10 +350,6 @@ describe('request stability across the loop', () => {
}
}([])
const ctx = await harness(adapter)
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
provider: 'mock',
model: 'mock',
@@ -362,7 +358,13 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toContain(failure)
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: {
reason: failure instanceof LlmError
? { kind: 'error', error: failure.failure }
: { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } },
},
})
expect(adapter.requests).toHaveLength(0)
},
)
@@ -409,19 +411,13 @@ describe('request stability across the loop', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
// A pre-step listener compacts turn 1's history before turn 2's step —
// the sanctioned surface rewrite, landing OUTSIDE the step.
const preStep = ctx.on('agent/step', () => {
preStep()
const session = agent.session
const nodes = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}), {
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
sourceEventSeqs: [nodes[0]!, nodes[1]!],
})
const nodes = agent.session.surface.nodes
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}), {
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
sourceEventSeqs: [nodes[0]!, nodes[1]!],
})
send(agent, 'second')
@@ -491,10 +487,6 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
ctx.on('llm/stream', (options, next) => {
// The historical failure mode this design kills: a listener rewriting
// request content in place. The freeze turns it into a loud error.
@@ -508,8 +500,10 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } })
if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error()
expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i)
})
it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
@@ -595,14 +589,18 @@ describe('request stability across the loop', () => {
adapter.requests.forEach((request, index) => {
const stepStart = stepStarts[index]!
// Messages: the derivation over the log prefix strictly before this
// step's step/start — rebuilt here through a completely fresh Session.
const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
const firstChunk = events.find(e =>
e.type === 'assistant/chunk'
&& e.data.turn === stepStart.data.turn
&& e.data.step === stepStart.data.step,
)!
// Messages: the entered batch is logged after step/start, so rebuild the
// complete dispatch prefix through a completely fresh Session.
const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq)))
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
// Header: the latest request/header snapshot up to this step's dispatch
// (its header event sits between step/start and the first chunk).
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
expect(request.model).toBe(header.config.model)
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)

View File

@@ -1,12 +1,12 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
@@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
// balanced completed turn is the smallest resumable log and avoids running
// the model merely to construct this lifecycle fixture.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const session = ctx.sessions.create(sessionId, { seed })
@@ -52,6 +52,18 @@ async function persistSession(sessionId: SessionId): Promise<string> {
return root
}
/** Build a detached preparation for lifecycle-race test doubles. */
function preparationFromSnapshot(
ctx: Context,
snapshot: { meta: SessionHeader; events: readonly SessionEvent[] },
): SessionPreparation {
return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, {
seed: structuredClone(snapshot.events) as SessionEvent[],
meta: structuredClone(snapshot.meta),
seedSource: 'persistence',
}))
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -77,7 +89,7 @@ function throwUnknown(value: unknown): never {
}
describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
it('resumes a session persisted before messages gained identities', async () => {
it('resumes a pre-react-loop session including pre-identity message events', async () => {
const sessionId = SessionId('pre-identity-resume')
const first = await persistentHarness(new MockAdapter([]))
await first.ctx.sessionPersistence.create({
@@ -86,7 +98,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
createdAt: 1,
})
await first.ctx.sessionPersistence.append(sessionId, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'turn/start', seq: 0, time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'user/message',
seq: 1,
@@ -107,8 +122,19 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
},
surfaceOp: 'append',
},
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
{
type: 'steering/message',
seq: 4,
time: 5,
data: {
turn: 1,
content: [{ type: 'text', text: 'old steering' }],
source: { kind: 'user' },
},
surfaceOp: 'append',
},
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
] as unknown as SessionEvent[])
await first.ctx.fiber.dispose()
@@ -120,14 +146,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(handle.agent.session.deriveMessages()).toMatchObject([
{ id: `legacy-message:${sessionId}:1`, role: 'user' },
{ id: `legacy-message:${sessionId}:3`, role: 'assistant' },
{ id: `legacy-message:${sessionId}:4`, role: 'user' },
])
expect(handle.agent.inbox.nextTurn).toEqual([])
expect(handle.agent.inbox.nextStep).toEqual([])
handle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'new question' }],
source: { kind: 'user' },
}))
await waitForIdle(ctx, handle.agent)
expect(handle.agent.session.deriveMessages()).toHaveLength(4)
expect(handle.agent.session.deriveMessages()).toHaveLength(5)
expect(handle.agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -175,11 +204,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
const sessionId = SessionId('live-resume-race')
const first = (await ctx.agents.create({ sessionId })).agent
first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.session.append('turn/start', { turn: 1 })
await ctx.sessions.flush(first.session)
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
.rejects.toThrow(/live turn is open/)
.rejects.toThrow(/while it is live/)
first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(first.session)
@@ -429,22 +458,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx.fiber.dispose()
})
it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => {
it('owner unload aborts a never-settling persistence preparation, releases the identity, and blocks late publication', async () => {
const sessionId = SessionId('resume-load-owner-unload')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
const lateLoad = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
let loads = 0
ctx.sessionPersistence.load = (id) => {
const abandoned = preparationFromSnapshot(ctx, snapshot)
const latePreparation = Promise.withResolvers<SessionPreparation>()
const preparationStarted = Promise.withResolvers<undefined>()
const originalPrepare = ctx.sessionPersistence.prepare.bind(ctx.sessionPersistence)
let preparations = 0
ctx.sessionPersistence.prepare = (id, signal) => {
expect(id).toBe(sessionId)
loads += 1
if (loads === 1) {
loadStarted.resolve(undefined)
return lateLoad.promise
preparations += 1
if (preparations === 1) {
preparationStarted.resolve(undefined)
return latePreparation.promise
}
return Promise.resolve(structuredClone(snapshot))
return originalPrepare(id, signal)
}
const published: string[] = []
@@ -456,7 +487,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
}, { inject: ['agents'] }))
await loadStarted.promise
await preparationStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
@@ -468,23 +499,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
// can be reused before awaiting the public rejection.
const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
await rejection
expect(loads).toBe(2)
expect(preparations).toBe(2)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
// Settlement of the abandoned backend promise cannot resume the old
// transaction or emit a second publication after the retry owns the ids.
lateLoad.resolve(structuredClone(snapshot))
latePreparation.resolve(abandoned)
await Promise.resolve()
await Promise.resolve()
expect(ctx.agents.get(sessionId)).toBe(retry.agent)
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
abandoned[Symbol.dispose]()
await retry.dispose()
await ctx.fiber.dispose()
})
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
it('AgentLoop unload aborts persistence preparation and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const root = await persistSession(sessionId)
const ctx = new Context()
@@ -498,19 +530,20 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
const lateLoad = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.load = (id) => {
const abandoned = preparationFromSnapshot(ctx, snapshot)
const latePreparation = Promise.withResolvers<SessionPreparation>()
const preparationStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.prepare = (id) => {
expect(id).toBe(sessionId)
loadStarted.resolve(undefined)
return lateLoad.promise
preparationStarted.resolve(undefined)
return latePreparation.promise
}
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
await loadStarted.promise
await preparationStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
await promptly(loopFiber.dispose())
await rejection
@@ -518,10 +551,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(published).toEqual([])
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
lateLoad.resolve(structuredClone(snapshot))
latePreparation.resolve(abandoned)
await Promise.resolve()
await Promise.resolve()
expect(published).toEqual([])
abandoned[Symbol.dispose]()
await ctx.fiber.dispose()
})
@@ -530,7 +564,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
// in its header) by creating it with a complete-turn seed — the write path
// materializes the fork (header + seed) on disk.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const adapter1 = new MockAdapter([textResponse('a')])
@@ -567,7 +601,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx2.fiber.dispose()
})
it('an idle inject() survives persist + resume without a synthetic turn', async () => {
it('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
@@ -575,9 +609,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await waitForIdle(ctx1, a1)
a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
await a1.whenIdle()
await ctx1.fiber.dispose()
await ctx1.sessions.flush(a1.session)
// Lifecycle 2: resume; the injected context is still in the derived history.
// Lifecycle 2: resume; the injected context is still pending and becomes
// model-visible when the next turn admits it.
const adapter2 = new MockAdapter([textResponse('next')])
const ctx2 = new Context()
await ctx2.plugin(LlmService)
@@ -588,10 +623,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const loaded = await ctx2.sessionPersistence.load(SessionId('inject-sess'))
expect(loaded.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
expect(JSON.stringify(a2.inbox.nextStep)).toContain('background task 42 finished')
a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await waitForIdle(ctx2, a2)
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
await ctx1.fiber.dispose()
})
it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
@@ -705,6 +747,24 @@ describe('creation and resume cancellation edges', () => {
await ctx.fiber.dispose()
})
it('rejects when setup synchronously aborts its caller signal', async () => {
const { ctx } = await persistentHarness(new MockAdapter([]))
const controller = new AbortController()
const creating = ctx.agents.create({
sessionId: SessionId('setup-synchronous-abort'),
agentOptions: { provider: 'mock', model: 'mock' },
signal: controller.signal,
setup() {
controller.abort(new Error('setup synchronously cancelled'))
},
})
await expect(promptly(creating)).rejects.toThrow('setup synchronously cancelled')
expect(ctx.agents.get(SessionId('setup-synchronous-abort'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('resume with a pre-aborted caller signal rejects out of the load race', async () => {
const sessionId = SessionId('resume-pre-aborted')
const root = await persistSession(sessionId)
@@ -718,19 +778,45 @@ describe('creation and resume cancellation edges', () => {
signal: controller.signal,
}))).rejects.toThrow('resume abandoned')
const stringReason = new AbortController()
stringReason.abort('resume string reason')
await expect(promptly(ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
signal: stringReason.signal,
}))).rejects.toThrow(/creation aborted/)
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('factory teardown during a hung resume load rejects with loop-inactive', async () => {
it('releases a restored preparation if the loop becomes inactive before setup', async () => {
const sessionId = SessionId('resume-loop-inactive-after-prepare')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
const loop = ctx.agentLoop as unknown as {
ownership: { isActive: () => boolean }
}
vi.spyOn(loop.ownership, 'isActive').mockReturnValueOnce(false)
await expect(ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})).rejects.toThrow('agent loop is not active')
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('factory teardown during a hung resume preparation rejects with loop-inactive', async () => {
const sessionId = SessionId('resume-loop-teardown')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
const gate = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.load = () => {
loadStarted.resolve(undefined)
const abandoned = preparationFromSnapshot(ctx, snapshot)
const gate = Promise.withResolvers<SessionPreparation>()
const preparationStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.prepare = () => {
preparationStarted.resolve(undefined)
return gate.promise
}
@@ -738,27 +824,28 @@ describe('creation and resume cancellation edges', () => {
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await loadStarted.promise
// Resolve the load only after teardown began: the post-load ownership
await preparationStarted.promise
// Resolve the preparation only after teardown began: the post-prepare ownership
// check, not the abort race, must reject the wrapper.
const rejection = expect(promptly(resuming)).rejects.toThrow()
const disposal = ctx.fiber.dispose()
gate.resolve(structuredClone(snapshot))
gate.resolve(abandoned)
await rejection
await disposal
abandoned[Symbol.dispose]()
})
})
describe('configured-start failure edges', () => {
it('a non-Error mid-load abort reason is wrapped for the resume caller', async () => {
it('a non-Error mid-prepare abort reason is wrapped for the resume caller', async () => {
const sessionId = SessionId('resume-string-mid-abort')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
const gate = Promise.withResolvers<never>()
gate.promise.catch(() => undefined)
const loadStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.load = () => {
loadStarted.resolve(undefined)
const preparationStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.prepare = () => {
preparationStarted.resolve(undefined)
return gate.promise
}
const controller = new AbortController()
@@ -768,7 +855,7 @@ describe('configured-start failure edges', () => {
agentOptions: { provider: 'mock', model: 'mock' },
signal: controller.signal,
})
await loadStarted.promise
await preparationStarted.promise
controller.abort('operator string reason')
await expect(promptly(resuming)).rejects.toThrow(/creation aborted/)
@@ -783,7 +870,7 @@ describe('configured-start failure edges', () => {
// The artifact exists (list reports it) but its load fails: this is
// corruption, not first creation — the failure must be reported, and no
// fresh same-id session may shadow the broken one.
ctx.sessionPersistence.load = () => Promise.reject(new Error('artifact corrupt'))
ctx.sessionPersistence.prepare = () => Promise.reject(new Error('artifact corrupt'))
const configured = new Context()
await configured.plugin(LlmService)
@@ -793,7 +880,7 @@ describe('configured-start failure edges', () => {
await configured.plugin(AgentRegistry)
await configured.plugin(SessionPersistenceJsonl, { root })
configured.llm.registerAdapter(['mock'], new MockAdapter([]))
configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
const configFailures: unknown[] = []
configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) })
const configWarnings: string[] = []
@@ -822,9 +909,9 @@ describe('configured-start failure edges', () => {
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
const gate = Promise.withResolvers<never>()
gate.promise.catch(() => undefined)
const loadStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.load = () => {
loadStarted.resolve(undefined)
const preparationStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.prepare = () => {
preparationStarted.resolve(undefined)
return gate.promise
}
const failures: unknown[] = []
@@ -838,12 +925,12 @@ describe('configured-start failure edges', () => {
await configured.plugin(AgentRegistry)
await configured.plugin(SessionPersistenceJsonl, { root })
configured.llm.registerAdapter(['mock'], new MockAdapter([]))
configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
const loop = await configured.plugin(AgentLoop, {
agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
})
await loadStarted.promise
await preparationStarted.promise
const disposal = loop.dispose()
gate.reject(new Error('late backend failure'))
await disposal

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { RuntimeContextProjection } from '../src/runtime-context.ts'
const SOURCE = '@deepseek-ai/dsh-system-prompt'
function contextMessage(text: string) {
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: SOURCE },
})
}
describe('RuntimeContextProjection', () => {
it('restores the latest visible owned snapshot and ignores other sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('runtime-context-replay'))
const retained = session.append('user/message', contextMessage('retained'), { surfaceOp: 'append' })
const shadowed = session.append('user/message', contextMessage('shadowed'), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: shadowed.seq, end: shadowed.seq },
sourceEventSeqs: [shadowed.seq],
})
const projection = new RuntimeContextProjection(ctx, session)
expect(session.surface.nodes).toContain(retained.seq)
expect(projection.project('retained', [])).toBeUndefined()
expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({
kind: 'plugin',
plugin: SOURCE,
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'policy' }],
})
const other = ctx.sessions.create(SessionId('runtime-context-other'))
other.append('user/message', contextMessage('other'), { surfaceOp: 'append' })
expect(projection.project('retained', [])).toBeUndefined()
})
})

View File

@@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => {
expect(agent.ctx.agent).toBe(agent)
// The root accessor default: a plain context answers undefined, not a throw.
expect(ctx.agent).toBeUndefined()
await ctx.agents.get(SessionId('a1'))?.whenIdle()
await agent.whenIdle()
})
it('records agents created through an agent context as non-root runtime children', async () => {
@@ -1074,7 +1074,7 @@ describe('agent scope lifecycle', () => {
await waitForIdle(ctx, agent)
expect(reentered).toBe(true)
// Idle again: the reentrant admission was already claimed and settled (its
// Idle again: the reentrant batch was already claimed and settled (its
// prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
// fires from the disposal cancel's idle transition itself.
reentered = false

View File

@@ -518,10 +518,10 @@ describe('tool-call scheduler: abort handling', () => {
])
})
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
it('stops replenishing after abort, commits started results, and parks accepted additional contexts', async () => {
const adapter = new MockAdapter([
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
textResponse('should never be requested'),
textResponse('after wake'),
])
const ctx = await harness(adapter, 2)
const gated = gatedParallelTool('p')
@@ -566,9 +566,23 @@ describe('tool-call scheduler: abort handling', () => {
const settled = events(agent).filter(e => e.type === 'tool/result'
|| (e.type === 'user/message' && e.data.source.kind === 'plugin'))
expect(settled.map(e => e.type))
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message'])
expect(settled.filter(e => e.type === 'user/message')
.map(e => (e.data.content[0] as { text: string }).text))
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result'])
expect(agent.inbox.nextStep.map(message => message.content[0]))
.toEqual([
{ type: 'text', text: 'ctx-c1' },
{ type: 'text', text: 'ctx-c2' },
])
const idle = waitForIdle(ctx, agent)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } }))
await idle
expect(events(agent).flatMap(e =>
e.type === 'user/message'
&& e.data.source.kind === 'plugin'
&& e.data.content[0]?.type === 'text'
? [e.data.content[0].text]
: []))
.toEqual(['ctx-c1', 'ctx-c2'])
})
@@ -648,10 +662,6 @@ describe('tool-call scheduler: failure quiescence', () => {
? new Promise((_resolve, reject) => { rejectFirst = reject })
: dispatch(exec).then(() => { throw drainedError })
const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' })
const errors: unknown[] = []
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject === agent) errors.push(error)
})
let idle = false
const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true })
@@ -664,15 +674,16 @@ describe('tool-call scheduler: failure quiescence', () => {
const startedBeforeDrain = [...gated.started]
const idleBeforeDrain = idle
const errorsBeforeDrain = [...errors]
const turnEndBeforeDrain = events(agent).find(event => event.type === 'turn/end')
for (const id of gated.pending()) gated.release(id)
await idlePromise
expect(startedBeforeDrain).toEqual(['2'])
expect(idleBeforeDrain).toBe(false)
expect(errorsBeforeDrain).toEqual([])
expect(turnEndBeforeDrain).toBeUndefined()
expect(gated.pending()).toEqual([])
expect(errors).toEqual([schedulerError])
expect(errors[0]).toBe(schedulerError)
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
})
})
})

View File

@@ -93,24 +93,18 @@ describe('loop-level canonical tool order', () => {
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
})
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
// Unknown tool order fails before step or request creation and returns the agent to idle.
it('closes a no-step turn when toolOrder names an unregistered tool', async () => {
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The turn is balanced (turn/start → turn/end) with no step events inside.
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true)
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false)
})
})

View File

@@ -1,286 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function prompt(agent: Agent, text: string): void {
agent.followup(createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}))
}
function itemText(item: InboxItem): string {
return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
}
interface InboxRecording {
readonly events: string[]
readonly enqueued: InboxItem['id'][]
readonly dequeued: InboxItem['id'][]
readonly discarded: InboxItem['id'][]
}
/** Record the complete inbox lifecycle of one agent for order and identity assertions. */
function recordInbox(ctx: Context): InboxRecording {
const events: string[] = []
const enqueued: InboxItem['id'][] = []
const dequeued: InboxItem['id'][] = []
const discarded: InboxItem['id'][] = []
ctx.on('agent/inbox/enqueue', (_agent, item) => {
events.push(`enqueue:${item.placement}:${itemText(item)}`)
enqueued.push(item.id)
})
ctx.on('agent/inbox/dequeue', (_agent, item) => {
events.push(`dequeue:${itemText(item)}`)
dequeued.push(item.id)
})
ctx.on('agent/inbox/discard', (_agent, items) => {
events.push(`discard:${items.map(itemText).join(',')}`)
discarded.push(...items.map(item => item.id))
})
return { events, enqueued, dequeued, discarded }
}
/** Text of every ordinary prompt the log admitted, in durable order. */
function promptTexts(agent: Agent): string[] {
return agent.session.events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}
describe('idle turn admission reservation', () => {
it('holds later waking prompts in the FIFO until release', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
prompt(agent, 'first prompt')
prompt(agent, 'second prompt')
expect(agent.acceptsNextStep).toBe(false)
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
])
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt'])
expect(agent.session.events.flatMap(event =>
event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2])
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
'dequeue:first prompt',
'dequeue:second prompt',
])
expect(inbox.dequeued).toEqual(inbox.enqueued)
expect(inbox.discarded).toEqual([])
})
it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
prompt(agent, 'accepted first')
expect(agent.status).toBe('idle')
expect(agent.reserveTurnAdmission()).toBeUndefined()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('refuses acquisition while a turn is running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reserved: unknown[] = []
ctx.on('agent/step', () => {
reserved.push(agent.reserveTurnAdmission())
})
prompt(agent, 'running')
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(reserved).toEqual([undefined])
})
it('refuses a second reservation and releases idempotently', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
expect(agent.reserveTurnAdmission()).toBeUndefined()
prompt(agent, 'queued behind the reservation')
release?.()
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['queued behind the reservation'])
expect(adapter.requests).toHaveLength(1)
const second = agent.reserveTurnAdmission()
expect(second).toBeDefined()
second?.()
})
it('ignores a stale release once a later reservation owns the boundary', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const stale = agent.reserveTurnAdmission()
stale?.()
const live = agent.reserveTurnAdmission()
prompt(agent, 'held by the live reservation')
stale?.()
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(adapter.requests).toHaveLength(0)
live?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('acquires beside quiet queued work and leaves it queued', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send(createUserMessage({
content: [{ type: 'text', text: 'quiet' }],
source: { kind: 'user' },
}), {
target: 'next-turn',
wakeup: false,
})
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
})
it('makes whenIdle() wait for release without spinning on a settled promise', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const machine = agent as Agent & { done: Promise<void> }
let backing = machine.done
let reads = 0
Object.defineProperty(agent, 'done', {
configurable: true,
get(): Promise<void> {
reads += 1
return backing
},
set(value: Promise<void>) {
backing = value
},
})
const release = agent.reserveTurnAdmission()
prompt(agent, 'waiting for the reservation')
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
for (let tick = 0; tick < 5; tick += 1) {
await new Promise<void>((resolve) => { setTimeout(resolve, 1) })
}
expect(settled).toBe(false)
expect(reads).toBeLessThanOrEqual(2)
release?.()
await idle
expect(settled).toBe(true)
expect(adapter.requests).toHaveLength(1)
})
it('resolves whenIdle() after release with nothing queued', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(settled).toBe(false)
release?.()
await idle
expect(agent.status).toBe('idle')
})
it('lets cancellation discard held prompts and keeps the boundary quiet', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded while held')
agent.cancel({ kind: 'user' })
expect(inbox.events).toEqual([
'enqueue:queued:discarded while held',
'discard:discarded while held',
])
expect(inbox.discarded).toEqual(inbox.enqueued)
expect(inbox.dequeued).toEqual([])
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
})
it('disposes the agent without waiting for the reservation to be released', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('a1'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { agent } = handle
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded by disposal')
await handle.dispose()
expect(ctx.agents.list()).toEqual([])
expect(adapter.requests).toHaveLength(0)
release?.()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: 98421aa6de3d6778702665854ed723507e933028
README.zh.md: 287a0feddba5c1092aab17e361b9382a52179b63
README.md: c3d6e6c24480894b6059417c1ab89db7aa0d7fa2
README.zh.md: 16ee8f5e6c483555839b0c3ab174e2e2356b1359

View File

@@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control.
@@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
@@ -50,9 +50,11 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity.
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary.
Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope.
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
@@ -60,18 +62,15 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session.
- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or strictly steers one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Strict steer requires `acceptsNextStep`, ends the queued occurrence, and accepts the same immutable message as a new steering occurrence with a new `InboxItemId`; a closed window returns `steer-unavailable` without mutation. Pending steering and claimed occurrences return `not-found`.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it.
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
- `agent.steer(message)` — queue waking `next-step` input. An idle agent starts a turn synchronously; a running driver consumes later steering at its next step boundary.
- `agent.inject(message)` — queue non-waking `next-step` context. A running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.
- `agent.cancel(cause, options?)` — cancel the active driver and, unless `options.keepInbox`, durably cancel all pending inbox work. Idle cancellation is a no-op.
- `agent.whenIdle()` — observe whole-agent quiescence, including replacement work scheduled before the current driver retires. It does not settle any particular message.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`, `agent.ctx`
`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns.
`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. Only a caller that owns a complete interval may summarize it as a run result ([decision](../../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)).
### Extension points
@@ -85,7 +84,7 @@ The handle every plugin programs against:
#### What the model sees
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself.
`send`, `steer`, and `inject` feed the owning session. `agent/pre-step` and other declared events let plugins reject a proposed step or add durable request material; this interface contributes no fixed prose itself.
#### Token effect

View File

@@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
### 公开 API
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在 dispose资源释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在 dispose资源释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。具体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header并应用到每次对话模型请求显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。
@@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上消费方UI、ACPAgent Client Protocol桥接层可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。
- `ctx.agents.setFactory(factory: AgentFactory): () => void`注册创建工厂循环在构造时调用。第二个工厂会导致抛出dispose 时清空槽位。
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent在不发布的情况下等待可选 setup调用其可选的同步提交,然后通过最终的 `SessionStore.enter()``AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID多个操作可以进行准备但只有一个能进入每个失败方都会回滚其私有作用域会话驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup并在返回 handle 前分离;之后的取消使用 `handle.dispose()``agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent在不发布的情况下等待可选 setup然后通过最终的 `SessionStore.enter()``AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID多个操作可以进行准备但只有一个能进入每个失败方都会回滚其私有作用域会话驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup并在返回 handle 前分离;之后的取消使用 `handle.dispose()``agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent从存储中移除其会话最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`ACP 桥接层与进程内 subagent 后端持有消费方 handle而配置创建的 agent 已由循环 fiber 拥有。
@@ -50,9 +50,11 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
大多数拦截点都是协作式 waterfall瀑布式事件轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧接在 waterfall 最终的 `next` 之前;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域运行时设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
大多数拦截点都是协作式 waterfall瀑布式事件`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn``step` 与取消 `signal``PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()``agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content``additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界
inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }``agent/inbox/claimed { message, turn }``agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*``step/*``assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
@@ -60,18 +62,15 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
每个插件面向的 handle
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target``wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId``agent/inbox/enqueue``update` 及终态 `dequeue``discard` 都会携带这一完整 `InboxItem``target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'``wakeup: true` 提交 steering中途引导`target: 'next-step'``wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有
- `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩compaction等独立持久操作能够在排队提示词从会话派生内容前完成并 flush
- `agent.updateInbox(itemId, action)`:同步编辑、移除一个仍处于待处理状态的 queued 入队项,或对其执行严格 steering。编辑会替换已冻结的内容同时保留其 `MessageId``InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。严格 steering 要求 `acceptsNextStep` 为 true它会结束 queued 单次入队项,并把同一条不可变消息接受为新的 steering 单次入队项,后者使用新的 `InboxItemId`。窗口关闭时返回 `steer-unavailable`,且不做任何变更。待处理 steering 和已被认领的项会返回 `not-found`
- `agent.followup(input)``send()``next-turn`wakeup 预设:排队一个普通后续轮次并唤醒驱动器
- `agent.steer(input)``next-step`wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose 或准入前故障会使其解析为 `rejected``cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它
- `agent.inject(input)``next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件
- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。
- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时首个信号生效空闲取消是安全空操作不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`
- `agent.whenIdle()`agent 从 `running` 结算后达到完全停稳时解析idle ⇒ 立即disposed ⇒ 等待循环退出)。这是非拥有者的完全停稳观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。
- `agent.session``agent.status``agent.options``agent.id`
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``replace``remove``clear``splice``claim` 用于变更队列;`replace(messageId, newMessage)``remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`
- `agent.steer(message)`:将会唤醒的 `next-step` steering中途引导输入排队。agent 空闲时会同步启动一个轮次;驱动器运行期间收到的后续 steering 会在下一个步骤边界被消费
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。运行中的驱动器会在最近的后续 pre-step 边界领取它idle 驱动器则会让它保持待处理,直至 `followup()``steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求
- `agent.cancel(cause, options?)`:取消活跃驱动器,并在未设置 `options.keepInbox` 时持久取消全部待处理 inbox 工作。空闲取消是空操作
- `agent.whenIdle()`:观察整个 agent 达到完全停稳,包括当前驱动器退役前调度的替代工作。它不结算任何特定消息
- `agent.session``agent.status``agent.options``agent.id``agent.ctx`
`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。
`running` 描述驱动器范围的 drain 区间,而不是轮次仍打开的证明;它可以覆盖轮次关闭、持久性检查点和连续的排队轮次。只有拥有完整区间的调用方才能将其概括为一次运行的结果([决策](../../../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。
### 扩展点
@@ -85,7 +84,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
#### 模型看到的内容
`send``steer``inject` 会向所属会话提供输入。`agent/prompt-submit``agent/step` 和其他已声明事件让插件能够阻止提示词或添加持久请求材料;此接口本身不贡献固定文案。
`send``steer``inject` 会向所属会话提供输入。`agent/pre-step` 和其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料;此接口本身不贡献固定文案。
#### Token 影响

View File

@@ -15,22 +15,16 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
@@ -39,7 +33,6 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",

View File

@@ -1,23 +0,0 @@
/**
* dsh-agent's owned branded ids for live inbox occurrences.
*
* @module @deepseek-ai/dsh-agent/brand
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Identifies one accepted occurrence in an agent inbox. Re-sending the same
* message creates a distinct item id, so pending work remains independently
* addressable.
*/
export type InboxItemId = Branded<'InboxItemId'>
/**
* Brand a string as an {@link InboxItemId}.
* @param id - the agent-loop-minted occurrence identifier.
* @returns the same string, branded; no validation is performed.
*/
export function InboxItemId(id: string): InboxItemId {
return id as InboxItemId
}

View File

@@ -0,0 +1,222 @@
/**
* Incremental projection of durable agent inbox events.
*
* @module @deepseek-ai/dsh-agent/inbox
*/
import type { MessageId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
/** One of the two ordered pending-message lists owned by an agent. */
export type InboxTarget = 'next-turn' | 'next-step'
/** Mutable state privately owned by an {@link Inbox}. */
type InboxState = Record<InboxTarget, UserMessage[]>
/** Live notifications committed by inbox mutations. */
export interface InboxNotifications {
/** Publish one inserted message. */
inserted(message: UserMessage): void
/** Publish one discarded message. */
discarded(message: UserMessage): void
/** Publish one claimed message inside its owning turn. */
claimed(message: UserMessage, turn: number): void
}
/** A replay-once projection that incrementally consumes later inbox splices. */
export class Inbox {
private readonly state: InboxState = { 'next-turn': [], 'next-step': [] }
constructor(
private readonly session: Session,
private readonly notifications: InboxNotifications,
) {
for (const event of session.events.slice(session.header.seedLength ?? 0)) {
if (event.type !== 'agent/inbox/spliced') continue
try {
this.apply(event.data)
} catch (error: unknown) {
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
}
}
}
/** Prompts awaiting individual turns. */
get nextTurn(): readonly UserMessage[] {
return this.state['next-turn']
}
/** Input awaiting the next step boundary. */
get nextStep(): readonly UserMessage[] {
return this.state['next-step']
}
/** Whether either pending-message list contains work. */
get hasPending(): boolean {
return this.nextTurn.length > 0 || this.nextStep.length > 0
}
/** Durably cancel all pending input, clearing next-step before next-turn. */
clear(): void {
this.splice('next-step', 0, this.nextStep.length, [])
this.splice('next-turn', 0, this.nextTurn.length, [])
}
/**
* Remove and return the complete batch proposed for one step, publishing
* each claimed message. The durable splices are pure deletions.
* @param target - whether this boundary also consumes one queued turn.
* @param turn - turn that will own the claimed batch.
* @returns next-step input followed by the queued turn, when requested.
* @internal - the agent loop's step-boundary operation, not a plugin seam.
*/
claim(target: InboxTarget, turn: number): UserMessage[] {
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
if (target === 'next-turn') {
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
}
for (const message of claimed) this.notifications.claimed(message, turn)
return claimed
}
/**
* Append one message to a pending list and durably record the insertion.
* @param target - pending list to extend.
* @param message - message to append.
* @throws if the message identity is already pending.
*/
append(target: InboxTarget, message: UserMessage): void {
this.splice(target, this.state[target].length, 0, [message])
}
/**
* Prepend one message to a pending list and durably record the insertion.
* @param target - pending list to extend.
* @param message - message to prepend.
* @throws if the message identity is already pending.
*/
prepend(target: InboxTarget, message: UserMessage): void {
this.splice(target, 0, 0, [message])
}
/**
* Replace one pending message in place, possibly changing its identity. A
* successful replacement publishes the old message as discarded and the new
* message as inserted.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
* @throws if the replacement duplicates another pending message identity.
*/
replace(messageId: MessageId, newMessage: UserMessage): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message and durably record its cancellation.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(messageId: MessageId): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
/**
* Apply standard splice semantics and durably record the normalized result.
* The durable event commits before the live projection mutates, so synchronous
* `session/event` observers see the pre-splice lists and can reconstruct the
* removed messages from the normalized coordinates.
* @param target - pending list to mutate.
* @param start - splice position.
* @param deleteCount - maximum number of messages to remove.
* @param inserted - messages to insert at the resolved position.
* @returns messages removed by the splice.
*/
splice(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
): UserMessage[] {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
for (const target of ['next-turn', 'next-step'] as const) {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index >= 0) return { target, index }
}
return undefined
}
/** Commit one normalized mutation and publish its live notifications. */
private mutate(
target: InboxTarget,
start: number,
deleteCount: number,
inserted: UserMessage[],
discardRemoved: boolean,
): UserMessage[] {
const inbox = this.state[target]
const truncatedStart = Math.trunc(start)
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart
const actualStart = offset < 0
? Math.max(inbox.length + offset, 0)
: Math.min(offset, inbox.length)
const truncatedDeleteCount = Math.trunc(deleteCount)
const actualDeleteCount = Math.min(
Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0),
inbox.length - actualStart,
)
if (actualDeleteCount === 0 && inserted.length === 0) return []
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined
const splice = {
target,
start: actualStart,
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
inserted,
...(outcome === undefined ? {} : { outcome }),
}
this.validate(splice)
const event = this.session.append('agent/inbox/spliced', splice)
const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
if (discardRemoved) {
for (const message of removed) this.notifications.discarded(message)
}
for (const message of event.data.inserted) this.notifications.inserted(message)
return removed
}
/** Apply one normalized durable splice to the projection. */
private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] {
this.validate(splice)
const inbox = this.state[splice.target]
return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
}
/** Validate one normalized splice against the current projection. */
private validate(splice: SessionEventMap['agent/inbox/spliced']): void {
const inbox = this.state[splice.target]
const removedCount = splice.removedCount ?? 0
if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
|| !Number.isSafeInteger(removedCount) || removedCount < 0
|| splice.start + removedCount > inbox.length) {
throw new Error('invalid inbox splice')
}
const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
const ids = new Set<string>()
for (const message of splice.target === 'next-turn'
? [...candidate, ...this.nextStep]
: [...this.nextTurn, ...candidate]) {
if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
ids.add(message.id)
}
}
}

View File

@@ -15,7 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export * from './brand.ts'
export * from './inbox.ts'
export * from './llm-target.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
@@ -187,8 +187,8 @@ export interface AgentFactory {
*/
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* both `ctx.sessionPersistence.load` and the optional unpublished setup
* Prepare a persisted session and resume an agent on it. Async because it awaits
* both `ctx.sessionPersistence.prepare` and the optional unpublished setup
* transaction; must be called after that service exists (consumers inject
* `sessionPersistence`). Publication follows the same setup-commit and
* ordered boundary as {@link createAgent}.

View File

@@ -21,27 +21,6 @@ const install: InvariantInstaller = (ctx, fail) => {
}
lastStatus.set(agent, status)
}, { global: true })
// Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped
// (discard) only after it entered (enqueue), so the live outstanding count
// per agent can never go negative. Injection bypasses the FIFOs entirely and
// never appears on these events.
const outstanding = new WeakMap<Agent, number>()
ctx.on('agent/inbox/enqueue', (agent) => {
outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1)
}, { global: true })
ctx.on('agent/inbox/dequeue', (agent) => {
const count = outstanding.get(agent) ?? 0
if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue')
outstanding.set(agent, count - 1)
}, { global: true })
ctx.on('agent/inbox/discard', (agent, items) => {
const count = outstanding.get(agent) ?? 0
if (items.length > count) {
fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`)
}
outstanding.set(agent, count - items.length)
}, { global: true })
}
/**

View File

@@ -7,9 +7,10 @@
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { InboxItemId } from './brand.ts'
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
import type { Inbox, InboxTarget } from './inbox.ts'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
@@ -28,131 +29,61 @@ export interface AgentOptions {
maxTokens?: number
}
/**
* Which inbox queue a {@link Agent.send} item joins:
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
* - `next-step` — during prompt admission or an open turn, the item stages for
* the next safe step boundary; otherwise it is promoted per its `wakeup`
* flag.
*/
export type SendTarget = 'next-turn' | 'next-step'
/** Resolved inbox placement reported when an accepted message is enqueued. */
export type InboxPlacement = 'queued' | 'steering'
/** One independently addressable accepted occurrence in an agent inbox. */
export interface InboxItem {
/** Agent-loop-minted occurrence identity. */
readonly id: InboxItemId
/** Identified message delivered by the caller. */
readonly message: UserMessage
/** Acceptance-time FIFO classification. */
readonly placement: InboxPlacement
}
/** A user-requested mutation of one still-pending queued occurrence. */
export type InboxAction =
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
| { readonly kind: 'remove' }
| { readonly kind: 'steer' }
/** Result of applying an inbox action at the synchronous ownership boundary. */
export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable'
/** Final admission outcome for one call to {@link Agent.steer}. */
export type SteeringOutcome =
| { readonly status: 'admitted'; readonly turn: number; readonly step: number }
| { readonly status: 'rejected' }
/**
* Message-owned steering admission receipt. The outcome promise always
* resolves: synchronous input validation still throws from {@link Agent.steer},
* while lifecycle policy reports non-admission as `rejected`.
*/
export interface SteeringReceipt {
readonly outcome: Promise<SteeringOutcome>
}
/**
* Options for the unified {@link Agent.send} primitive over the
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
* {@link Agent.inject} (`next-step`/no-wakeup).
*
* The object is complete so routing policy is explicit.
*/
export interface SendOptions {
/** Queue the item joins. */
target: SendTarget
/**
* Whether this item makes the model run: wake a parked driver (`next-turn`)
* or force a continuation step (`next-step` while running). A `false`
* `next-turn` item queues without waking; a `false`
* `next-step` item attaches durable context without forcing another step
* (the injection preset).
*/
wakeup: boolean
}
/** Options for {@link Agent.cancel}. */
export interface CancelOptions {
/**
* Preserve queued and steering inbox items instead of discarding them. The
* active turn is still aborted, but un-started and pending work survives for a
* later turn and no `agent/inbox/discard` fires.
* later turn and no canceled inbox splice is logged.
*/
keepInbox?: boolean
keepInbox?: boolean | undefined
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (the driver is draining
* work and may be closing or checkpointing a turn). Disposal removes the
* agent from its registry; it is not a third observable status.
* `idle` means no driver is active; `running` begins when waking input starts
* cancellable pre-step processing and lasts while the driver drains,
* closes, or checkpoints turns. Disposal removes the agent from its registry;
* it is not a third observable status.
*/
export type AgentStatus = 'idle' | 'running'
/**
* Prompt interception result. `allow.content` replaces the prompt, while
* `additionalContexts` appends model-facing context before the turn starts.
* An `allow` returned by a listener is authoritative: a listener wrapping
* `next()` preserves both fields unless it intentionally replaces them.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] }
| { kind: 'block'; reason: string }
/** Coordinates and cancellation for a proposed step. */
export interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
/** Model-request failure with an optional machine-routable provider code. */
export type RequestError = Error & { code?: string }
/** Whether and with which messages the loop enters a proposed step. */
export type PreStepDecision =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] }
/** One failed model-request attempt presented to recovery listeners. */
export interface RequestFailureContext {
/** Turn containing the failed request. */
readonly turn: number
/** Step containing the failed request attempt. */
readonly step: number
/** Provider selected for the failed request. */
readonly provider: string
/** Serializable facts normalized at the final adapter boundary. */
readonly failure: LlmFailure
/** Policy of the adapter registration that served the failed request. */
readonly retryPolicy: ResolvedRetryPolicy | undefined
}
/** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined
/**
* Why a turn ended, reported live on `agent/settled` right after the turn's
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
* model-request recovery runs earlier through `agent/request-error`.
*/
export type SettleReason =
| { kind: 'completed' }
| { kind: 'aborted' }
| { kind: 'error'; error: unknown; failure?: LlmFailure }
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
/** Stable runtime cause accepted by {@link Agent.cancel}. */
export type AgentCancelCause =
| { readonly kind: 'user' }
| { readonly kind: 'parent' }
/** Runtime reason carried by the signal that controls one live turn. */
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
/**
* Public live-agent handle with aliases over the unified delivery primitive.
* @typert object
*/
/** Public live-agent handle. */
export interface Agent {
/** The single identity shared with {@link session}. */
readonly id: SessionId
@@ -160,109 +91,72 @@ export interface Agent {
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
/**
* Whether a `next-step` send currently stages for prompt admission or the
* open turn. Unlike {@link status}, this excludes admission exit and turn
* settlement, when a waking `next-step` send becomes a queued follow-up.
*/
readonly acceptsNextStep: boolean
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
readonly ctx: Context
/**
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
* It routes the caller's typed content and source as follows:
*
* - `next-turn` queues an item that becomes the sole ordinary message of its
* own FIFO-ordered turn; `wakeup:true` wakes a
* parked driver, while `wakeup:false` queues without waking.
* - `next-step` with `wakeup:true` stages steering during prompt admission
* or an open turn; outside that window it falls back to a woken
* `next-turn`.
* - `next-step` with `wakeup:false` injects durable model-facing context
* without running the model: admission or an open turn stages it for the
* next safe log position, while an injection outside that window appends
* immediately without opening a turn. If admission closes without a turn,
* a context-only boundary appends immediately; context staged beside
* steering remains pending with it.
* The agent publishes or queues the identified frozen message as-is.
* @param message - identified model-facing content and its producer provenance.
* @param options - target queue and wakeup decision.
*/
send(message: UserMessage, options: SendOptions): void
/**
* Reserve admission of the next ordinary turn while this agent is idle, so an
* operation can mutate durable history before any queued prompt derives a
* request from it. Already-accepted waking work has right of way, including a
* send whose wake is still a pending microtask. Later sends keep their
* ordinary placement, FIFO order, and `wakeup` facts, and
* {@link acceptsNextStep} stays `false`, so a waking `next-step` send becomes
* a queued follow-up rather than steering; cancellation and disposal may
* still discard them. {@link inject} is not withheld. {@link whenIdle} treats
* a live reservation as activity, while lifecycle teardown does not await it.
* @returns the idempotent release, or `undefined` when the agent is running, already reserved, or already committed to waking work.
*/
reserveTurnAdmission(): (() => void) | undefined
/**
* Mutate one still-pending queued occurrence synchronously. Editing preserves
* the message identity and queue position; removal publishes its terminal
* discard. Steer strictly transfers the message into the current next-step
* window, or returns `steer-unavailable` without changing the queued
* occurrence. Steering occurrences and driver-claimed items return
* `not-found`.
* @param id - independently addressable queued occurrence.
* @param action - edit, remove, or strict steer operation.
* @returns the applied outcome or the reason no mutation occurred.
*/
updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Idle
* cancellation is a no-op and does not arm later work.
* @param cause - the stable caller intent carried by the current turn signal.
* turn or between-turn task. The first cause wins for that activity. With no
* active activity, cancellation is a no-op and does not arm later work.
* @param cause - the stable caller intent carried by the active operation signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
cancel(cause: AgentCancelCause, options?: CancelOptions): void
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
/**
* Resolve after the current whole-agent activity reaches quiescence. This
* follows replacement work started before the observed driver retires,
* but does not identify the settlement of any particular message.
* @returns fulfillment after no active driver or maintenance task remains.
*/
whenIdle(): Promise<void>
/**
* Queue an ordinary follow-up turn and wake the driver — the
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* Run one non-turn maintenance task from the true idle phase. The task starts
* synchronously after claiming that phase; later waking input remains in the
* inbox until the task settles, while public status stays `idle`.
* `whenIdle()` follows both the task and any waking work released behind it.
* @param task - operation whose fulfillment or rejection is preserved, with a signal aborted by {@link cancel}.
* @throws synchronously when turn-driving or another maintenance task already owns the agent.
* @returns the task promise.
*/
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
/**
* Route identified input to an inbox boundary and optionally wake the driver.
* Waking input submitted after active cancellation is queued for the next turn.
* @param message - identified content and its producer provenance.
* @param target - the preferred next-turn or next-step inbox boundary.
* @param wakeup - whether delivery may wake the driver.
*/
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
/**
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
* sole ordinary message of its own turn.
* @param message - identified prompt content and its producer provenance.
*/
followup(message: UserMessage): void
/**
* Submit steering with a message-owned admission receipt — the
* `next-step`/wakeup preset of {@link send}. During prompt admission or an
* open turn, the message waits in the steering FIFO until a committed step
* snapshots it; outside that window it enters the ordinary queued FIFO. The
* receipt resolves `admitted` only after the message joins that step's
* immutable request history, or `rejected` when terminal policy,
* cancellation, or disposal discards it first. A non-terminal turn close may
* leave it staged for a later admitted prompt without settling the receipt.
* Submit steering for the nearest step. An idle driver starts a turn;
* a running driver consumes it at its next step boundary.
* A rejected step leaves steering parked in the inbox until the next
* wake; cancellation or disposal may discard pending steering.
* @param message - identified steering content and its producer provenance.
* @returns the receipt for this exact message's eventual admission outcome.
*/
steer(message: UserMessage): SteeringReceipt
steer(message: UserMessage): void
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
* stages it at the next safe log position; outside that window it appends
* immediately without opening a turn. If admission closes without a turn,
* a context-only boundary appends immediately; context staged beside
* steering remains pending with it.
* Queue model-facing context for the next pre-step without waking the
* driver. A running driver claims it at the nearest later step boundary;
* idle drivers leave it pending until follow-up or steering
* wakes them. It may miss a request whose pre-step already claimed its
* batch. Cancellation or disposal may discard pending context.
* @param message - identified injected context and its producer provenance.
*/
inject(message: UserMessage): void
@@ -292,8 +186,9 @@ declare module 'cordis' {
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`). `send()` does not enter
* `running` synchronously; drive lifecycle from this event.
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -301,57 +196,31 @@ declare module 'cordis' {
*/
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
/**
* An item entered the queued or steering inbox. `placement` is the
* acceptance-time routing result; listeners must not reconstruct it from
* later agent or session state.
* @param agent - the owning agent.
* @param item - accepted occurrence, message, and resolved placement.
* One message entered the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
/**
* A still-pending queued item changed content. The item id, placement, and
* position remain stable while the event carries the replacement message.
* @param agent - the owning agent.
* @param item - the complete post-update occurrence.
* One message left the inbox inside its open turn. If the proposed step
* is rejected, the claimed message ends here: it is neither discarded nor
* re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/update'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void
/**
* The driver claimed one item out of the inbox: a queued item at a turn
* boundary, or steering drained between steps. Fires after the item leaves
* its FIFO and before it becomes a durable message.
* @param agent - the agent whose inbox item was claimed.
* @param item - the exact claimed occurrence.
* One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed.
* @param event - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
/**
* Pending inbox items were dropped without delivering them, so every
* enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
* emits this after `agent/cancel-requested` when applicable and before
* aborting the active work. Fires once per drop with every dropped item.
* @param agent - the agent whose inbox items were dropped.
* @param items - the discarded occurrences in FIFO order (queued then steering); never empty.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, items: InboxItem[]): void
/**
* Effective broad cancellation was requested, before queued/outbox work
* is cleared or the active turn is aborted. This observe-only notification
* cannot veto cancellation; listener failures are contained.
* @param agent - the agent whose current work is being cancelled.
* @param cause - the explicit typed cancellation cause.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
// ---- session lifecycle (emit) ----
/**
* The session lifecycle began, once before the first turn. Use
@@ -367,30 +236,15 @@ declare module 'cordis' {
// ---- the machine's extension seams ----
/**
* Allow, rewrite, or block one claimed prompt before it becomes a user
* message or opens a turn. Call `next()` for the unchanged default. The
* signal controls only this admission attempt; listeners may cooperate with
* it but must not retain it for a later attempt or turn.
* @param agent - the agent whose turn claimed the message.
* @param message - the frozen claimed message, including identity and source.
* @param signal - the current turn's explicit abort signal.
* Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages.
* @param agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Awaited serial checkpoint before EVERY request of a turn is built (the
* first as well as each post-tools continuation). The single "between
* steps" extension point: inject context, steer, or edit the session log
* here — the request's history derives from the log right after this settles.
* @param agent - the agent about to send a request.
* @param turn - the open turn number.
* @param step - the step number about to open.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
/**
* Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged
@@ -405,24 +259,17 @@ declare module 'cordis' {
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Handle a model-request failure after its failed step has closed but
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
* without calling `next()` when it owns the error, or calls `next()` to
* delegate. The default `undefined` leaves the failure terminal.
* Handle one failed model-request attempt before the loop retries or closes
* its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param turn - the open turn number.
* @param step - the failed step number.
* @param error - the original model-request failure.
* @param failure - serializable facts normalized at the final adapter boundary.
* @param priorFailures - immutable failures that already authorized another
* retry turn in this consecutive sequence.
* @param retryPolicy - immutable policy of the adapter registration that served
* the failed request, or `undefined` if no final adapter served it.
* @param context - request coordinates, provider, normalized failure, and serving policy.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
/**
* The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a
@@ -430,7 +277,10 @@ declare module 'cordis' {
* re-reads its inbox: fresh steering runs another step, none closes the
* turn. Data decides, so listener order cannot change the outcome. The
* inverse control (stop a tool loop early) is data too: a tool result
* carrying `concludesTurn` ends the turn at its step.
* carrying `concludesTurn` ends the turn at its step. The conclusion
* never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close.
* @param signal - the current turn's explicit abort signal.
@@ -438,25 +288,10 @@ declare module 'cordis' {
* @mode serial
*/
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
/**
* One drain chain reached its terminal turn: that turn's `turn/end` is
* already committed. Automatically recovered failed turns do not emit this
* notification, and neither does a run that aborts or fails before its
* `turn/start` commits — there is no durable turn to settle against.
* `reason` says why; model-request recovery is exhausted when an error
* reaches it.
* @param agent - the agent whose turn closed.
* @param turn - the terminal turn number.
* @param reason - why the terminal turn ended, with live error facts when it failed.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void
// ---- error notifications (emit) ----
/**
* A step or turn errored. The machine reports a failure here (plus the
* logger) even when the error has no in-turn position for a durable record.
* A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
@@ -467,3 +302,20 @@ declare module 'cordis' {
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One normalized mutation of an agent's durable pending-message lists.
* Live dispatch precedes projection mutation, so synchronous observers may
* read the pre-splice inbox to recover the removed messages.
*/
'agent/inbox/spliced': {
target: InboxTarget
start: number
removedCount?: number
inserted: UserMessage[]
outcome?: 'canceled'
}
}
}

View File

@@ -1,9 +1,10 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
agentEvents,
Inbox,
} from '@deepseek-ai/dsh-agent'
import type {
@@ -16,25 +17,129 @@ import type {
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
const id = SessionId(rawId)
const session = Session.create(id)
const agent: Agent = {
id,
options: {},
session: Session.create(id),
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle() { return Promise.resolve() },
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
return Object.assign(agent, overrides)
}
describe('Inbox', () => {
it('rejects an invalid durable splice during reconstruction', () => {
const session = Session.create(SessionId('invalid-inbox-replay'))
session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 1,
inserted: [],
})
expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }))
.toThrow('invalid persisted inbox splice at session seq 0')
})
it('replaces a pending message by identity across both lists', () => {
const session = Session.create(SessionId('replace-inbox'))
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
claimed: () => {},
inserted: message => void inserted.push(message),
discarded: message => void discarded.push(message),
})
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const nextStep = createUserMessage({
content: [{ type: 'text', text: 'step' }],
source: { kind: 'user' },
})
const replacement = createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'user' },
})
const editedStep = freezeMessage({
...nextStep,
content: [{ type: 'text', text: 'edited step' }],
})
inbox.append('next-turn', original)
inbox.append('next-step', nextStep)
expect(inbox.replace(createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(inbox.replace(original.id, replacement)).toBe(true)
expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
expect(inbox.nextTurn).toEqual([replacement])
expect(inbox.nextStep).toEqual([editedStep])
expect(discarded).toEqual([original, nextStep])
expect(inserted).toEqual([original, nextStep, replacement, editedStep])
expect(() => { inbox.replace(editedStep.id, replacement) })
.toThrow(`message "${replacement.id}" is already pending`)
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
const session = Session.create(SessionId('splice-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
})
inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(inbox.nextTurn).toEqual([first, second])
expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
expect(inbox.remove(second.id)).toBe(false)
expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})
it('clears both pending lists as durable cancellations', () => {
const session = Session.create(SessionId('clear-inbox'))
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
claimed: () => {},
inserted: () => {},
discarded: message => void discarded.push(message),
})
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
inbox.append('next-turn', nextTurn)
inbox.append('next-step', nextStep)
const beforeClear = session.events.length
inbox.clear()
expect(inbox.hasPending).toBe(false)
expect(discarded).toEqual([nextStep, nextTurn])
expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
? event.data
: event.type)).toEqual([
{ target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
])
inbox.clear()
expect(session.events).toHaveLength(beforeClear + 2)
})
})
describe('AgentRegistry', () => {
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context()
@@ -185,12 +290,26 @@ describe('agentEvents()', () => {
'agent event "agent/status" listener rejected: Error: async listener',
])
})
it('dispatches serial listeners with the fused agent subject', async () => {
const ctx = new Context()
const agent = stubAgent('serial-event')
const signal = new AbortController().signal
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => {
await Promise.resolve()
heard.push({ agent: subject, turn, signal: receivedSignal })
})
await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal)
expect(heard).toEqual([{ agent, turn: 3, signal }])
})
})
describe('explicit cancellation contract', () => {
it('exposes the closed typed cancellation cause at the Agent seam', () => {
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
})
})

View File

@@ -1,7 +1,6 @@
import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { InboxItemId, type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -44,51 +43,3 @@ describe('agent status invariants', () => {
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
})
})
describe('agent inbox invariants', () => {
let nextItem = 0
const info = (placement: InboxPlacement = 'queued'): InboxItem => ({
id: InboxItemId(`i-${nextItem++}`),
message: freezeMessage({
id: MessageId('m'),
role: 'user' as const,
content: [],
source: { kind: 'user' as const },
}),
placement,
})
it('accepts a dequeue and a discard covered by prior enqueues', async () => {
const ctx = await setup()
const agent = mockAgent('i1')
const at = scopeTarget(agent, agent)
expect(() => {
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
ctx.emit(at, 'agent/inbox/enqueue', agent, info('steering'))
ctx.emit(at, 'agent/inbox/dequeue', agent, info())
ctx.emit(at, 'agent/inbox/discard', agent, [info()])
}).not.toThrow()
})
it('rejects a dequeue with no outstanding item', async () => {
const ctx = await setup()
const agent = mockAgent('i2')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) })
.toThrow(/without a matching prior enqueue/)
})
it('rejects a discard larger than the outstanding count', async () => {
const ctx = await setup()
const agent = mockAgent('i3')
const at = scopeTarget(agent, agent)
ctx.emit(at, 'agent/inbox/enqueue', agent, info())
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) })
.toThrow(/dropped 2 items but only 1 were outstanding/)
})
it('accepts an empty discard against a fresh agent', async () => {
const ctx = await setup()
const agent = mockAgent('i4')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow()
})
})

View File

@@ -14,9 +14,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../core/scope"
},

View File

@@ -1,6 +1,6 @@
import { defineConfig } from 'tsdown'
/** Build the package root and optional invariant companion as independent bundles. */
/** Build the package root and companions as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],

View File

@@ -8,21 +8,17 @@
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
'agent/cancel-requested': args => args[0],
'agent/created': args => args[0],
'agent/disposed': args => args[0],
'agent/error': args => args[0],
'agent/inbox/dequeue': args => args[0],
'agent/inbox/discard': args => args[0],
'agent/inbox/enqueue': args => args[0],
'agent/inbox/update': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/inbox/claimed': args => args[0],
'agent/inbox/discarded': args => args[0],
'agent/inbox/inserted': args => args[0],
'agent/pre-step': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-start': args => args[0],
'agent/settled': args => args[0],
'agent/status': args => args[0],
'agent/step': args => args[0],
'agent/turn-stopping': args => args[0],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0],

View File

@@ -2,7 +2,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Events } from 'cordis'
import { InboxItemId, type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -44,33 +44,29 @@ describe('scoped-dispatch invariants', () => {
content: [],
source: { kind: 'user' },
})
const item = { id: InboxItemId('i'), message, placement: 'queued' as const }
const agentRows = {
'agent/created': [agent],
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/inbox/enqueue': [agent, item],
'agent/inbox/update': [agent, item],
'agent/inbox/dequeue': [agent, item],
'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/inbox/inserted': [agent, { message }],
'agent/inbox/claimed': [agent, { message, turn: 1 }],
'agent/inbox/discarded': [agent, { message }],
'agent/session-start': [agent, 'startup'],
'agent/step': [agent, 1, 1, signal],
'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })],
'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })],
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
'agent/request-error': [
agent,
1,
1,
new Error('request'),
{ message: 'request', code: 'UNKNOWN' },
[],
undefined,
{
turn: 1,
step: 1,
provider: 'p',
failure: { message: 'request', code: 'UNKNOWN' },
retryPolicy: undefined,
},
signal,
() => Promise.resolve(undefined),
],
'agent/turn-stopping': [agent, 1, signal],
'agent/settled': [agent, 1, { kind: 'completed' }],
'agent/error': [agent, 1, 0, new Error('x')],
} satisfies { [K in AgentEventName]: EventArgs<K> }
const rows: Array<[string, unknown[]]> = [

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: ae4f0ccaa3ac3b3742a0856de4445cbd8412ea24
README.zh.md: ae4adb6afa48e67a3274231d6c9bf303a0086470
README.md: e2c014a1448125b23475d6cdf52c02f10fc54794
README.zh.md: a4aeead796961bd66a6c7ca1f9a66dffb60eba5d

View File

@@ -12,9 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `origin`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -43,7 +42,7 @@ Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.c
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`). `origin: 'subagent'` is a coarse product classification, not a continuation capability. Construction validates the durable record and requires its id to match `session.id`.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
### Lossless JSON utilities
@@ -53,36 +52,29 @@ Session-event import separates ownership from message validation. `snapshotSessi
### Chunk-row storage codec (`chunk-rows.ts`)
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only.
The shared [storage codec](src/chunk-rows.ts) losslessly converts event sequences to compact rows and back. It preserves unrecognized events verbatim and rejects malformed encoded rows; persistence backends decide whether to enable packed writes.
### Surface types
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent``{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
- `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`.
This package owns ordered surface projection, replacement validation, replay, and the type guards that distinguish append-origin from replacement events. The [surface type catalog](../../../docs/core-data-structures/session.md#surface-types) owns the exact shapes and field semantics. A human transcript must project append-origin events rather than `session.surface`, because landed replacements shadow history the reader already saw; model-facing consumers continue to read `session.surface`.
### Request-header reconstruction (`request-header.ts`)
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`request/context` records registration-bound metadata for the route a request resolved to, appended inside its step beside `request/header` and only when the provider, model, or capacity differs from the previous record. `session.requestContext()` folds the latest one incrementally, mirroring `requestHeader()`. Capacity stays OUT of `EpochHeader` on purpose: it is adapter metadata describing a route, not an input the request was built from, so it must not enter request reconstruction or header equality — a capacity change is not a header `change`. A route whose adapter advertises no capacity is still recorded with `contextWindow` absent, clearing any older known capacity.
A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message` and `tool/result` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision.
`tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message.
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following entered `user/message` batch records its input, while `llm/retry` records request recovery.
An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state.
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
Every `SessionEvent` carries two optional top-level fields (structural metadata):
@@ -91,12 +83,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`)
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, and assistant messages require provider/model provenance. Persistence owns read compatibility before constructing this current-format seed. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
## Model Experience
@@ -105,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives the complete messages from `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -12,9 +12,8 @@
### 公共 API
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt``seedLength``origin``delegationDepth`
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt``seedLength``delegationDepth`
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -43,7 +42,7 @@
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
- `session.seq``session.id`:当前序号和只读类型化身份。
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version``id``createdAt`,以及可选的 `cwd``parentSession``seedLength``origin``delegationDepth`)。`origin: 'subagent'` 是粗粒度产品分类,不代表具备继续执行能力。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version``id``createdAt`,以及可选的 `cwd``parentSession``seedLength``delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
### 无损 JSON 工具
@@ -53,36 +52,29 @@
### 分片行存储编解码器(`chunk-rows.ts`
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks``reasoning-chunks``tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0``time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq``time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture测试前置数据读取器`dsh-llm-replay``dsh-acp-snapshot`)共享同一编解码器;后端默认启用的 `packChunks` 配置只控制写入
共享的[存储编解码器](src/chunk-rows.ts)在事件序列与紧凑行之间无损转换。它会逐字保留无法识别的事件,并拒绝形态错误的编码行;是否启用打包写入由持久化后端决定
### Surface 类型
- `SurfaceOp`:事件进入有序 surface 的方式,即 `'append'`(正常尾部追加)或 `{ op: 'replace', start, end }`(替换从 `start``end` 的条目,含两端;二者都必须是有效的 surface 序号;`start === end` 时替换一个条目)。压缩用它遮蔽旧事件而不删除它们
- `SurfaceIntent``{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`,对于可进入 surface 的类型,这是调用 `session.append()` 时必需的第三个参数。
- `SessionSurface`:实时只读 `nodes``replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍是 `Session` 的私有实现。
- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。
- `isSurfaceEvent(event)``isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。
- `isAppendSurfaceEvent(event)``isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是供人阅读的 transcript文本记录的持久来源而该 transcript 并非模型可见的 surface已落地的替换会遮蔽它所概括的范围因此从 `session.surface` 投影 transcript 会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`
此包拥有有序 surface 投影、替换校验、回放,以及区分追加来源事件与替换事件的类型守卫。[surface 类型目录](../../../docs/core-data-structures/session.md#surface-types)拥有精确形状与字段语义。面向人的 transcript文本记录必须投影追加来源事件而不是 `session.surface`,因为已落地的替换会遮蔽读者已经看到的历史;面向模型的消费方继续读取 `session.surface`
### 请求头重建(`request-header.ts`
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial``resume``change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort``maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
`request/context` 记录请求所解析到的路由的、绑定注册项的元数据,在其所属步骤内与 `request/header` 一同追加,且仅在提供方、模型或容量与上一条记录不同时追加`session.requestContext()` 以增量方式归并最新一条,与 `requestHeader()` 保持一致。容量刻意不进入 `EpochHeader`:它是描述路由的适配器元数据,不是构建该请求所依据的输入,因此绝不可进入请求重建或请求头相等性判断:容量变化不构成请求头 `change`。适配器不公布容量的路由仍会被记录,但 `contextWindow` 字段缺失,从而清除较早的已知容量
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message``tool/result` 和 steering中途引导对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start``turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实`assistant/message``tool/result` 也会存储完整的消息值。轮次执行仍由 `turn/start``turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它
`tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。
### 会话事件词汇(`types.ts`
生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实
生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存。
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook钩子桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。
此包还定义 `TurnTriggerMap``TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;`kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤
此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展`kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后进入步骤的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复
被中断的实时轮次以粗粒度的 `{ kind: 'aborted' }` 结果结束。调用方身份属于 Agent 的运行时取消信号,不属于持久 transcript资源释放仍是独立的 `{ kind: 'disposed' }` 终态
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript文本记录中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`
每个 `SessionEvent` 都有两个可选顶层字段(结构元数据):
@@ -91,12 +83,12 @@
### 元数据类型(`types.ts`
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
### 扩展点
- 持久化插件:订阅 `session/event`(延后写入),并在 `session/flush`(受等待)及 fiber dispose资源释放时排空。持久后端读取日志并重新加载到实时会话这类后端会把元数据 seam`SessionHeader``session.header`)与日志一同存储。
- 回放fork`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface请求头必须包含提供方模型assistant 消息必须包含提供方/模型溯源信息,而粗粒度中止结果必须只含 `{ kind: 'aborted' }`(带旧版原因的记录会被拒绝)`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。
- 回放fork`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface请求头必须包含提供方模型assistant 消息必须包含提供方/模型溯源信息。持久化层在构造该当前格式 seed 前负责读取兼容性处理`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。
- 压缩:`dsh-compact-basic` 为摘要检查点追加一个替换用 `user/message`,而 `dsh-compact-tool-result-prune` 追加仅修改内容的 `tool/result` 替换。工具配对边界策略及其缓存归 [`dsh-compact` seam](../../compact/compact/README.md) 所有;此包拥有有序 surface 成员关系、替换校验与 `replaceGeneration`
## 模型体验
@@ -105,7 +97,7 @@
#### 模型看到的内容
模型会原样接收 `user/message``assistant/message``tool/result``steering/message` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
模型会原样接收 `user/message``assistant/message``tool/result` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
#### Token 影响

View File

@@ -13,13 +13,15 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
export { SessionPreparation } from './preparation.ts'
export type { SessionPreparationOptions } from './preparation.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
@@ -31,22 +33,22 @@ export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurface
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, ignoring other triggers and
* between-turn events.
* Find the latest closed turn that entered at least one model step, ignoring
* balanced no-step turns produced by rejection, empty input, or cancellation.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
const messageTurns = new Set<number>()
const steppedTurns = new Set<number>()
let latest: SessionEvent<'turn/end'> | undefined
for (const event of events) {
if (event.type === 'turn/start') {
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
if (event.type === 'step/start') {
steppedTurns.add(event.data.turn)
continue
}
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
}
return latest
}
@@ -143,6 +145,17 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
return deepFreeze(record as unknown as SessionHeader)
}
/** Validate and freeze one exclusively owned persistence header in place. */
function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
const prototype = Reflect.getPrototypeOf(input)
if (prototype !== Object.prototype && prototype !== null) {
throw new Error('session header is not a plain JSON record')
}
}
return validateSessionHeader(id, input)
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
@@ -172,7 +185,6 @@ export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(event.data.message)
break
default:
@@ -191,24 +203,58 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
return adoptSessionEvent(structuredClone(event))
}
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
function freezeRestoredObject<T extends object>(value: T): T {
const pending: object[] = [value]
while (pending.length > 0) {
// The non-empty check proves an object remains to visit.
// oxlint-disable-next-line typescript/no-non-null-assertion
const current = pending.pop()!
Object.freeze(current)
for (const key in current) {
const child = (current as Record<string, unknown>)[key]
if (child !== null && typeof child === 'object') pending.push(child)
}
}
return value
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
if (event['type'] === 'request/header-delta') {
throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
}
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
if (Object.keys(event).some(key => !allowed.has(key))
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|| !Object.hasOwn(event, 'data')) {
for (const key in event) {
switch (key) {
case 'type':
case 'seq':
case 'time':
case 'data':
case 'surfaceOp':
case 'sourceEventSeqs':
break
default:
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
}
const type = event['type']
const seq = event['seq']
const time = event['time']
if (typeof type !== 'string'
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|| event['data'] === undefined) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
assertCurrentLlmShape(event, index)
assertCurrentTurnEndShape(event, index)
switch (type) {
case 'request/header':
case 'user/message':
case 'assistant/message':
case 'tool/result':
assertCurrentLlmShape(event, index)
break
}
}
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
@@ -234,10 +280,12 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
}
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
&& type !== 'tool/result') return
assertMessageEventShape(event, `seed ${type} at index ${index}`)
}
const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens'])
/** Validate adapter-default provenance imported from a durable request header. */
function assertAdapterDefaults(
value: unknown,
@@ -249,8 +297,7 @@ function assertAdapterDefaults(
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
}
const defaults = value as Record<string, unknown>
const allowed = new Set(['reasoningEffort', 'maxTokens'])
if (Object.keys(defaults).some(key => !allowed.has(key))
if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
|| Object.values(defaults).some(marker => marker !== true)
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
@@ -262,7 +309,7 @@ function assertAdapterDefaults(
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
&& type !== 'tool/result') return
const data = event['data']
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
@@ -312,22 +359,6 @@ function assertMessageEventShape(event: Record<string, unknown>, subject: string
}
}
/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
if (event['type'] !== 'turn/end') return
const data = event['data']
/* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
if (typeof data !== 'object' || data === null) return
const reason = (data as Record<string, unknown>)['reason']
/* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
const record = reason as Record<string, unknown>
if (record['kind'] === 'aborted'
&& (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
}
}
/** Whether an unknown value carries the current provider/model pair. */
function hasProviderModel(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false
@@ -412,7 +443,7 @@ export class Session {
/**
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* `Session` is created without a store-owned header, a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
@@ -433,9 +464,7 @@ export class Session {
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
* in-process construction fact. An explicitly supplied empty seed has the
* same value as no seed (0); its `session/end-seed` event preserves the
* lifecycle distinction.
* in-process construction fact.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
@@ -462,7 +491,28 @@ export class Session {
return new Session(id, seed, header)
}
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
/**
* Restore a detached session by taking ownership of fresh persistence values.
* Storage shape, event envelopes, sequence continuity, surface transitions,
* and header fields are validated before the graphs are frozen in place.
* @param id - restored session identity.
* @param seed - fresh detached events whose ownership is transferred.
* @param header - fresh detached metadata whose ownership is transferred.
* @returns a restored detached session.
*/
static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session {
return new Session(id, seed, header, 'restore')
}
private constructor(
id: SessionId,
seed?: readonly SessionEvent[],
header?: SessionHeader,
mode: 'snapshot' | 'restore' = 'snapshot',
) {
const restoredHeader = mode === 'restore'
? validateRestoredSessionHeader(id, header)
: undefined
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -474,7 +524,7 @@ export class Session {
for (const [index, source] of seed.entries()) {
// The seed is a persistence/replay boundary: validate and detach the
// complete event in one lossless-JSON pass.
const snapshot = snapshotJsonValue(source)
const snapshot = mode === 'restore' ? source : snapshotJsonValue(source)
if (snapshot === undefined) {
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
}
@@ -491,11 +541,11 @@ export class Session {
} catch (error: unknown) {
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
}
this.log.push(deepFreeze(snapshot))
this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot))
}
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
this.header = restoredHeader ?? snapshotSessionHeader(id, header)
// Appended here so the marker is already in `events` when a backend
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
@@ -637,23 +687,18 @@ export class Session {
return this.headerFold
}
/** Cached fold of the request-context events — see {@link requestContext}. */
/** Cached fold of `request/context` events. */
private contextFold: RequestContext | undefined
/** Log position (events consumed) the context fold has reached. */
private contextFoldSeq = 0
/**
* The route metadata in force after the log's last `request/context` event —
* what the NEXT request deduplicates against — or undefined before any such
* record. Maintained incrementally like {@link requestHeader}, so a per-step
* read costs O(new events).
* @returns the folded context record, or undefined when none exists yet.
* Return the latest resolved route metadata, or `undefined` before the first
* `request/context` event. Each event is folded once.
* @returns the latest immutable route metadata.
*/
requestContext(): RequestContext | undefined {
if (this.contextFoldSeq < this.log.length) {
for (const event of this.log.slice(this.contextFoldSeq)) {
// Frozen for the same reason as the header fold: it is session state
// exposed by reference and every later dedup compares against it.
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
}
this.contextFoldSeq = this.log.length
@@ -728,10 +773,9 @@ export class Session {
// trace/replay data.
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. Steering's `turn` is log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// Ordinary prompts and injected context project in user role: the
// event's model-facing content stays verbatim. Do NOT
// re-add per-type framing (e.g. `<context>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
@@ -740,9 +784,6 @@ export class Session {
case 'user/message': {
return event.data
}
case 'steering/message': {
return event.data.message
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
@@ -810,7 +851,7 @@ export class SessionStore extends Service {
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store attachment ends), do NOT use this
* loop's final events are published before the store attachment ends), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see
* `dsh-agent-loop`'s creation transaction).
@@ -842,16 +883,20 @@ export class SessionStore extends Service {
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would remove the publication hooks
* before the loop's closing `session/flush`, dropping the closing events.
* before the driver's closing events commit, dropping them.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @param options - seed events and/or creation metadata for the header. With
* `seedSource: 'persistence'`, metadata and events must be fresh detached
* graphs whose ownership transfers to this call: they are validated and
* frozen in place through {@link Session.fromRestore}, so the caller must
* retain no mutable aliases.
* @returns the constructed session, NOT yet in the store.
* @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
* non-absolute path.
*/
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
prepare(id?: SessionId, options?: PrepareSessionOptions): Session {
let sessionId: SessionId
if (id === undefined) {
do sessionId = SessionId(`session-${++this.counter}`)
@@ -860,6 +905,9 @@ export class SessionStore extends Service {
sessionId = SessionId(id)
}
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
if (options?.seedSource === 'persistence') {
return Session.fromRestore(sessionId, options.seed, options.meta)
}
const seed = options?.seed
const meta = options?.meta
const header: SessionHeader = {
@@ -996,10 +1044,11 @@ export class SessionStore extends Service {
/**
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
* with the carrier captured at {@link enter}. THE flush entry point: the
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
* injection, teardown drains) must come through here rather than dispatch a
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
* scoped-dispatch invariant can pin it.
* store owns the carrier, so callers (the checkpoint policy's per-request
* barrier, goal-session's idle checkpoint, teardown drains, and consumers
* that flush themselves before reading storage) must come through here
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
* one spelling, and the scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns whether at least one durability listener participated, after every
* listener has settled successfully.

View File

@@ -147,10 +147,9 @@ function validateEvent(
case 'session/end-seed':
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
break
case 'steering/message':
case 'todo/write':
case 'request/context':
case 'request/header': {
case 'request/header':
case 'request/context': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}

View File

@@ -0,0 +1,49 @@
/**
* Ownership of one unpublished Session before registry publication.
* @module @deepseek-ai/dsh-session/preparation
*/
import type { Session } from './index.ts'
/** Options for a preparation whose provider retains unpublished state. */
export interface SessionPreparationOptions {
/** Release provider-owned state when the Session was not published. */
readonly release?: () => void
}
/**
* One exact unpublished Session and the provider state that keeps it usable.
* Disposal is synchronous and idempotent. Providers decide whether release
* returns the Session to a cache or discards it; publication may consume that
* state before disposal, making the callback a no-op.
*/
export class SessionPreparation implements Disposable {
private released = false
/** The exact Session to use for setup and publication. */
readonly session: Session
private constructor(
session: Session,
private readonly options: SessionPreparationOptions,
) {
this.session = session
}
/**
* Wrap an unpublished Session in one preparation lifetime.
* @param session - exact unpublished Session.
* @param options - optional provider release behavior.
* @returns a preparation disposed after publication or rollback.
*/
static create(session: Session, options?: SessionPreparationOptions): SessionPreparation {
return new SessionPreparation(session, options ?? {})
}
/** Release provider state once when this preparation leaves its caller. */
[Symbol.dispose](): void {
if (this.released) return
this.released = true
this.options.release?.()
}
}

View File

@@ -15,13 +15,12 @@ const SURFACE_EVENT_TYPES = new Set<string>([
'user/message',
'assistant/message',
'tool/result',
'steering/message',
])
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
* @returns true for one of the four message-producing event types.
* @returns true for one of the three message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -242,13 +241,14 @@ function assertToolResultRewrite(
event: SessionEvent,
shadowedSeqs: readonly number[],
events: readonly SessionEvent[],
baseSeq: number,
): void {
if (event.type !== 'tool/result') return
if (shadowedSeqs.length !== 1) {
throw new Error('tool/result surface replacement must rewrite exactly one current node')
}
for (const originalSeq of shadowedSeqs) {
const original = events[originalSeq]
const original = events[originalSeq - baseSeq]
if (original?.type !== 'tool/result') {
throw new Error('tool/result surface replacement must target a current tool/result')
}
@@ -276,6 +276,7 @@ function planSurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -288,7 +289,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
assertToolResultRewrite(event, range.shadowedSeqs, events)
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
return {
kind: 'replace',
seq: event.seq,
@@ -304,8 +305,17 @@ function applySurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
return applySurfacePlan(state, plan)
}
/** Commit one previously validated surface transition. */
function applySurfacePlan(
state: SurfaceFoldState,
plan: SurfacePlan | undefined,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq, events)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -331,7 +341,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index, events)
const replacement = applySurfaceEvent(state, event, index, events, 0)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -341,38 +351,63 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
export class SurfaceManager implements SessionSurface {
/** Shared transition state; replacement history is not retained. */
private _state = createFoldState()
/** Last processed seq; -1 folds a seeded log on first access. */
private _lastProcessedSeq = -1
/** Last processed absolute seq. */
private _lastProcessedSeq: number
/** Candidate already validated by `validateNext`, pending exact log admission. */
private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined
constructor(private log: readonly SessionEvent[]) {}
/**
* @param log - Contiguous complete log or loaded event window.
* @param baseSeq - Absolute sequence of the window's first event.
*/
constructor(
private log: readonly SessionEvent[],
private readonly baseSeq = 0,
) {
this._lastProcessedSeq = baseSeq - 1
}
/**
* Validate the next candidate without mutating the committed surface.
* @param event - candidate event that has not entered the log yet.
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length, this.log)
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
const expectedSeq = this.baseSeq + this.log.length
this._pendingPlan = {
event,
expectedSeq,
plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq),
}
}
/** Monotonic count of folded positional replacements. */
get replaceGeneration(): number {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.replaceGeneration
}
/** Surface event sequences in model-visible order. */
get nodes(): readonly number[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.nodes
}
/** Fold events appended since the previous access. */
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
const tailSeq = this.baseSeq + this.log.length - 1
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
const index = seq - this.baseSeq
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
const event = this.log[index]!
const pending = this._pendingPlan
if (pending?.event === event && pending.expectedSeq === seq) {
applySurfacePlan(this._state, pending.plan)
} else {
applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq)
}
if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
this._lastProcessedSeq = seq
}
}
}

View File

@@ -5,7 +5,6 @@ import type {
LlmCallConfig,
LlmCallConfigAdapterDefaults,
LlmFailure,
MessageSource,
StreamChunk,
TokenUsage,
ToolResultMessage,
@@ -95,23 +94,32 @@ export interface CreateSessionOptions {
}
/**
* What started a turn.
* Merge-extensible sum type (same pattern as MessageSourceMap).
* Fresh storage values transferred to {@link SessionStore.prepare} without a
* second serialization copy. Callers retain no mutable aliases.
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
/**
* An out-of-band producer explicitly enclosed injected context in a one-shot
* turn. `Agent.inject()` appends idle context directly and does not use this
* trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
export interface RestoredSessionOptions {
/** Fresh detached storage events to validate and freeze in place. */
readonly seed: SessionEvent[]
/** Fresh detached storage metadata to validate and freeze in place. */
readonly meta: SessionHeader
/** Select the persistence ownership-transfer path. */
readonly seedSource: 'persistence'
}
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/** Inputs accepted while constructing an unpublished Session. */
export type PrepareSessionOptions =
| (CreateSessionOptions & { readonly seedSource?: undefined })
| RestoredSessionOptions
/** Why an active agent driver was cancelled. */
export type AgentCancelCause =
| { readonly kind: 'user' }
| { readonly kind: 'parent' }
| { readonly kind: 'hook'; readonly reason: string }
| { readonly kind: 'disposed' }
/** Durable cancellation cause, including imports whose original coarse record carried no cause. */
export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }
/**
* Why a turn ended. Merge-extensible sum type.
@@ -119,20 +127,15 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
blocked: { kind: 'blocked' }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other thrown values retain their rendered message and a
* real `HarnessError` code when present.
* The turn failed. `error` is always a structured failure: the `LlmError`
* facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
* flattened from any other error.
*/
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
| { message: string; code?: string; failure?: never }
)
disposed: { kind: 'disposed' }
error: { kind: 'error'; error: LlmFailure }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
@@ -178,17 +181,13 @@ export interface EpochHeader {
tools?: ToolSchema[]
}
/**
* Registration-bound context metadata of one resolved model route. Adapter
* metadata about a route rather than a request input, which is why it lives
* outside {@link EpochHeader}.
*/
/** Registration-bound metadata for one resolved model route. */
export interface RequestContext {
/** Registered provider route the metadata was resolved through. */
/** Registered provider route the metadata belongs to. */
provider: string
/** Provider-owned model id the metadata belongs to. */
model: string
/** Maximum combined request and response context in tokens; absent when the adapter advertises none. */
/** Maximum combined request and response context in tokens, when advertised. */
contextWindow?: number
}
@@ -208,14 +207,19 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started the model loop.
* Opens turn `turn` before the loop claims queued input or runs pre-step.
* Rejection, empty input, cancellation, or failure may close it with no
* step; otherwise the following identified `user/message` event or batch
* records the messages entering the step.
*/
'turn/start': { turn: number; trigger: TurnTrigger }
'turn/start': { turn: number }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* awaits `session/flush` after an ordinary turn ends before claiming the next
* queued item. Success commits the turn; rejection is reported live and does
* not prevent later work.
* Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn
* with no entered step has no `step/start` or `step/end`. The loop does not await a
* flush at turn boundaries: `dsh-session-checkpoint-policy` owns the
* per-request durability checkpoint, and consumers that read storage after
* `whenIdle()` flush themselves. Success commits the turn; rejection is
* reported live and does not prevent later work.
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
@@ -226,9 +230,8 @@ export interface SessionEventMap {
* A user-role message on the model-visible surface: a direct human prompt
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
* notifications, …), or an entered goal continuation round. All three
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
/** Raw stream chunk — token-level replay fidelity. */
@@ -264,8 +267,6 @@ export interface SessionEventMap {
error?: { name: string; code: string }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; message: UserMessage }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**
@@ -274,21 +275,14 @@ export interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Registration-bound context metadata for the route a request resolved to,
* appended inside its step beside `request/header` and only when the route
* or capacity differs from the last record. It is log-only and deliberately
* NOT part of {@link EpochHeader}: capacity is adapter metadata about a
* route, not an input the request was built from, so it must not participate
* in request reconstruction or header equality. `contextWindow` is absent
* when the route's adapter advertises no capacity.
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
*/
'request/context': RequestContext
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. An explicitly supplied empty seed puts the marker
* at seq 0, distinguishing an empty resumed session from a fresh session.
* This log-only event is the durable projection of
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
@@ -322,7 +316,6 @@ export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
| 'steering/message'
/**
* A {@link SessionEvent} that is **on** the ordered surface — its
@@ -339,7 +332,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* - `'append'`: added to the tail — normal path for user/assistant/tool
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -374,7 +367,7 @@ export interface SurfaceIntent {
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`, `steering/message`).
* `assistant/message`, `tool/result`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.

View File

@@ -22,7 +22,7 @@ function scratch(session: Session): unknown {
describe('derived-message cache', () => {
it('stays deep-equal to a from-scratch replay derivation as the log grows', () => {
const session = Session.create(SessionId('cache-grow'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
userText(session, 'one')
expect(session.deriveMessages()).toEqual(scratch(session))
userText(session, 'two')
@@ -55,7 +55,7 @@ describe('derived-message cache', () => {
it('rebuilds on a surface replace and still matches scratch', () => {
const session = Session.create(SessionId('cache-replace'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
userText(session, 'one')
userText(session, 'two')
const beforeReplace = session.deriveMessages()
@@ -73,7 +73,7 @@ describe('derived-message cache', () => {
it('returns a fresh array per call: later appends never grow a held snapshot', () => {
const session = Session.create(SessionId('cache-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
userText(session, 'one')
const first = session.deriveMessages()
userText(session, 'two')
@@ -90,7 +90,7 @@ describe('derived-message cache', () => {
describe('Session.deriveEventMessage — the per-event projection', () => {
it('projects one appended event exactly as the full derivation projects its node', () => {
const session = Session.create(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -100,7 +100,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
it('reuses the logged event\'s already frozen content', () => {
const session = Session.create(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -114,7 +114,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
it('projects null for events that produce no message (boundaries, empty assistant)', () => {
const session = Session.create(SessionId('per-event-null'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const boundary = session.append('step/start', { turn: 1, step: 1 })
expect(session.deriveEventMessage(boundary)).toBeNull()
const empty = session.append('assistant/message', {

View File

@@ -24,7 +24,7 @@ function appendClosedTurn(
text = `hello ${turn}`,
reason: TurnEndReason = { kind: 'completed' },
): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
@@ -33,7 +33,7 @@ function appendClosedTurn(
}
function appendOpenTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `open ${turn}` }],
source: { kind: 'user' },
@@ -138,18 +138,18 @@ describe('SessionStore.fork', () => {
const { ctx, sessions } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
{ kind: 'aborted' },
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
{ kind: 'disposed' },
{ kind: 'aborted', reason: { kind: 'user' } },
{ kind: 'error', error: { message: 'model failed', code: 'UNKNOWN' } },
{ kind: 'aborted', reason: { kind: 'disposed' } },
{ kind: 'max-tokens' },
{ kind: 'interrupted' },
]
for (const reason of reasons) {
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
for (const [index, reason] of reasons.entries()) {
const source = ctx.sessions.create(SessionId(`parent-${index}`))
appendClosedTurn(source, 1, reason.kind, reason)
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`))
expect(inherited(child).at(-1)?.type).toBe('turn/end')
expect(child.header.seedLength).toBe(source.events.length)
@@ -236,23 +236,23 @@ describe('SessionStore.fork', () => {
const { ctx, sessions } = await setup()
const cases: [string, (session: Session) => number][] = [
['turn/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
return lastSeq(session)
}],
['step/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
return lastSeq(session)
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
return lastSeq(session)
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1, step: 1,
@@ -269,7 +269,7 @@ describe('SessionStore.fork', () => {
}],
['tool/call', (session) => {
const callId = CallId('call-open')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
@@ -310,7 +310,7 @@ describe('SessionStore.fork', () => {
it('rejects a duplicate child session id before validating the boundary', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('open-parent'))
source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
source.append('turn/start', { turn: 1 })
ctx.sessions.create(SessionId('child'))
expect(() => sessions.fork(source, undefined, SessionId('child')))

View File

@@ -26,7 +26,7 @@ describe('session-log invariants', () => {
await scopedCtx.plugin(SessionInvariant)
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -35,7 +35,7 @@ describe('session-log invariants', () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -78,11 +78,10 @@ describe('session-log invariants', () => {
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('later dispatch veto')
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -94,7 +93,7 @@ describe('session-log invariants', () => {
const session = ctx.sessions.create(SessionId('postcommit-peer'))
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(warnings).toHaveLength(2)
@@ -107,7 +106,7 @@ describe('session-log invariants', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
} as never)
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/end',
@@ -120,30 +119,42 @@ describe('session-log invariants', () => {
it('enforces turn numbering and core execution enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
open.append('turn/start', { turn: 1 })
expect(() => open.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
.toThrow(/does not match open turn 1/)
const second = (await setup()).ctx.sessions.create()
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
second.append('turn/start', { turn: 1 })
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
expect(() => second.append('turn/start', { turn: 3 }))
.toThrow(/expected turn 2, got 3/)
const third = (await setup()).ctx.sessions.create()
third.append('turn/start', { turn: 1 })
third.append('step/start', { turn: 1, step: 1 })
third.append('step/end', { turn: 1, step: 1 })
expect(() => third.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.not.toThrow()
const enclosed = (await setup()).ctx.sessions.create()
enclosed.append('turn/start', { turn: 1 })
enclosed.append('step/start', { turn: 1, step: 1 })
expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow()
expect(() => enclosed.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
reason: 'initial',
} as never)).not.toThrow()
expect(() => enclosed.append('request/context', {
provider: 'mock', model: 'mock',
})).not.toThrow()
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'plugin', plugin: 'test' },
}), { surfaceOp: 'append' })).not.toThrow()
expect(() => outside.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Route capacity is core execution state like the header beside it.
expect(() => outside.append('request/context', {
provider: 'mock',
@@ -155,17 +166,16 @@ describe('session-log invariants', () => {
expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow()
expect(() => outside.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
it('enforces open-step identity and numbering', async () => {
const wrongTurn = (await setup()).ctx.sessions.create()
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
wrongTurn.append('turn/start', { turn: 1 })
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
const nested = (await setup()).ctx.sessions.create()
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
nested.append('turn/start', { turn: 1 })
nested.append('step/start', { turn: 1, step: 1 })
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
@@ -185,16 +195,21 @@ describe('session-log invariants', () => {
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
skipped.append('turn/start', { turn: 1 })
skipped.append('step/start', { turn: 1, step: 1 })
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
expect(() => skipped.append('turn/end', {
turn: 1,
reason: { kind: 'completed' },
})).not.toThrow()
})
it('requires step-scoped stream and tool events to name the open step', async () => {
const chunk = (await setup()).ctx.sessions.create()
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
chunk.append('turn/start', { turn: 1 })
expect(() => chunk.append('assistant/chunk', {
turn: 1,
step: 1,
@@ -202,7 +217,7 @@ describe('session-log invariants', () => {
})).toThrow(/open is turn 1\/step null/)
const tool = (await setup()).ctx.sessions.create()
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
tool.append('turn/start', { turn: 1 })
tool.append('step/start', { turn: 1, step: 1 })
expect(() => tool.append('tool/result', {
turn: 1,
@@ -218,7 +233,7 @@ describe('session-log invariants', () => {
it('keeps fresh tool-result appends open-step checked', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
@@ -233,7 +248,7 @@ describe('session-log invariants', () => {
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
@@ -254,7 +269,7 @@ describe('session-log invariants', () => {
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 2 })
expect(() => session.append('tool/result', {
...original.data,
message: freezeMessage({
@@ -273,7 +288,7 @@ describe('session-log invariants', () => {
it('rejects a tool-result replacement outside a turn', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
@@ -312,7 +327,7 @@ describe('session-log invariants', () => {
it('allows not-started repair results and unresolved calls at step end', async () => {
const repaired = (await setup()).ctx.sessions.create()
expect(() => {
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
repaired.append('turn/start', { turn: 1 })
repaired.append('step/start', { turn: 1, step: 1 })
repaired.append('tool/result', {
turn: 1,
@@ -330,18 +345,18 @@ describe('session-log invariants', () => {
const unresolved = (await setup()).ctx.sessions.create()
expect(() => {
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unresolved.append('turn/start', { turn: 1 })
unresolved.append('step/start', { turn: 1, step: 1 })
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
unresolved.append('step/end', { turn: 1, step: 1 })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
}).not.toThrow()
})
it('does not let a result in a later step satisfy an earlier call', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('step/end', { turn: 1, step: 1 })
@@ -360,22 +375,22 @@ describe('session-log invariants', () => {
it('replays seeded sessions and tracks each session independently', async () => {
const { ctx } = await setup()
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1 } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2 } },
]
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
a.append('turn/start', { turn: 1 })
expect(() => b.append('turn/start', { turn: 1 }))
.not.toThrow()
})
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
await fiber.dispose()
await ctx.plugin(SessionInvariant)
@@ -384,7 +399,7 @@ describe('session-log invariants', () => {
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'h' },
})).not.toThrow()
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
expect(() => session.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
})
@@ -392,16 +407,16 @@ describe('session-log invariants', () => {
const { ctx } = await setup()
// Balanced seed: between turns.
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] })).not.toThrow()
// Unbalanced seed: inside the open turn, which the relation permits.
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
] })
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
// Still open afterwards: the boundary moves no cursor.
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
expect(() => open.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
})
@@ -409,11 +424,10 @@ describe('session-log invariants', () => {
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
await fiber.dispose()
expect(() => session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
})

View File

@@ -70,7 +70,7 @@ const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
// A non-message event (trace/replay data — must NOT affect derived history).
const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1 } }),
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),

View File

@@ -13,7 +13,7 @@ import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
*/
const userTurnStart = (turn: number, seq: number): SessionEvent =>
({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
({ type: 'turn/start', seq, time: seq, data: { turn } })
describe('interruptedTurnClosers', () => {
it('returns nothing for a balanced log (ends on turn/end)', () => {

View File

@@ -68,7 +68,7 @@ describe('foldRequestHeader', () => {
it('returns the supplied baseline when no snapshot follows', () => {
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
const unrelated: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
]
expect(foldRequestHeader(unrelated)).toBeUndefined()
expect(foldRequestHeader(unrelated, from)).toBe(from)
@@ -76,7 +76,7 @@ describe('foldRequestHeader', () => {
it('takes the latest full snapshot and skips unrelated events', () => {
const session = Session.create(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
@@ -121,7 +121,7 @@ describe('Session.requestContext', () => {
/** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
const events: SessionEvent[] = [{
type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
type: 'turn/start', seq: 0, time: 1, data: { turn: 1 },
}]
for (const data of records) {
events.push({ type: 'request/context', seq: events.length, time: 1, data })

View File

@@ -40,7 +40,7 @@ describe('session dispatch carriers', () => {
otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
const session = scope.ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(heard).toEqual([
`owner-created:${session.id}`,
@@ -57,7 +57,7 @@ describe('session dispatch carriers', () => {
scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
const bare = ctx.sessions.create()
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
bare.append('turn/start', { turn: 1 })
expect(heard).toEqual(['global:turn/start'])
})

View File

@@ -3,16 +3,32 @@ import { Context } from 'cordis'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
adoptSessionEvent,
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
SessionId,
findLastMessageTurnEnd,
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('finds the latest closed turn that entered a model step', () => {
const session = Session.create(SessionId('last-message-turn'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', { turn: 2 })
session.append('step/start', { turn: 2, step: 1 })
session.append('step/end', { turn: 2, step: 1 })
session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
expect(findLastMessageTurnEnd(session.events)?.data)
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
})
it('exposes one stable readonly surface view', () => {
const session = Session.create(SessionId('surface-view'))
const surface = session.surface
@@ -23,7 +39,7 @@ describe('Session', () => {
it('derives message history from the event log', () => {
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -63,7 +79,7 @@ describe('Session', () => {
// The max-tokens TurnEndReason variant carries no extra data, so it must
// append and persist like any other reason (JSON-serializable, no fields).
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
@@ -72,81 +88,27 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('finds the latest message-turn outcome past later non-message turns', () => {
const session = Session.create(SessionId('message-turn-outcome'))
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
})
it('round-trips the coarse aborted turn outcome', () => {
it('round-trips an aborted turn with its cancellation cause', () => {
const session = Session.create(SessionId('aborted'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
})
it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => {
const legacy = [
{
type: 'turn/start', seq: 0, time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } },
},
] as unknown as SessionEvent[]
expect(() => Session.create(SessionId('legacy-aborted'), legacy))
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
it('renders injected-context and steering messages as plain user content', () => {
it('renders injected-context and user messages as plain user content', () => {
const session = Session.create(SessionId('s2'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
session.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const [contextMessage, steeringMessage] = session.deriveMessages()
expect(contextMessage!.role).toBe('user')
@@ -170,7 +132,7 @@ describe('Session', () => {
it('replays identically from a seeded event log', () => {
const original = Session.create(SessionId('s3'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/start', { turn: 1 })
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -288,6 +250,14 @@ describe('Session', () => {
},
message: 'message has invalid source',
},
{
name: 'content shape',
event: {
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
data: { ...user, content: 'not-an-array' },
},
message: 'message has invalid content',
},
{
name: 'assistant source',
event: {
@@ -300,17 +270,6 @@ describe('Session', () => {
},
message: 'message must have model source',
},
{
name: 'content block',
event: {
type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
message: { ...user, content: 'not-an-array' },
},
},
message: 'message has invalid content',
},
{
name: 'tool source',
event: {
@@ -365,13 +324,13 @@ describe('Session', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
})
expect(boundary).toEqual({
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
})
const extended = snapshotSessionEvent({
@@ -555,7 +514,7 @@ describe('Session', () => {
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = Session.create(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
const widenedType = 'user/message' as SessionEventType
@@ -584,7 +543,7 @@ describe('Session', () => {
it('validates seed events: rejects a non-contiguous seq', () => {
const gapSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
@@ -596,7 +555,7 @@ describe('Session', () => {
// so a resume/fork would silently lose history. append() forbids this at
// compile time; a raw seed must be rejected at runtime to match.
const markerlessSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}) },
@@ -607,7 +566,7 @@ describe('Session', () => {
it('accepts a well-formed contiguous serializable seed', () => {
const goodSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}), surfaceOp: 'append' as const },
@@ -623,7 +582,7 @@ describe('Session', () => {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
data: { turn: 1 },
}
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
let reads = 0
@@ -699,7 +658,7 @@ describe('Session', () => {
readonly type = 'turn/start' as const
readonly seq = 0
readonly time = 1
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
readonly data = { turn: 1 }
}
const seed: SessionEvent[] = [new SeedEvent()]
@@ -712,7 +671,7 @@ describe('Session', () => {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
data: { turn: 1 },
}) as unknown as SessionEvent
const session = Session.create(SessionId('seed-null-prototype'), [event])
@@ -794,7 +753,7 @@ describe('Session', () => {
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message' as const, seq: 1, time: 2, data: {
id: MessageId('seed-input'),
role: 'user' as const,
@@ -945,14 +904,14 @@ describe('Session', () => {
expect(() => appendRaw(
'turn/start',
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
{ turn: 1 },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
surfaceOp: 'append',
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
expect(session.events).toEqual([])
@@ -963,13 +922,12 @@ describe('Session', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
}])
const seededEvent = seeded.events[0]!
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
expect(Object.isFrozen(seededEvent)).toBe(true)
expect(Object.isFrozen(seededEvent.data)).toBe(true)
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = Session.create(SessionId('append-frozen'))
@@ -983,9 +941,39 @@ describe('Session', () => {
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
})
it('iteratively freezes deeply nested restored event data', () => {
const depth = 20_000
const data: Record<string, unknown> = {}
let tail = data
for (let index = 0; index < depth; index += 1) {
const child: Record<string, unknown> = {}
tail['child'] = child
tail = child
}
const event = {
type: 'test/deep-restore', seq: 0, time: 1, data,
} as unknown as SessionEvent
expect(() => Session.fromRestore(SessionId('deep-restore'), [event], {
version: SESSION_FORMAT_VERSION,
id: SessionId('deep-restore'),
createdAt: 1,
})).not.toThrow()
let current: unknown = event
let frozenNodes = 0
for (let index = 0; index <= depth + 1; index += 1) {
if (!Object.isFrozen(current)) break
frozenNodes += 1
current = (current as Record<string, unknown>)['data']
?? (current as Record<string, unknown>)['child']
}
expect(frozenNodes).toBe(depth + 2)
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = Session.create(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const before = session.events
const beforeEvent = before[0]!
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
@@ -1040,6 +1028,15 @@ describe('Session', () => {
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not losslessly JSON-serializable/)
expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader()))
.toThrow(/not a plain JSON record/)
for (const header of [null, 1, []]) {
expect(() => Session.fromRestore(
SessionId('header-invalid'),
[],
header as unknown as SessionHeader,
)).toThrow(/not a plain JSON record/)
}
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
@@ -1082,7 +1079,7 @@ describe('Session', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
}
const cases: unknown[] = [
{ ...base, extra: true },
@@ -1092,7 +1089,6 @@ describe('Session', () => {
{ ...base, seq: -1 },
{ ...base, time: '1' },
{ ...base, time: 0.5 },
{ ...base, time: -1 },
{ type: base.type, seq: base.seq, time: base.time },
]
@@ -1121,7 +1117,7 @@ describe('SessionStore', () => {
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1139,7 +1135,7 @@ describe('SessionStore', () => {
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/start', { turn: 1 })
a.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1390,7 +1386,7 @@ describe('SessionStore', () => {
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1416,7 +1412,6 @@ describe('SessionStore', () => {
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
expect(committedBeforeNotify).toBe(true)
@@ -1455,14 +1450,12 @@ describe('SessionStore', () => {
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('reject first candidate')
expect(session.events).toEqual([])
expect(observed).toEqual([])
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
{ logLength: 0, frozen: true },
@@ -1478,7 +1471,7 @@ describe('SessionStore', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }],
@@ -1533,7 +1526,6 @@ describe('SessionStore', () => {
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('dispatch instrumentation rejected the carrier')
expect(session.events).toEqual([])
expect(observed).toEqual([])
@@ -1553,7 +1545,6 @@ describe('SessionStore', () => {
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
@@ -1584,7 +1575,6 @@ describe('SessionStore', () => {
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
@@ -1735,7 +1725,7 @@ describe('todo/write event', () => {
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = Session.create(SessionId('t4'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/start', { turn: 1 })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.

View File

@@ -9,6 +9,7 @@ import {
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
import { SurfaceManager } from '@deepseek-ai/dsh-session/surface'
import {
createMessage,
createToolResultMessage,
@@ -21,7 +22,7 @@ import {
/** Build a minimal session with turn boundaries and a single user message. */
function surfaceSession(): Session {
const s = Session.create(SessionId('ss'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('turn/start', { turn: 1 })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -95,7 +96,7 @@ describe('foldSurface provenance', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
sourceEventSeqs: [0],
} as unknown as SessionEvent
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
@@ -239,6 +240,53 @@ describe('foldSurface tool-result rewrites', () => {
})
describe('SurfaceManager', () => {
it('folds a contiguous window without materializing earlier event sequences', () => {
const baseSeq = 400_000
const events = [
provenanceEvent(baseSeq, undefined),
provenanceEvent(baseSeq + 1, undefined),
{
...provenanceEvent(baseSeq + 2, [baseSeq]),
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
},
] as SessionEvent[]
const surface = new SurfaceManager(events, baseSeq)
expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1])
expect(surface.replaceGeneration).toBe(1)
})
it('validates tool-result rewrites against a nonzero window offset', () => {
const baseSeq = 400_000
const original = toolResultEvent(baseSeq, 'call')
const events: SessionEvent[] = [
original,
{
...original,
seq: baseSeq + 1,
time: baseSeq + 1,
surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
} as SessionEvent,
]
expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1])
})
it('rejects a replacement that crosses a loaded window head', () => {
const baseSeq = 400_000
const events = [
provenanceEvent(baseSeq, undefined),
{
...provenanceEvent(baseSeq + 1, [baseSeq - 1, baseSeq]),
surfaceOp: { op: 'replace', start: baseSeq - 1, end: baseSeq },
},
] as SessionEvent[]
expect(() => new SurfaceManager(events, baseSeq).nodes)
.toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`)
})
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = Session.create(SessionId('shared-fold'))
s.append('user/message', createUserMessage({
@@ -380,7 +428,7 @@ describe('SurfaceManager', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
surfaceOp: 'append',
} as unknown as SessionEvent
@@ -398,7 +446,7 @@ describe('SurfaceManager', () => {
it('empty surface yields empty nodes', () => {
const s = Session.create(SessionId('empty'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -667,7 +715,7 @@ describe('deriveMessages with surface', () => {
it('surface path skips non-surface events (chunks, boundaries)', () => {
const s = Session.create(SessionId('filter'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('turn/start', { turn: 1 })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
s.append('user/message', createUserMessage({
@@ -711,18 +759,15 @@ describe('deriveMessages with surface', () => {
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
})
it('injected-context and steering/message appear on surface', () => {
it('injected-context and user messages appear on surface', () => {
const s = Session.create(SessionId('ctx'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
s.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'focus' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'focus' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
@@ -733,7 +778,7 @@ describe('deriveMessages with surface', () => {
describe('Session.append surface opts', () => {
it('records sourceEventSeqs and surfaceOp on the event', () => {
const s = Session.create(SessionId('opts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
const event = s.append('assistant/message',
{
@@ -761,7 +806,7 @@ describe('Session.append surface opts', () => {
// but _deriveOneMessage returns null for it, so the surface derivation path's
// null-check is exercised — the node is on the surface yet produces no message.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: {
turn: 1, step: 1,
@@ -784,7 +829,7 @@ describe('Session.append surface opts', () => {
it('a non-surface event carries no surface fields', () => {
const s = Session.create(SessionId('noopts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('turn/start', { turn: 1 })
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
})
@@ -818,7 +863,7 @@ describe('Session.append surface opts', () => {
}
expect(isSurfaceEvent(noMarker)).toBe(false)
// A non-surface type is rejected too (the type gate).
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }
expect(isSurfaceEvent(boundary)).toBe(false)
// A properly-marked surface event narrows.
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
@@ -831,7 +876,6 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType('user/message')).toBe(true)
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
expect(isSurfaceEligibleType('tool/result')).toBe(true)
expect(isSurfaceEligibleType('steering/message')).toBe(true)
expect(isSurfaceEligibleType('turn/start')).toBe(false)
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
})
@@ -902,7 +946,7 @@ describe('surface type guards', () => {
describe('SurfaceManager.replaceGeneration', () => {
it('folds the pending log delta on access and counts replaces', () => {
const s = Session.create(SessionId('gen'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('turn/start', { turn: 1 })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e
README.zh.md: 2b96c6baa612d760b3497d5b9fe01b26c6a9dd91
README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
README.zh.md: b442239a50d539a8f079aa692c433d9defe57295

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Model-input assembly registry. Plugins contribute ordered stable system sections, cache-safe dynamic context, tool schemas, and named variables. The loop assembles once per step, renders stable sections as the system prompt, and appends a durable full dynamic-context snapshot only when its text changes or compaction removed the retained snapshot. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
## Config
@@ -17,7 +17,6 @@ Model-input assembly registry. Plugins contribute ordered stable system sections
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.context(context: PromptContext): () => void` Contribute cache-safe dynamic model context. Contexts are ordered independently from system sections; scoped contributions shadow same-named globals. The agent loop materializes the complete current set as one sourced user-role snapshot after retained history, only when changed or missing. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
@@ -30,17 +29,14 @@ Model-input assembly registry. Plugins contribute ordered stable system sections
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
- `PromptSection``{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100199`.
- `PromptContext``{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections.
- `PromptAssembly``{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section and context texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
- `renderContextSnapshot(assembly)` — applies the same strict interpolation to contexts, drops empty entries, and emits one full snapshot with an explicit supersession statement. An empty active set returns `''`; the loop emits one clearing snapshot when previously visible context disappears.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
### Extension points
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
- Context providers: policy and other changing-state owners contribute complete current facts without mutating the stable system prompt.
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
@@ -69,20 +65,6 @@ Identity is a fixed per-request cost when enabled. Persona and plugin text are r
Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token.
### Dynamic runtime context
#### What the model sees
Active contexts are joined in deterministic order after strict interpolation and logged as one sourced user-role message immediately before the request that first needs that snapshot. The message begins `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` A changed snapshot is appended after retained history; an unchanged retained snapshot adds nothing. If compaction removes it, the current full snapshot is emitted again. Removing the last context emits one explicit clearing snapshot.
#### Token effect
One concise message on the first request, on an effective context change, after compaction removed the retained snapshot, or when the active set becomes empty. Unchanged steps add no duplicate tokens.
#### KV Cache effect
Append-only after retained history. A context change preserves the previously cached system and conversation prefix instead of rewriting the first wire message.
### Tool schemas
#### What the model sees

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
模型输入组装注册表。插件贡献有序且稳定的系统段、缓存安全的动态上下文、工具 schema 和具名变量。循环在每个步骤组装一次,将稳定段渲染为系统提示词并且仅在文本变化或压缩compaction移除了保留的快照时追加一份持久的完整动态上下文快照。此插件拥有静态 harness 身份和全局部署 personaagent智能体作用域的 persona 会遮蔽全局默认值。
系统提示词组装注册表。插件贡献有序、工具 schema 和具名变量。循环在每个步骤组装一次,并将结果渲染为完整的模型提示词。此插件拥有静态 harness 身份和全局部署 personaagent智能体作用域的 persona 会遮蔽全局默认值。
## 配置
@@ -17,7 +17,6 @@
### 公开 API
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose资源释放
- `ctx.systemPrompt.context(context: PromptContext): () => void`贡献缓存安全的动态模型上下文。上下文与系统段分别排序带作用域的贡献会遮蔽同名全局项。仅在完整当前集合变化或缺失时agent loop智能体循环会在保留的历史后将其具体化为一份带来源的 user 角色快照。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }``schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
@@ -30,18 +29,15 @@
- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。
- `PromptSection``{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona工具引导使用 `100199`
- `PromptContext``{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约
- `PromptAssembly``{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输
- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}``{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。
- `renderContextSnapshot(assembly)`:对上下文执行同样严格的插值,删除空条目,并发出一份带显式取代声明的完整快照。活动集合为空时返回 `''`;先前可见的上下文消失时,循环会发出一份清除快照。
可通过合并扩展:插件可以借助声明合并,为 `PromptAssembly``AssembleContext` 声明额外字段。
### 扩展点
- 段提供方:工具包拥有跨调用引导(`tool:bash``tool:read` 等);此插件拥有 `harness:identity``deployment:persona`
- 上下文提供方:策略及其他变化状态的归属方贡献完整的当前事实,而不改变稳定的系统提示词
- 变量提供方agent loop 注册 `model``cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
- 段提供方:工具包package拥有跨调用引导(`tool:bash``tool:read` 等);此插件拥有 `harness:identity``deployment:persona`
- 变量提供方:agent loop智能体循环注册 `model``cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)
- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。
@@ -69,20 +65,6 @@ You are an AI agent powered by the DeepSeek Harness SDK.
只要身份、persona、变量、段文本与顺序的渲染完全相同前缀就保持稳定。任何变更都可能从第一个变化的系统提示词 token 起使复用失效。
### 动态运行时上下文
#### 模型看到的内容
活动上下文经过严格插值后按确定顺序连接,并在首次需要该快照的请求之前立即记录为一条带来源的 user 角色消息。消息以 `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` 开头。变化后的快照会追加到保留的历史之后;保留的快照未变时不会增加内容。如果压缩移除了它,当前完整快照会再次发出。移除最后一项上下文时会发出一份显式清除快照。
#### Token 影响
首次请求、上下文实际变化、压缩移除保留的快照或活动集合变空时,会增加一条简洁消息。未变化的步骤不会增加重复 token。
#### KV Cache 影响
在保留的历史之后仅追加。上下文变化会保留先前缓存的系统与对话前缀,而不会改写第一条 wire 消息。
### 工具 schema
#### 模型看到的内容

View File

@@ -1,5 +1,5 @@
/**
* Registry for ordered system sections, cache-safe context, tool schemas, and prompt variables.
* Registry for ordered system sections, dynamic context, tool schemas, and prompt variables.
*
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -65,15 +65,11 @@ export interface PromptSection {
readonly text: string | ((context: AssembleContext) => string)
}
/**
* One dynamic model-context contribution. Unlike a {@link PromptSection}, its
* rendered text is materialized as a durable user-role snapshot at the request
* tail, so changing runtime state preserves the stable system/history prefix.
*/
/** Dynamic model context materialized as a durable user-role snapshot. */
export interface PromptContext {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
readonly name: string
/** Contexts are joined in ascending order, independently of system-section order. */
/** Contexts are joined in ascending order. */
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
@@ -87,11 +83,11 @@ export interface AssembledSection {
text: string
}
/** One dynamic context contribution with its text resolved. */
/** One resolved dynamic context contribution. */
export interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved (but not yet interpolated) context text. */
/** The resolved text before variable interpolation. */
text: string
}
@@ -105,8 +101,7 @@ export interface ToolProviderResult {
/**
* Merge-extensible assembled model input. Sections and contexts remain
* uninterpolated until their renderers; tools are already in canonical
* model-facing order.
* uninterpolated until rendered; tools are already in canonical order.
*/
export interface PromptAssembly {
sections: AssembledSection[]
@@ -200,10 +195,7 @@ export function renderPrompt(assembly: PromptAssembly): string {
}
/**
* Render the complete current dynamic context snapshot. The agent loop appends
* a new durable snapshot only when this text changes or is no longer retained
* after compaction; the explicit supersession clause makes older snapshots in
* history harmless.
* Render the complete dynamic context snapshot.
* @param assembly - the assembly whose contexts and variables to render.
* @returns the current full snapshot, or `''` when no context is active.
*/
@@ -372,10 +364,8 @@ export class SystemPrompt extends Service {
}
/**
* Register ordered cache-safe dynamic context in the calling context's scope.
* A scoped context shadows a global context with the same name; duplicates
* within one layer and non-finite orders throw. Registration and disposal
* emit `system-prompt/change`.
* Register ordered dynamic context in the calling context's scope. Scoped
* entries shadow global entries with the same name.
* @param context - the context contribution to register.
* @returns the exact Cordis effect disposer.
*/

View File

@@ -98,7 +98,7 @@ describe('tool-pipeline invariants', () => {
arguments: {},
}
expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
@@ -107,7 +107,7 @@ describe('tool-pipeline invariants', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('tool/code-dispatch', {
parentCallId: CallId('parent'),
subCallId: CallId('child'),