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/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?.()
})
})