diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
new file mode 100644
index 0000000000..0dfa875d0b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
@@ -0,0 +1,39 @@
+# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message
+
+Status: implemented
+
+## Problem
+
+The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work.
+
+Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`).
+
+## Decision
+
+**One primitive, three preset aliases.** `Agent` is now an abstract class whose single abstract `send(content, { target, wakeup, source, contexts, meta })` covers the (`target` × `wakeup`) matrix. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) are concrete delegates on the base class, so concrete drivers implement `send` once and inherit the ergonomic presets. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `send` defaults to `{ target: 'next-turn', wakeup: true }`, so every prior bare `agent.send(content)` call keeps its exact behavior. `next-turn`/no-wakeup (queue without waking) is now representable with no alias and no current caller.
+
+**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position (deferred behind an executing tool batch), or a one-shot `injection` turn when idle. It bypasses the FIFOs entirely and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`.
+
+**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`.
+
+**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata.
+
+**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO; carries `target`/`wakeup` on `InboxItemInfo`), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items). Injection never touches a FIFO and emits none of these. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative.
+
+**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped).
+
+## Alternatives considered
+
+- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead.
+- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact.
+- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the added `target`/`wakeup` facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe.
+
+## Consequences
+
+The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The cost: `Agent` became an abstract class, so object-literal test fakes must supply `followup` and cannot spread a class-typed value without re-casting (prototype methods are non-enumerable); the goal fold's channel split moved from event type to `source.round`; and every consumer that filtered `context/message` now filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged — an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
+
+## Related
+
+- [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on.
+- [remove-agent-steering-mirror](../simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event.
+- [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends.
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index b732708e1f..9a97873a17 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -18,7 +18,7 @@ sequenceDiagram
participant Persistence
participant SDK as UI or SDK listener
User->>Agent: send(content)
- Agent-->>SDK: agent/queued
+ Agent-->>SDK: agent/inbox/enqueue
Agent->>Driver: queued work wakes driver
Driver-->>SDK: agent/status running
Driver->>Session: turn/start
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 4ca3fd0601..7c9ae4bea8 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts)
### `agent/created` — emit
@@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
@@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -96,7 +96,72 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:477`](../../packages/core/agent/src/types.ts)
+
+### `agent/inbox/dequeue` — emit
+
+The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps. Fires after the item leaves its FIFO and before it becomes a durable message.
+
+```ts cordis-catalog
+/**
+ * The driver claimed one item out of the inbox: a queued item at a turn
+ * boundary, or steering drained between steps. Fires after the item leaves
+ * its FIFO and before it becomes a durable message.
+ * @param agent - the agent whose inbox item was claimed.
+ * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+'agent/inbox/dequeue'(this: Scoped, agent: Agent, info: InboxItemInfo): void
+```
+
+Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+
+Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts)
+
+### `agent/inbox/discard` — emit
+
+`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them. Fires once per effective clearing call with every discarded item, after `agent/cancel-requested` and before the abort.
+
+```ts cordis-catalog
+/**
+ * `cancel()` (without `keepInbox`) dropped pending inbox items without
+ * delivering them. Fires once per effective clearing call with every
+ * discarded item, after `agent/cancel-requested` and before the abort.
+ * @param agent - the agent whose inbox was cleared.
+ * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void
+```
+
+Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+
+Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts)
+
+### `agent/inbox/enqueue` — emit
+
+A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `info` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
+
+```ts cordis-catalog
+/**
+ * A detached, frozen item entered the agent's inbox (queued or steering
+ * FIFO). Source defaults are already applied, so `info` holds the exact
+ * accepted values. This is the enqueue-time live signal; the durable record
+ * is the eventual `user/message`/`steering/message`. Injection
+ * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
+ * @param agent - the agent whose inbox received the item.
+ * @param info - the accepted content, source, contexts, steering, and wakeup facts.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+'agent/inbox/enqueue'(this: Scoped, agent: Agent, info: InboxItemInfo): void
+```
+
+Types: [Agent](../core-data-structures/core.md) · [InboxItemInfo](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
+
+Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts)
### `agent/post-step` — serial
@@ -119,7 +184,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — serial
@@ -142,7 +207,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
@@ -169,28 +234,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts)
-
-### `agent/queued` — emit
-
-Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log.
-
-```ts cordis-catalog
-/**
- * Detached, frozen content entered the agent's inbox. Source defaults have
- * already been applied, so these are the exact values retained for the log.
- * @param agent - the agent whose inbox received the message.
- * @param content - the accepted content blocks retained by the inbox.
- * @param info - the accepted source, contexts, and whether it entered as steering.
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode emit
- */
-'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
-```
-
-Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [HookContext](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-
-Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
@@ -215,7 +259,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:388`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall
@@ -241,7 +285,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:442`](../../packages/core/agent/src/types.ts)
### `agent/session-prefix` — waterfall
@@ -267,7 +311,7 @@ Compose request-only messages placed before derived history. The frozen result i
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -289,7 +333,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -309,7 +353,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts)
### `agent/step-result` — waterfall
@@ -332,7 +376,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:415`](../../packages/core/agent/src/types.ts)
### `agent/turn-continuation` — waterfall
@@ -354,7 +398,7 @@ Override whether the turn continues. The default continues after tool calls or s
Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:341`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:453`](../../packages/core/agent/src/types.ts)
### `agent/turn-stop` — serial
@@ -376,7 +420,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a
Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 0fcb2fc28e..ef40314ed8 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1173,7 +1173,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 6b5596b782..c2d8b56a80 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -323,7 +323,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
- * `assistant/message`, `tool/result`, `context/message`, `steering/message`).
+ * `assistant/message`, `tool/result`, `steering/message`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
@@ -351,7 +351,7 @@ type SessionEvent = {
}[T]
```
-The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `context/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
+The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
## The agent handle
@@ -361,10 +361,35 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
```ts type-equiv
/**
- * Message options. An omitted source attests direct human input as `{ kind: 'user' }`
- * and may authorize policy consumers, so non-human producers must label their content.
+ * Which inbox queue a {@link Agent.send} item joins:
+ * - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
+ * - `next-step` — the item joins the active turn between steps as steering,
+ * or, when no turn is active, is promoted per its `wakeup` flag.
+ */
+type SendTarget = 'next-turn' | 'next-step'
+```
+
+```ts type-equiv
+/**
+ * Options for the unified {@link Agent.send} primitive over the
+ * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
+ * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
+ * {@link Agent.inject} (`next-step`/no-wakeup).
+ *
+ * An omitted source attests direct human input as `{ kind: 'user' }` and may
+ * authorize policy consumers, so non-human producers must label their content.
*/
interface SendOptions {
+ /** Queue the item joins; defaults to `next-turn`. */
+ target?: SendTarget
+ /**
+ * Whether this item makes the model run: wake a parked driver (`next-turn`)
+ * or force a continuation step (`next-step` while running). Defaults to
+ * `true`. A `false` `next-turn` item queues without waking; a `false`
+ * `next-step` item attaches durable context without forcing another step
+ * (the injection preset).
+ */
+ wakeup?: boolean
source?: MessageSource
/**
* Model-facing contexts captured with this inbox item. A queued prompt exposes
@@ -372,16 +397,47 @@ interface SendOptions {
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
+ /** Opaque JSON state retained on the durable message but hidden from the model. */
+ meta?: JsonValue
}
```
-`InjectOptions` accepts ordinary message attribution and durable model-hidden JSON metadata. Attached contexts belong only to queued or steering input, so synthetic injection cannot accept them:
+The fixed-preset aliases own `target` and `wakeup`, so they accept only the remaining fields:
```ts type-equiv
-/** Options specific to durable synthetic context injection. */
-interface InjectOptions extends Omit {
- /** Opaque JSON state retained in the session event but hidden from the model. */
- meta?: JsonValue
+/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
+type AliasSendOptions = Omit
+```
+
+The `agent/inbox/*` live events carry the resolved facts of one FIFO item; injection bypasses the FIFOs and never appears on them:
+
+```ts type-equiv
+/**
+ * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*`
+ * live events. Source defaults are already applied, so these are the exact
+ * values the item was accepted with. `steering` is true for a `next-step`
+ * item drained between steps; a `next-turn` item is claimed at a turn boundary.
+ */
+interface InboxItemInfo {
+ content: ContentBlock[]
+ source: MessageSource
+ contexts: HookContext[]
+ /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */
+ steering: boolean
+ /** Whether the item is marked to wake the driver or force a continuation. */
+ wakeup: boolean
+}
+```
+
+```ts type-equiv
+/** Options for {@link Agent.cancel}. */
+interface CancelOptions {
+ /**
+ * Preserve queued and steering inbox items instead of discarding them. The
+ * active turn is still aborted, but un-started and pending work survives for a
+ * later turn and no `agent/inbox/discard` fires.
+ */
+ keepInbox?: boolean
}
```
@@ -392,59 +448,103 @@ type AgentCancelCause =
| { readonly kind: 'parent' }
```
+`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix.
+
```ts type-equiv
-/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
-interface Agent {
+/**
+ * Public agent handle; its concrete implementation is internal to
+ * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
+ * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
+ * {@link Agent.inject}) are shared concrete delegates over the single abstract
+ * {@link Agent.send} primitive; concrete drivers implement `send` once.
+ */
+abstract class Agent {
/** The single identity shared with {@link session}. */
- readonly id: SessionId
- readonly options: AgentOptions
- readonly session: Session
- readonly status: AgentStatus
+ abstract readonly id: SessionId
+ /** The provider route and model this agent's requests use. */
+ abstract readonly options: AgentOptions
+ /** The live session this agent drives; its log is the durable source of truth. */
+ abstract readonly session: Session
+ /** The current lifecycle state, mirrored on every `agent/status` transition. */
+ abstract readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
- readonly ctx: Context
+ abstract readonly ctx: Context
/**
- * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
- * ordinary message in its FIFO-ordered turn; the next claimed item waits for
- * that turn's checkpoint.
+ * The unified delivery primitive over the (`target` × `wakeup`) matrix.
+ * Detaches, validates, and freezes one lossless-JSON item, then routes it:
+ *
+ * - `next-turn` (default) queues an item that becomes the sole ordinary
+ * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a
+ * parked driver, while `wakeup:false` queues without waking.
+ * - `next-step` with `wakeup:true` submits steering into the active turn
+ * (idle falls back to a woken `next-turn`).
+ * - `next-step` with `wakeup:false` injects durable model-facing context
+ * without running the model: an open turn joins at the current log position
+ * (deferred behind an executing tool batch until it settles), and an idle
+ * inject records a one-shot turn with its own durability checkpoint.
+ *
* Attached contexts share the same snapshot and ownership boundary. Invalid
- * input throws synchronously before notification or enqueue.
+ * input throws synchronously before any notification, enqueue, or append.
+ * @param content - the model-facing content blocks to deliver.
+ * @param options - target queue, wakeup decision, source, contexts, and meta.
*/
- send(content: ContentBlock[], options?: SendOptions): void
+ abstract send(content: ContentBlock[], options?: SendOptions): void
/**
- * Submit steering while the agent is `running`. An open turn records it at
- * the next steering checkpoint before a request or continuation decision;
- * policy may stop before another step. After turn close and its checkpoint,
- * any remainder is queued for a later turn; terminal `agent/turn-stop`,
- * cancellation, or disposal may discard it. Uses the same synchronous
- * snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
- */
- steer(content: ContentBlock[], options?: SendOptions): void
-
- /**
- * Append detached model-facing context without running the model. An open-turn
- * injection joins at the current log position unless the current tool batch is
- * executing; then it waits FIFO until that batch settles and drains before turn
- * close even when interrupted. Idle injection uses a one-shot turn and durability
- * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`.
- */
- inject(content: ContentBlock[], options?: InjectOptions): void
-
- /**
- * Clear all queued and steering work, including items waiting to start, and
- * abort the active turn. An effective call first emits
- * `agent/cancel-requested` with the resolved typed cause. The first cause wins
- * for the active turn, and `whenIdle()` resolves after cancellation reaches
- * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
- * and does not arm later work. The active turn snapshots and freezes the cause.
+ * Clear queued and steering work — unless `keepInbox` — and abort the active
+ * turn. An effective call first emits `agent/cancel-requested` with the
+ * resolved typed cause. The first cause wins for the active turn, and
+ * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
+ * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
+ * later work. The active turn snapshots and freezes the cause.
* @param cause - the stable caller intent carried by the current turn signal.
+ * @param options - cancellation options; `keepInbox` preserves pending work.
*/
- cancel(cause?: AgentCancelCause): void
+ abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
- whenIdle(): Promise
+ abstract whenIdle(): Promise
+ /**
+ * Queue an ordinary follow-up turn and wake the driver — the
+ * `next-turn`/wakeup preset of {@link send}. The item becomes the sole
+ * ordinary message of its own turn.
+ * @param content - the prompt content blocks.
+ * @param options - source and attached contexts.
+ */
+ followup(content: ContentBlock[], options?: AliasSendOptions): void {
+ this.send(content, { ...options, target: 'next-turn', wakeup: true })
+ }
+
+ /**
+ * Submit steering into the running turn — the `next-step`/wakeup preset of
+ * {@link send}. An open turn records it at the next steering checkpoint before
+ * a request or continuation decision; policy may stop before another step.
+ * After turn close and its checkpoint, any remainder is queued for a later
+ * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
+ * Idle steering falls back to a woken follow-up turn.
+ * @param content - the steering content blocks.
+ * @param options - source and attached contexts.
+ */
+ steer(content: ContentBlock[], options?: AliasSendOptions): void {
+ this.send(content, { ...options, target: 'next-step', wakeup: true })
+ }
+
+ /**
+ * Append detached model-facing context without running the model — the
+ * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
+ * at the current log position unless the current tool batch is executing;
+ * then it waits FIFO until that batch settles and drains before turn close
+ * even when interrupted. Idle injection uses a one-shot turn and durability
+ * checkpoint. Disposal awaits idle checkpoints; flush failures report through
+ * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
+ * @param content - the injected context content blocks.
+ * @param options - source and durable model-hidden meta.
+ */
+ inject(content: ContentBlock[], options?: AliasSendOptions): void {
+ this.send(content, { ...options, target: 'next-step', wakeup: false })
+ }
}
```
@@ -460,7 +560,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above,
## Interception decisions
-Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes `context/message`; `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
+Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
@@ -470,8 +570,8 @@ interface HookContext {
content: ContentBlock[]
source: MessageSource
/**
- * Model placement. Absent or `separate` records an independent
- * `context/message`; `prompt-prefix` prepends this context and a stable
+ * Model placement. Absent or `separate` records an independent injected
+ * `user/message`; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md
index d45847ba0f..c0351f2f77 100644
--- a/docs/core-data-structures/goal.md
+++ b/docs/core-data-structures/goal.md
@@ -69,7 +69,7 @@ interface GoalView extends GoalSnapshot {
## Durable changes
-Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant.
+Every mutation is a round-zero goal-sourced `user/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, and verbatim rendered content form one replay invariant.
```ts type-equiv
/** Full-snapshot goal mutation retained in a model-visible context event. */
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index 7f61be2e73..d27f6fae88 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -9,7 +9,13 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
```ts type-equiv
-/** Shared payload for ordinary and steering prompt messages. */
+/**
+ * Shared payload for user, injected-context, and steering prompt messages. A
+ * direct human prompt, a synthetic `agent.inject()` context, and mid-turn
+ * steering all project into the model transcript as verbatim user-role content;
+ * they are told apart by `source` (a non-`user` kind marks injected context),
+ * not by event type. `meta` carries durable model-hidden producer state.
+ */
interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
content: ContentBlock[]
@@ -17,6 +23,15 @@ interface PromptMessageData {
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
+ /**
+ * Opaque durable JSON state retained on the event but hidden from the model
+ * projection. It is the intended channel for a future framing directive (a
+ * producer declares the frame, a dedicated renderer applies it — see the
+ * deferred note in
+ * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
+ * so the surface keeps projecting `content` verbatim rather than wrapping it.
+ */
+ meta?: JsonValue
}
```
@@ -46,29 +61,21 @@ interface SessionEventMap {
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
- /** A user-visible prompt (the queued message claimed for this turn). */
+ /**
+ * A user-role message on the model-visible surface: a direct human prompt
+ * (the queued message claimed for this turn), a synthetic `agent.inject()`
+ * context (file-change notices, subdir AGENTS.md, skill content, cron
+ * notifications, …), or an admitted goal continuation round. All three
+ * project their `content` verbatim; `source` (with a non-`user` kind marking
+ * injected context) is the only channel that tells them apart. An idle
+ * injection wraps this event in a one-shot turn so the log stays turn-enclosed.
+ */
'user/message': PromptMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
- /**
- * In-session context injection (file-change notices, subdir AGENTS.md,
- * skill content, cron notifications, …). Rendered into the derived history
- * as a synthetic user-role message carrying `content` verbatim — NOT a
- * user prompt. `meta` is durable JSON state omitted from the model
- * projection; it is also the intended channel for any future framing
- * directive (a producer declares the frame, a dedicated renderer applies it —
- * see the deferred note in
- * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
- * so the surface keeps projecting `content` verbatim rather than wrapping it.
- */
- 'context/message': {
- content: ContentBlock[]
- source: MessageSource
- meta?: JsonValue
- }
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -189,7 +196,7 @@ A proper discriminated union over `type` (not independent `type`/`data` unions),
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
- * `assistant/message`, `tool/result`, `context/message`, `steering/message`).
+ * `assistant/message`, `tool/result`, `steering/message`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
@@ -223,7 +230,7 @@ For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-emp
## Surface types
-The five message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md).
+The four message-producing types (`SurfaceEventType` — `user/message`, `assistant/message`, `tool/result`, `steering/message`) carry surface metadata declaring how they join the ordered derived surface. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md).
### `SurfaceEventType` — the message-producing subset of event types
@@ -237,7 +244,6 @@ type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
- | 'context/message'
| 'steering/message'
```
@@ -248,7 +254,7 @@ type SurfaceEventType =
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
- * - `'append'`: added to the tail — normal path for user/assistant/tool/context
+ * - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -455,7 +461,7 @@ declare class Session {
- `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata.
- `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript.
- `tool/result` → a user message carrying a `tool-result` block.
-- `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered.
+- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered.
- `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata.
Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data.
@@ -479,11 +485,12 @@ interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
- * was idle. The loop wraps the injected `context/message` in a one-shot turn
- * (`turn/start` → `context/message` → `turn/end`) so every event in the log
- * stays turn-enclosed — the durability/replay boundary is the turn, and a
- * bare event between turns would otherwise be indistinguishable from a crash
- * tail on reload.
+ * was idle. The loop wraps the injected `user/message` (a non-`user` source,
+ * plugin by default) in a one-shot turn (`turn/start` → `user/message` →
+ * `turn/end`) so every event in the log stays turn-enclosed — the
+ * durability/replay boundary is the turn, and a bare event between turns would
+ * otherwise be indistinguishable from a crash tail on reload. The trigger's
+ * `source` mirrors that message's producer.
*/
injection: { kind: 'injection'; source: MessageSource }
}
@@ -532,13 +539,13 @@ interface TurnEndReasonMap {
## The turn-enclosure invariant
-Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md).
+Every session event lives **inside** a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, an idle `agent.inject()` wraps its `user/message` in a one-shot `injection` turn, and `appendOutOfBand()` similarly wraps an eligible log-only event when no turn is open. This makes the turn the single durability/replay boundary: a backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The optional `dsh-session/invariant` companion enforces it in dev through `ctx.invariants` (a message event outside an open turn throws). See [the turn-enclosure invariant Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md).
## Plugin-contributed log-only events
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
-The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
+The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)).
## Durability contract
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index a04e633377..0825bbf348 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -8,22 +8,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
-| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
-| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
-| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
-| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:197`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
-| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
-| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
+| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:477`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
+| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
+| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
+| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
+| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:388`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) |
+| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:415`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:453`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) |
+| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index b5a3c60463..c94568f4d3 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -24,14 +24,13 @@ export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
- | 'context/message'
| 'steering/message'
/**
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
- * - `'append'`: added to the tail — normal path for user/assistant/tool/context
+ * - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -51,7 +50,7 @@ export type SurfaceOp =
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
- * `assistant/message`, `tool/result`, `context/message`, `steering/message`).
+ * `assistant/message`, `tool/result`, `steering/message`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
@@ -79,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts)
## Events
@@ -151,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv
Types: [StreamChunk](core-data-structures/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface
@@ -167,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
### `compact/*`
@@ -221,33 +220,6 @@ Types: [ContentBlock](core-data-structures/core.md)
Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts)
-### `context/*`
-
-#### `context/message` — surface
-
-```ts persistence-catalog
-/**
- * In-session context injection (file-change notices, subdir AGENTS.md,
- * skill content, cron notifications, …). Rendered into the derived history
- * as a synthetic user-role message carrying `content` verbatim — NOT a
- * user prompt. `meta` is durable JSON state omitted from the model
- * projection; it is also the intended channel for any future framing
- * directive (a producer declares the frame, a dedicated renderer applies it —
- * see the deferred note in
- * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
- * so the surface keeps projecting `content` verbatim rather than wrapping it.
- */
-'context/message': {
- content: ContentBlock[]
- source: MessageSource
- meta?: JsonValue
-}
-```
-
-Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
-
-Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts)
-
### `hook/*`
#### `hook/invoked` — log-only
@@ -357,7 +329,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/s
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts)
### `request/*`
@@ -371,7 +343,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
```
-Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -427,7 +399,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages
'steering/message': PromptMessageData & { turn: number }
```
-Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
### `step/*`
@@ -438,7 +410,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/
'step/end': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts)
#### `step/start` — log-only
@@ -447,7 +419,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts)
### `todo/*`
@@ -460,7 +432,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/
Types: [TodoItem](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)
### `tool/*`
@@ -477,7 +449,7 @@ Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/
Types: [CallId](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only
@@ -521,7 +493,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
### `turn/*`
@@ -539,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/
Types: [TurnEndReason](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only
@@ -555,15 +527,23 @@ Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/
Types: [TurnTrigger](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts)
### `user/*`
#### `user/message` — surface
```ts persistence-catalog
-/** A user-visible prompt (the queued message claimed for this turn). */
+/**
+ * A user-role message on the model-visible surface: a direct human prompt
+ * (the queued message claimed for this turn), a synthetic `agent.inject()`
+ * context (file-change notices, subdir AGENTS.md, skill content, cron
+ * notifications, …), or an admitted goal continuation round. All three
+ * project their `content` verbatim; `source` (with a non-`user` kind marking
+ * injected context) is the only channel that tells them apart. An idle
+ * injection wraps this event in a one-shot turn so the log stays turn-enclosed.
+ */
'user/message': PromptMessageData
```
-Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts)
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 5c1bfbfdf3..ded9a0973e 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -23,12 +23,12 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
-| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
+| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
-| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
+| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
| `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md
index 5fc21db2f5..46fd009ada 100644
--- a/docs/tool-execution-pipeline.md
+++ b/docs/tool-execution-pipeline.md
@@ -20,7 +20,7 @@ flowchart TD
owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"]
post["tools/post-execute waterfall
accept, block, replace, add context"]
final["tools/result synchronous notification
frozen authoritative outcome"]
- context["Active-batch additionalContexts FIFO
context/message after recorded tool results"]
+ context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"]
toolResult["Session event: tool/result
single model-facing outcome"]
allResults["Tool batch settled
recorded tool/result events complete"]
presentResult["UI completed card
presentResult(args, result)"]
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index e35d47cea0..7d014991ed 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -140,11 +140,11 @@ const SCENARIOS: Scenario[] = [
// Keyless, authored (like error-finish/cancel): deterministically forcing a
// LIVE model to repeat one call three times is not a stable recording, so
// the fixture scripts five identical todo_write calls and pins BOTH reminder
- // tiers (gentle at 3, detailed at 5) as context/message in transcript and log.
+ // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log.
{ name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
// Authored replay: a root AGENTS.md pins the session prefix, then a read in
// nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
- // context/message. Both AGENTS.md fixtures are symlinks to a sibling
+ // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling
// AGENTS.canonical.md, so this scenario also guards that discovery follows a
// symlinked instruction file to its target's content. The scenario-specific
// config keeps home/root discovery hermetic, and the resulting prefix needs
@@ -220,7 +220,7 @@ const SCENARIOS: Scenario[] = [
// tool/code-dispatch events. Each overlay composes and pins its own header class.
{ name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
// A nested fs dispatch inside run_code discovers workspace instructions. The
- // context/message must follow the outer result while retaining workspace
+ // injected user/message must follow the outer result while retaining workspace
// provenance, which proves Code Mode carries deferred tool context end to end.
{
name: 'code-mode-workspace-context',
diff --git a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl
index 10ffb8c507..cb28e3c646 100644
--- a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl
+++ b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl
@@ -688,7 +688,7 @@
{"type":"turn/start","seq":686,"time":1783421455801,"data":{"turn":4,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"approval/policy","seq":687,"time":1783421455801,"data":{"policy":"never"}}
{"type":"user/message","seq":688,"time":1783421455801,"data":{"content":[{"type":"text","text":"帮我创建一个 c.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
+{"type":"user/message","seq":689,"time":1783421455802,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
{"type":"step/start","seq":690,"time":1783421455802,"data":{"turn":4,"step":1}}
{"type":"request/header-delta","seq":691,"time":1783421455802,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":["","Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation."]}}}
{"type":"assistant/chunk","seq":692,"time":1783421456825,"data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -830,7 +830,7 @@
{"type":"turn/start","seq":828,"time":1783421478599,"data":{"turn":5,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"bash/sandbox-mode","seq":829,"time":1783421478599,"data":{"mode":"workspace-write"}}
{"type":"user/message","seq":830,"time":1783421478599,"data":{"content":[{"type":"text","text":"帮我创建一个 d.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
+{"type":"user/message","seq":831,"time":1783421478600,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"read-only\" to \"workspace-write\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
{"type":"step/start","seq":832,"time":1783421478600,"data":{"turn":5,"step":1}}
{"type":"request/header-delta","seq":833,"time":1783421478600,"data":{"system":{"keepStart":12,"keepEnd":2,"insert":["Bash commands run under the \"workspace-write\" file sandbox."]}}}
{"type":"assistant/chunk","seq":834,"time":1783421479489,"data":{"turn":5,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -1482,7 +1482,7 @@
{"type":"turn/start","seq":1480,"time":1783421524030,"data":{"turn":7,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"approval/policy","seq":1481,"time":1783421524030,"data":{"policy":"ask"}}
{"type":"user/message","seq":1482,"time":1783421524030,"data":{"content":[{"type":"text","text":"帮我在 ~ 创建一个 f.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1483,"time":1783421524030,"data":{"content":[{"type":"text","text":"The approval policy changed from \"never\" to \"ask\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"approval"}},"surfaceOp":"append"}
{"type":"step/start","seq":1484,"time":1783421524030,"data":{"turn":7,"step":1}}
{"type":"request/header-delta","seq":1485,"time":1783421524030,"data":{"system":{"keepStart":13,"keepEnd":0,"insert":[]}}}
{"type":"assistant/chunk","seq":1486,"time":1783421524940,"data":{"turn":7,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -1946,7 +1946,7 @@
{"type":"turn/start","seq":1944,"time":1783421552564,"data":{"turn":9,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"bash/sandbox-mode","seq":1945,"time":1783421552564,"data":{"mode":"danger-full-access"}}
{"type":"user/message","seq":1946,"time":1783421552564,"data":{"content":[{"type":"text","text":"创建一个 h.md"}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1947,"time":1783421552564,"data":{"content":[{"type":"text","text":"The bash sandbox mode changed from \"workspace-write\" to \"danger-full-access\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"tool-bash"}},"surfaceOp":"append"}
{"type":"step/start","seq":1948,"time":1783421552564,"data":{"turn":9,"step":1}}
{"type":"request/header-delta","seq":1949,"time":1783421552564,"data":{"system":{"keepStart":12,"keepEnd":0,"insert":["Bash commands run under the \"danger-full-access\" file sandbox."]}}}
{"type":"assistant/chunk","seq":1950,"time":1783421553289,"data":{"turn":9,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
index 8954fd6bad..d598b34e37 100644
--- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
+++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
@@ -12,7 +12,7 @@
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}}
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
-{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
+{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -49,6 +49,6 @@
{"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}}
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
-{"type":"context/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
+{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}
{"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}}
{"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}}
diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts
index 4bb01acd16..23359f9982 100644
--- a/examples/acp-agent/tests/goal.snapshot.ts
+++ b/examples/acp-agent/tests/goal.snapshot.ts
@@ -83,6 +83,7 @@ describe('ACP same-session goal snapshot', () => {
const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name)
expect(calls).toEqual(['create_goal', 'get_goal'])
const rounds = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
+ && event.data.source.round > 0
? [event.data.source.round]
: [])
expect(rounds).toEqual([1, 2])
diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
index 281970523c..29bca35a09 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
@@ -87,7 +87,7 @@
{"type":"tool/call","seq":85,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}
{"type":"tool/code-dispatch","seq":86,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}}
{"type":"tool/result","seq":87,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[85],"surfaceOp":"append"}
-{"type":"context/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"}
+{"type":"user/message","seq":88,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"}
{"type":"step/end","seq":89,"time":1783921767272,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":90,"time":1783921767272,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":91,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
index bd13d3b146..5a51f98a42 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
-{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
index 6a78b05a65..04f138d06c 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
@@ -3,7 +3,7 @@
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions extends Omit {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
index c638389319..910bdfca68 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
@@ -63,7 +63,7 @@
{"type":"hook/invoked","seq":61,"time":1783352197968,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}}
{"type":"hook/result","seq":62,"time":1783352197976,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.404540999999881}}
{"type":"tool/result","seq":63,"time":1783352197976,"data":{"turn":1,"step":1,"callId":"call_00_HbCMzTslWBZTSphWN0z97382","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
-{"type":"context/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
+{"type":"user/message","seq":64,"time":1783352197976,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
{"type":"step/end","seq":65,"time":1783352197977,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":66,"time":1783352197977,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":67,"time":1783352198981,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
index 46561a4760..921bc270d9 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
@@ -3,7 +3,7 @@
{"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}}
{"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}}
{"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
+{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"}
{"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
index 9b58a769c5..deab3ab423 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
@@ -63,7 +63,7 @@
{"type":"hook/invoked","seq":61,"time":1783352229622,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}}
{"type":"hook/result","seq":62,"time":1783352229632,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":9.27664199999981}}
{"type":"tool/result","seq":63,"time":1783352229632,"data":{"turn":1,"step":1,"callId":"call_00_Q6wHtakaip2QNfIXaVJY5458","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
-{"type":"context/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
+{"type":"user/message","seq":64,"time":1783352229633,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
{"type":"step/end","seq":65,"time":1783352229633,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":66,"time":1783352229633,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":67,"time":1783352230757,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
index c1f23a6b5e..ac319964f3 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
@@ -3,7 +3,7 @@
{"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}}
{"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}}
{"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
+{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"}
{"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
index cbb52c1b08..381458957c 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
@@ -106,7 +106,7 @@
{"type":"sandbox/mode","seq":104,"time":1784518115842,"data":{"mode":"danger-full-access"}}
{"type":"approval/policy","seq":105,"time":1783962244624,"data":{"policy":"never"}}
{"type":"user/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
-{"type":"context/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"}
+{"type":"user/message","seq":107,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"}
{"type":"step/start","seq":108,"time":1783962244624,"data":{"turn":2,"step":1}}
{"type":"request/header","seq":109,"time":1784000791271,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
{"type":"assistant/chunk","seq":110,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
index a277f2e997..1f7345dc80 100644
--- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
@@ -35,7 +35,7 @@
{"type":"tool/call","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
{"type":"todo/write","seq":34,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[33],"surfaceOp":"append"}
-{"type":"context/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
+{"type":"user/message","seq":36,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
{"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -58,7 +58,7 @@
{"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
{"type":"todo/write","seq":57,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
-{"type":"context/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
+{"type":"user/message","seq":59,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
{"type":"step/end","seq":60,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":61,"time":0,"data":{"turn":1,"step":6}}
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
index 8293ea3abf..883e685a6d 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
@@ -12,7 +12,7 @@
{"type":"assistant/message","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}
{"type":"tool/result","seq":12,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
-{"type":"context/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"}
+{"type":"user/message","seq":13,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"}
{"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts
index c7f5d48562..58256cbda4 100644
--- a/examples/headless-agent/tests/code-mode.e2e.ts
+++ b/examples/headless-agent/tests/code-mode.e2e.ts
@@ -151,7 +151,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
const events: SessionEvent[] = [...handle.agent.session.events]
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
const outerResult = events.find(event => event.type === 'tool/result')
- const workspaceContext = events.find(event => event.type === 'context/message'
+ const workspaceContext = events.find(event => event.type === 'user/message'
+ && event.data.source.kind === 'plugin'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts
index 1626489bf5..a1ce4b6cc5 100644
--- a/examples/headless-agent/tests/headless.snapshot.ts
+++ b/examples/headless-agent/tests/headless.snapshot.ts
@@ -224,7 +224,7 @@ describe('headless stream-json snapshots', () => {
.map(record => (record.data as JsonObject | undefined)?.name)
expect(calls).toEqual(['create_goal', 'get_goal'])
const goalChanges = records.filter((record) => {
- if (record.type !== 'context/message') return false
+ if (record.type !== 'user/message') return false
const data = record.data as JsonObject | undefined
const meta = data?.meta as JsonObject | undefined
return meta?.kind === 'goal/change'
diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
index 05a1282a6f..5005192ec8 100644
--- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
+++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
@@ -11,7 +11,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts
index 7d04dfe952..7d6d9de97d 100644
--- a/packages/bash/tool-bash/tests/integration.spec.ts
+++ b/packages/bash/tool-bash/tests/integration.spec.ts
@@ -168,7 +168,7 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
- it('background: start ack → completion notice as context/message → task_output collects it', async () => {
+ it('background: start ack → completion notice as user/message → task_output collects it', async () => {
// The task id is deterministic (a fresh TaskService counts per kind from 1),
// so the script can name `bash-1` without threading a generated id.
const adapter = new MockAdapter([
@@ -188,10 +188,12 @@ describe('bash tool through the agent loop', () => {
expect(resultText(firstResult)).toBe('started background task bash-1')
// The task settles on its own; the tool-tasks notice listener injects a
- // durable context/message into the owning agent's session (settlement may
- // race turn end, so poll for it).
- await pollUntil(() => events(agent).some(event => event.type === 'context/message'))
- const notice = findEvent(events(agent), 'context/message')
+ // durable plugin-sourced user/message into the owning agent's session
+ // (settlement may race turn end, so poll for it).
+ const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
+ e.type === 'user/message' && e.data.source.kind === 'plugin'
+ await pollUntil(() => events(agent).some(isNotice))
+ const notice = events(agent).find(isNotice)!
expect(notice.data.content.some(
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
)).toBe(true)
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index bbf4809466..f474f56485 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -42,7 +42,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
if (turn % 9 === 4) {
- push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
+ push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2
diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts
index ccb48a0161..d10bcea074 100644
--- a/packages/client/runtime/src/client/sessions/fold-adapter.ts
+++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts
@@ -38,6 +38,14 @@ function materializeNode(
): ConversationNode {
switch (event.type) {
case 'user/message':
+ // Injected context (plugin/goal source) folds to a context node, not a
+ // user message; only a direct human prompt is a user node.
+ if (event.data.source.kind !== 'user') {
+ return {
+ kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
+ meta: event.data.meta,
+ }
+ }
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
case 'assistant/message':
return {
@@ -46,11 +54,6 @@ function materializeNode(
}
case 'steering/message':
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
- case 'context/message':
- return {
- kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
- meta: event.data.meta,
- }
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
return {
@@ -63,7 +66,7 @@ function materializeNode(
resultView,
}
}
- /* v8 ignore next 2 -- defensive arm: fold output only carries the five
+ /* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts
index 3214c44ee9..bb360e2a67 100644
--- a/packages/client/runtime/tests/fold-adapter.spec.ts
+++ b/packages/client/runtime/tests/fold-adapter.spec.ts
@@ -40,7 +40,7 @@ describe('FoldAdapter', () => {
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
- at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
+ at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts
index 3ea658b64d..1db86d38e4 100644
--- a/packages/compact/compact-basic/tests/compact-basic.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts
@@ -563,7 +563,10 @@ describe('pressure measurement and retention', () => {
const result = await compactIfNeeded(compact, session)
expect(result).not.toBeNull()
expect(prefix).toHaveLength(1)
- expect(session.events.some(event => event.type === 'context/message')).toBe(false)
+ // The routed request prefix must not reach the surface as its own message
+ // (the compaction summary itself is an expected plugin-sourced checkpoint).
+ expect(session.events.some(event => event.type === 'user/message'
+ && event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false)
})
it('uses the latest logged request envelope without an AgentOptions override', async () => {
@@ -959,7 +962,7 @@ describe('compaction region transaction', () => {
const compact = service()
const session = conversation(2)
compact.mutateDuringSummary = () => {
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'concurrent surface mutation' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
diff --git a/packages/compact/compact/tests/tool-pairing.spec.ts b/packages/compact/compact/tests/tool-pairing.spec.ts
index 5f48e7f99f..3de0473d57 100644
--- a/packages/compact/compact/tests/tool-pairing.spec.ts
+++ b/packages/compact/compact/tests/tool-pairing.spec.ts
@@ -96,23 +96,23 @@ describe('tool-pairing boundaries', () => {
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
}, SURFACE)
- midStep.append('context/message', {
+ midStep.append('user/message', {
content: [{ type: 'text', text: 'background update' }],
source: { kind: 'plugin', plugin: 'test' },
}, SURFACE)
midStep.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
}, SURFACE)
- expect(before(midStep, 'context/message')).toBe(false)
- expect(after(midStep, 'context/message')).toBe(false)
+ expect(before(midStep, 'user/message')).toBe(false)
+ expect(after(midStep, 'user/message')).toBe(false)
const free = new Session(SessionId('neutral-free'))
- free.append('context/message', {
+ free.append('user/message', {
content: [{ type: 'text', text: 'idle injection' }],
source: { kind: 'user' },
}, SURFACE)
- expect(before(free, 'context/message')).toBe(true)
- expect(after(free, 'context/message')).toBe(true)
+ expect(before(free, 'user/message')).toBe(true)
+ expect(after(free, 'user/message')).toBe(true)
})
})
diff --git a/packages/context/session-reference/src/projection.ts b/packages/context/session-reference/src/projection.ts
index bbb2a2c739..dea29b38ee 100644
--- a/packages/context/session-reference/src/projection.ts
+++ b/packages/context/session-reference/src/projection.ts
@@ -57,7 +57,6 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
break
}
case 'tool/result':
- case 'context/message':
break
/* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
default:
diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts
index bb21cfab05..4496203dc7 100644
--- a/packages/context/session-reference/tests/session-reference.spec.ts
+++ b/packages/context/session-reference/tests/session-reference.spec.ts
@@ -61,7 +61,7 @@ function appendConversation(session: Session): void {
{ surfaceOp: 'append' },
)
session.append(
- 'context/message',
+ 'user/message',
{ content: [{ type: 'text', text: 'workspace secret' }], source: { kind: 'plugin', plugin: 'workspace' } },
{ surfaceOp: 'append' },
)
diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts
index 96ea7165af..fcf9e36efc 100644
--- a/packages/context/time-context/src/index.ts
+++ b/packages/context/time-context/src/index.ts
@@ -64,7 +64,6 @@ function precedingMessageTime(agent: Agent): number | undefined {
case 'user/message':
case 'assistant/message':
case 'tool/result':
- case 'context/message':
case 'steering/message':
return event.time
default:
@@ -79,7 +78,7 @@ function precedingMessageTime(agent: Agent): number | undefined {
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
- if (event.type === 'context/message'
+ if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
@@ -91,7 +90,7 @@ function precedingStepContextTime(agent: Agent, turn: number): number | undefine
/** Find this plugin's latest durable injection, including a shadowed surface event. */
function latestInjectionTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
- if (event.type === 'context/message'
+ if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts
index 45fdb48cba..aa8f0418dd 100644
--- a/packages/context/time-context/src/invariant.ts
+++ b/packages/context/time-context/src/invariant.ts
@@ -48,7 +48,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
/** Validate one plugin-attributed time reading against its session position and timestamp. */
function validateReading(
history: readonly SessionEvent[],
- event: SessionEvent<'context/message'>,
+ event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const [block] = event.data.content
@@ -84,7 +84,7 @@ function validateReading(
/** Validate all package-owned readings already present in one session. */
function validateSession(session: Session, fail: InvariantFailure): void {
for (const [index, event] of session.events.entries()) {
- if (event.type !== 'context/message'
+ if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== SOURCE_NAME) continue
validateReading(session.events.slice(0, index), event, fail)
@@ -97,7 +97,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
- if (event.type !== 'context/message'
+ if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== SOURCE_NAME) return
validateReading(session.events, event, fail)
diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts
index cd65f1aa3d..855303b295 100644
--- a/packages/context/time-context/tests/invariant.spec.ts
+++ b/packages/context/time-context/tests/invariant.spec.ts
@@ -17,7 +17,7 @@ async function setup(): Promise {
function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
return {
- type: 'context/message',
+ type: 'user/message',
seq: 0,
time,
data: {
@@ -56,7 +56,7 @@ function preparing(turn: number, step: number): Session {
}
function appendReading(session: Session, text: string): void {
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'time-context' },
}, { surfaceOp: 'append' })
@@ -162,7 +162,7 @@ describe('time-context invariants', () => {
it('ignores context messages owned by another package', async () => {
const ctx = await setup()
- const other = event('unrelated') as SessionEvent<'context/message'>
+ const other = event('unrelated') as SessionEvent<'user/message'>
other.data.source = { kind: 'plugin', plugin: 'other' }
expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
other.data.source = { kind: 'user' }
diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts
index 2a0c06fe51..13edcfb036 100644
--- a/packages/context/time-context/tests/time-context.e2e.ts
+++ b/packages/context/time-context/tests/time-context.e2e.ts
@@ -48,7 +48,7 @@ describe('time-context through a real headless cordis.yml', () => {
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
- const contexts = events.filter(event => event.type === 'context/message')
+ const contexts = events.filter(event => event.type === 'user/message')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)
diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts
index d1cd07207e..26bb5d7a9d 100644
--- a/packages/context/time-context/tests/time-context.spec.ts
+++ b/packages/context/time-context/tests/time-context.spec.ts
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
-import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -43,9 +43,10 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
status: 'running',
ctx: new Context(),
send() {},
+ followup() {},
steer() {},
inject(content, options) {
- session.append('context/message', {
+ session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
@@ -66,7 +67,7 @@ function openMessageTurn(session: Session, turn: number): void {
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
- if (event.type === 'context/message'
+ if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
@@ -151,8 +152,8 @@ describe('durable step context', () => {
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
])
const event = session.events.at(-1)
- expect(event?.type).toBe('context/message')
- if (event?.type !== 'context/message') throw new Error('missing time context')
+ expect(event?.type).toBe('user/message')
+ if (event?.type !== 'user/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
expect(event.surfaceOp).toBe('append')
})
@@ -230,10 +231,10 @@ describe('durable step context', () => {
const original = new Session(SessionId('seed-source'))
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
- const user = original.events.find(event => event.type === 'user/message')
- const reading = original.events.find(event => event.type === 'context/message')
+ const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
+ const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
- original.append('context/message', {
+ original.append('user/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact-basic' },
}, {
@@ -292,7 +293,7 @@ describe('durable step context', () => {
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
- ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
+ ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
await fire(ctx, agent, 1, 1)
@@ -401,7 +402,8 @@ describe('real agent-loop request history', () => {
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
- const contexts = agent.session.events.filter(event => event.type === 'context/message')
+ const contexts = agent.session.events.filter(
+ (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
const starts = agent.session.events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)
diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts
index 66b70f639d..61db3f527b 100644
--- a/packages/context/workspace-context/src/state.ts
+++ b/packages/context/workspace-context/src/state.ts
@@ -145,7 +145,7 @@ function visibleInstructionChanges(
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map()
for (const [seq, event] of agent.session.events.entries()) {
- if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue
+ if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
for (const change of changes) {
const waiting = pending.get(change.scope)
@@ -281,7 +281,7 @@ export function observeInstructionSessionEvent(
if (pending === undefined) return
switch (event.type) {
- case 'context/message': {
+ case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
const waiting = pending.get(change.scope)
diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts
index 9341eb33d0..f80ee5acde 100644
--- a/packages/context/workspace-context/tests/workspace-context.e2e.ts
+++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts
@@ -107,15 +107,15 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
await waitForIdle(live.ctx, live.agent)
const events = [...live.agent.session.events]
- const update = events.find(event => event.type === 'context/message'
+ const update = events.find(event => event.type === 'user/message'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
- expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
+ expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
- const updateText = update?.type === 'context/message'
+ const updateText = update?.type === 'user/message'
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
: ''
expect(updateText).toContain('Updated instructions from: AGENTS.md')
diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts
index c26be1a58e..c96cee8b38 100644
--- a/packages/context/workspace-context/tests/workspace-context.spec.ts
+++ b/packages/context/workspace-context/tests/workspace-context.spec.ts
@@ -175,9 +175,10 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session,
status: 'idle',
send() {},
+ followup() {},
steer() {},
inject(content, options) {
- session.append('context/message', {
+ session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.meta !== undefined ? { meta: options.meta } : {},
@@ -219,7 +220,7 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
- lastSeq = agent.session.append('context/message', {
+ lastSeq = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
@@ -971,7 +972,7 @@ describe('workspace context request injection', () => {
const second = await composeBaselinePrefix(ctx, agent)
expect(second).toEqual(first)
- expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
+ expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(derivedText(agent)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1143,7 +1144,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
- expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0)
+ expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1711,12 +1712,12 @@ describe('dynamic nested workspace context injection', () => {
agent.send([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
- expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1)
+ expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
agent.send([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
- const contexts = agent.session.events.filter(event => event.type === 'context/message')
+ const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// The aborted batch drained its accepted context before step close, so the
// retry sees durable history without producing a duplicate instruction.
expect(contexts).toHaveLength(1)
@@ -2490,10 +2491,7 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
appendAdditionalContexts(agent, first)
- const resumed = {
- ...agent,
- session: new Session(agent.session.id, [...agent.session.events], agent.session.header),
- }
+ const resumed = stubAgent(root, [...agent.session.events])
const afterResume = await ctx.tools.execute({
signal: testToolSignal,
@@ -2531,11 +2529,11 @@ describe('dynamic nested workspace context injection', () => {
await composeBaselinePrefix(ctx, resumed)
- const update = resumed.session.events.findLast(event => event.type === 'context/message')
- expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
+ const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+ expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
- expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
+ expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2681,7 +2679,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
- agent.session.append('context/message', {
+ agent.session.append('user/message', {
content: [
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
@@ -2698,12 +2696,12 @@ describe('dynamic nested workspace context injection', () => {
],
},
}, { surfaceOp: 'append' })
- agent.session.append('context/message', {
+ agent.session.append('user/message', {
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
}, { surfaceOp: 'append' })
- agent.session.append('context/message', {
+ agent.session.append('user/message', {
content: [{ type: 'text', text: 'foreign plugin context' }],
source: { kind: 'plugin', plugin: 'other' },
meta: {
@@ -3216,14 +3214,14 @@ describe('workspace context pending state', () => {
path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
}]]))
- const unrelated = agent.session.append('context/message', {
+ const unrelated = agent.session.append('user/message', {
content: [], source: { kind: 'plugin', plugin: 'other' },
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, unrelated, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const otherContext = workspaceChangeContext('other', 'other')
- const otherWorkspaceEvent = agent.session.append('context/message', {
+ const otherWorkspaceEvent = agent.session.append('user/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
@@ -3232,7 +3230,7 @@ describe('workspace context pending state', () => {
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
const context = workspaceChangeContext('pkg', 'one')
- const confirmed = agent.session.append('context/message', {
+ const confirmed = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 3b9111f558..f5872f5fcc 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -843,6 +843,27 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A step or turn errored.',
},
+ {
+ name: 'agent/inbox/dequeue',
+ mode: 'emit',
+ signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void',
+ jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param info - the claimed item\'s accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
+ summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.',
+ },
+ {
+ name: 'agent/inbox/discard',
+ mode: 'emit',
+ signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void',
+ jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
+ summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.',
+ },
+ {
+ name: 'agent/inbox/enqueue',
+ mode: 'emit',
+ signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, info: InboxItemInfo): void',
+ jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `info` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param info - the accepted content, source, contexts, steering, and wakeup facts.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
+ summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).',
+ },
{
name: 'agent/post-step',
mode: 'serial',
@@ -864,13 +885,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.',
},
- {
- name: 'agent/queued',
- mode: 'emit',
- signature: '\'agent/queued\'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void',
- jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source, contexts, and whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
- summary: 'Detached, frozen content entered the agent\'s inbox.',
- },
{
name: 'agent/request',
mode: 'waterfall',
@@ -1127,14 +1141,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
- {
- name: 'Agent',
- declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n}',
- },
- {
- name: 'AgentCancelCause',
- declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};',
- },
{
name: 'AgentFactory',
declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}',
@@ -1147,10 +1153,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}',
},
- {
- name: 'AgentStatus',
- declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
- },
{
name: 'ApprovalOutcome',
declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';',
@@ -1451,10 +1453,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}',
},
- {
- name: 'InjectOptions',
- declaration: 'export interface InjectOptions extends Omit {\n meta?: JsonValue;\n}',
- },
{
name: 'InvariantFailure',
declaration: 'export type InvariantFailure = (message: string) => never;',
@@ -1525,7 +1523,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptMessageData',
- declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}',
+ declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}',
},
{
name: 'PromptMessageEnvelope',
@@ -1627,6 +1625,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
},
+ {
+ name: 'RequestHeaderReason',
+ declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',
+ },
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}',
@@ -1659,17 +1661,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ScopeKey',
declaration: 'export type ScopeKey = object;',
},
- {
- name: 'SendOptions',
- declaration: 'export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n}',
- },
{
name: 'SessionEvent',
declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
},
{
name: 'SessionEventMap',
- declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */',
+ declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
},
{
name: 'SessionEventReadRequest',
@@ -1881,7 +1879,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SurfaceEventType',
- declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'context/message\' | \'steering/message\';',
+ declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';',
},
{
name: 'SurfaceOp',
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index 1b89f288a8..a3b834394b 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start 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.
-Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
+The unified `send()` primitive materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON record, then routes it 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`); if claimed, it is the sole ordinary message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. A running `next-step`/wakeup `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `next-step`/no-wakeup `inject()` bypasses the FIFOs and appends durable context directly: an open-turn injection uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append.
### Loop lifecycle (`loop.ts`)
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index 91efbf7782..d118fb3063 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -8,8 +8,8 @@
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
-import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
+import { Agent } from '@deepseek-ai/dsh-agent'
+import type { AgentCancelCause, AgentOptions, AgentStatus, CancelOptions, HookContext, InboxItemInfo, SendOptions } from '@deepseek-ai/dsh-agent'
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
@@ -100,7 +100,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy — plugins never need this class.
*/
-export class ReactLoopAgent implements Agent {
+export class ReactLoopAgent extends Agent {
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
readonly #inbox = new Inbox()
@@ -161,6 +161,7 @@ export class ReactLoopAgent implements Agent {
public readonly session: Session,
maxParallelToolCalls: number,
) {
+ super()
this.maxParallelToolCalls = maxParallelToolCalls
const { promise, resolve } = Promise.withResolvers()
this.disposed = promise
@@ -190,25 +191,25 @@ export class ReactLoopAgent implements Agent {
for (const resolve of waiters) resolve()
}
- private resolveSource(options?: SendOptions): MessageSource {
- return options?.source ?? { kind: 'user' }
- }
-
/**
* Accept one public message payload as a detached record. Lossless-JSON
* materialization reads every nested field once; deep freeze prevents later
* caller mutation before an inbox or deferred-injection queue drains it.
*/
- private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
- const source = this.resolveSource(options)
+ private acceptMessage(content: ContentBlock[], source: MessageSource, wakeup: boolean, options?: SendOptions): InboxMessage {
const contexts = options?.contexts ?? []
- const accepted = snapshotJsonValue({ content, source, contexts })
+ const accepted = snapshotJsonValue({ content, source, contexts, wakeup })
if (accepted === undefined) {
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
}
return deepFreeze(accepted)
}
+ /** Build the `agent/inbox/*` payload for one accepted item. */
+ private inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo {
+ return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
+ }
+
/** Detach one context before it can outlive its caller in the active-batch FIFO. */
private acceptContext(context: HookContext): HookContext {
const accepted = snapshotJsonValue(context)
@@ -225,24 +226,26 @@ export class ReactLoopAgent implements Agent {
send(content: ContentBlock[], options?: SendOptions): void {
this.assertNotDisposed()
- const accepted = this.acceptMessage(content, options)
- this.#inbox.enqueue(accepted)
- const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const
- agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
+ const target = options?.target ?? 'next-turn'
+ const wakeup = options?.wakeup ?? true
+ // next-step/no-wakeup is injection: durable context without running the model.
+ if (target === 'next-step' && !wakeup) { this.injectContext(content, options); return }
+ // next-step/wakeup is steering into the running turn; idle falls back to a
+ // woken follow-up turn (there is no active turn to attach to).
+ const steering = target === 'next-step' && this._status === 'running'
+ const source = options?.source ?? { kind: 'user' }
+ const accepted = this.acceptMessage(content, source, wakeup, options)
+ if (steering) {
+ this.#inbox.steer(accepted)
+ } else {
+ this.#inbox.enqueue(accepted, wakeup)
+ }
+ agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', this.inboxInfo(accepted, steering))
}
- steer(content: ContentBlock[], options?: SendOptions): void {
- this.assertNotDisposed()
- if (this._status !== 'running') { this.send(content, options); return }
- const accepted = this.acceptMessage(content, options)
- this.#inbox.steer(accepted)
- const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const
- agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
- }
-
- inject(content: ContentBlock[], options?: InjectOptions): void {
- this.assertNotDisposed()
- const source = this.resolveSource(options)
+ /** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
+ private injectContext(content: ContentBlock[], options?: SendOptions): void {
+ const source = options?.source ?? { kind: 'plugin', plugin: '' }
const context = {
content,
source,
@@ -257,7 +260,7 @@ export class ReactLoopAgent implements Agent {
this.deferredInjections.push(accepted)
return
}
- this.session.append('context/message', accepted, { surfaceOp: 'append' })
+ this.session.append('user/message', accepted, { surfaceOp: 'append' })
return
}
// No turn open: wrap the injection in a one-shot turn so every event stays
@@ -269,7 +272,7 @@ export class ReactLoopAgent implements Agent {
// are contained by Session and cannot create a false append failure.
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- this.session.append('context/message', context, { surfaceOp: 'append' })
+ this.session.append('user/message', context, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. A pre-commit veto
// must escape rather than being mistaken for a committed turn/end.
@@ -301,7 +304,7 @@ export class ReactLoopAgent implements Agent {
private drainDeferredInjections(): void {
const pending = this.deferredInjections.splice(0)
for (const accepted of pending) {
- this.session.append('context/message', accepted, { surfaceOp: 'append' })
+ this.session.append('user/message', accepted, { surfaceOp: 'append' })
}
}
@@ -325,10 +328,14 @@ export class ReactLoopAgent implements Agent {
}
}
- cancel(cause?: AgentCancelCause): void {
+ cancel(cause?: AgentCancelCause, options?: CancelOptions): void {
const resolvedCause = cause ?? { kind: 'user' }
+ const keepInbox = options?.keepInbox ?? false
const cancellation = this.turnCancellation
- const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
+ // keepInbox preserves pending work, so un-started items must not arm the
+ // pre-run cancel path that would otherwise drop the next queued turn.
+ const preRun = !keepInbox && cancellation === undefined
+ && (this.#inbox.hasQueued || this.#inbox.hasSteering)
if (cancellation !== undefined || preRun) {
if (preRun) this.preRunCancelled = true
// Coordination consumers must update their own state before this call
@@ -336,9 +343,18 @@ export class ReactLoopAgent implements Agent {
// contained by the fused dispatcher and cannot veto cancellation.
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
}
- // Clear work already present before abort observers run. A replacement
- // synchronously enqueued by an observer belongs to the next turn.
- this.#inbox.clear()
+ if (!keepInbox) {
+ // Snapshot before clearing so the discard notification carries the exact
+ // dropped items; a replacement synchronously enqueued by an
+ // `agent/cancel-requested` observer belongs to the next turn, not here.
+ const discarded = this.#inbox.pending()
+ // Clear work already present before abort observers run.
+ this.#inbox.clear()
+ if (discarded.length > 0) {
+ const items = discarded.map(({ message, steering }) => this.inboxInfo(message, steering))
+ agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
+ }
+ }
cancellation?.request(resolvedCause)
}
diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts
index 6c8a20e3d1..a5ddd87b1f 100644
--- a/packages/core/agent-loop/src/inbox.ts
+++ b/packages/core/agent-loop/src/inbox.ts
@@ -1,7 +1,7 @@
/**
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
- * mechanism of the loop driver — the public surface is `Agent.send()` and
- * `Agent.steer()`.
+ * mechanism of the loop driver — the public surface is `Agent.send()` and its
+ * fixed-preset aliases.
*
* @module dsh-agent-loop/inbox
*/
@@ -14,12 +14,14 @@ export interface InboxMessage {
content: ContentBlock[]
source: MessageSource
contexts: HookContext[]
+ /** Whether the item is marked to wake the driver or force a continuation. */
+ wakeup: boolean
}
/**
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
* (drained between steps of a running turn). Purely an in-memory mechanism of
- * the loop — the public surface is `Agent.send()` / `Agent.steer()`.
+ * the loop — the public surface is `Agent.send()` and its aliases.
*/
export class Inbox {
private queuedMessages: InboxMessage[] = []
@@ -37,18 +39,21 @@ export class Inbox {
}
/**
- * Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
+ * Add a message to the queued FIFO, waking a parked {@link waitForQueued}
+ * unless the item opted out. A non-waking item still runs once any woken
+ * item or later wakeup drives the parked loop.
* @param message - the message to queue for the next turn start.
+ * @param wake - whether to wake a parked idle wait (default true).
*/
- enqueue(message: InboxMessage): void {
+ enqueue(message: InboxMessage, wake = true): void {
this.queuedMessages.push(message)
- this.wakeup?.()
+ if (wake) this.wakeup?.()
}
/**
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
* drained between steps of a running turn, never by the idle wait —
- * `Agent.steer()` on an idle agent falls back to `send()` instead.
+ * `Agent.steer()` on an idle agent falls back to a woken follow-up instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {
@@ -71,6 +76,18 @@ export class Inbox {
return this.steeringMessages.splice(0)
}
+ /**
+ * Snapshot the pending items (queued then steering, FIFO order) without
+ * removing them — the discard notification's payload source.
+ * @returns the pending items paired with whether each is steering.
+ */
+ pending(): { message: InboxMessage; steering: boolean }[] {
+ return [
+ ...this.queuedMessages.map(message => ({ message, steering: false })),
+ ...this.steeringMessages.map(message => ({ message, steering: true })),
+ ]
+ }
+
/**
* Discard all pending messages (queued + steering) without delivering them —
* used by `cancel()`, which drops un-started work rather than draining it into
diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts
index 1cfd913b77..c13fa5a74f 100644
--- a/packages/core/agent-loop/src/loop.ts
+++ b/packages/core/agent-loop/src/loop.ts
@@ -10,7 +10,7 @@ import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFai
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
-import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
+import type { AgentEventDispatch, ContinuationDecision, HookContext, InboxItemInfo, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -19,9 +19,14 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
-import type { Inbox } from './inbox.ts'
+import type { Inbox, InboxMessage } from './inbox.ts'
import type { TurnCancellation } from './cancellation.ts'
+/** Build the `agent/inbox/dequeue` payload for one claimed inbox item. */
+function inboxInfo(message: InboxMessage, steering: boolean): InboxItemInfo {
+ return { content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
+}
+
/** Normalize thrown values while preserving an existing error code. */
function toError(error: unknown): RequestError {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
@@ -279,10 +284,11 @@ async function runTurn(
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
+ events.emit('agent/inbox/dequeue', inboxInfo(message, true))
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
- session.append('context/message', {
+ session.append('user/message', {
content: context.content,
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
@@ -296,6 +302,7 @@ async function runTurn(
const message = handle.inbox.dequeueQueued()
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
+ events.emit('agent/inbox/dequeue', inboxInfo(message, false))
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let reason: TurnEndReason = { kind: 'completed' }
@@ -538,7 +545,7 @@ async function runTurn(
// A continuation reason becomes next-step steering.
if (decision.action === 'continue' && decision.reason) {
- handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] })
+ handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [], wakeup: true })
}
let shouldContinue = decision.action === 'continue'
diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts
index 2cb3191f83..608f1bad60 100644
--- a/packages/core/agent-loop/tests/agent.spec.ts
+++ b/packages/core/agent-loop/tests/agent.spec.ts
@@ -139,7 +139,7 @@ describe('Agent', () => {
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
- expect(agent.session.events.at(-1)!.type).toBe('context/message')
+ expect(agent.session.events.at(-1)!.type).toBe('user/message')
// Close the turn; now inject must wrap its own one-shot injection turn.
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -151,6 +151,16 @@ describe('Agent', () => {
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
})
+ it('inject() defaults its source to an empty plugin, never user', async () => {
+ const adapter = new MockAdapter([textResponse('ok')])
+ const ctx = await harness(adapter)
+ const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
+ agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ agent.inject([{ type: 'text', text: 'no explicit source' }])
+ const injected = agent.session.events.at(-1)!
+ expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
+ })
+
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -202,7 +212,7 @@ describe('Agent', () => {
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
const types = agent.session.events.map(e => e.type)
- expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced
+ expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
await new Promise(r => setTimeout(r, 10))
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
})
diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts
index 162d3b552a..11dc77ed5d 100644
--- a/packages/core/agent-loop/tests/cancel.spec.ts
+++ b/packages/core/agent-loop/tests/cancel.spec.ts
@@ -98,6 +98,25 @@ 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')])
+ 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) })
+
+ // Queue a turn WITHOUT waking the driver, so it sits in the inbox.
+ agent.send([{ type: 'text', text: 'preserved' }], { target: 'next-turn', wakeup: false })
+ // keepInbox cancel: no active turn, work preserved, no discard event.
+ agent.cancel({ kind: 'user' }, { keepInbox: true })
+ expect(discards).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('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts
index 3a9ca87d68..3b7aa8170c 100644
--- a/packages/core/agent-loop/tests/contract-regressions.spec.ts
+++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts
@@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => {
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
- case 'context/message': order.push('context/message'); break
+ // Injected context is a plugin-sourced user/message; the direct human
+ // prompt (user source) is not tracked in this ordering.
+ case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break
case 'steering/message': order.push('steering/message'); break
case 'step/end': order.push('step/end'); break
case 'turn/end': {
@@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
+ const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
expect(events
- .filter(event => event.type === 'tool/result' || event.type === 'context/message'
+ .filter(event => event.type === 'tool/result' || isInjected(event)
|| event.type === 'step/end' || event.type === 'turn/end')
- .map(event => event.type))
+ .map(event => isInjected(event) ? 'context/message' : event.type))
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
expect(events
- .filter(event => event.type === 'context/message')
+ .filter(isInjected)
.map(event => event.data.content))
.toEqual([
[{ type: 'text', text: 'accepted before abort' }],
@@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
+ const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
expect(events
- .filter(event => event.type === 'tool/result' || event.type === 'context/message'
+ .filter(event => event.type === 'tool/result' || isInjected(event)
|| event.type === 'step/end' || event.type === 'turn/end')
- .map(event => event.type))
+ .map(event => isInjected(event) ? 'context/message' : event.type))
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
- expect(events.find(event => event.type === 'context/message')?.data.content)
+ expect(events.find(isInjected)?.data.content)
.toEqual([{ type: 'text', text: 'accepted after first result' }])
})
@@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => {
await fiber.dispose()
expect(agent.session.events
- .filter(event => event.type === 'context/message')
+ .filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
.map(event => event.data.content))
.toEqual([
[{ type: 'text', text: 'accepted before disposal' }],
@@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'start a text-only turn')
await waitForIdle(ctx, agent)
- expect(agent.session.events.find(event => event.type === 'context/message')?.data.content)
+ expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content)
.toEqual([{ type: 'text', text: 'new turn context' }])
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
})
@@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
})
- it('agent/queued carries the resolved source; steering/message records its source', async () => {
+ it('agent/inbox/enqueue carries the resolved source; steering/message records 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' })
@@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
}))
const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = []
- ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
+ ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering }))
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
await waitForIdle(ctx, agent)
@@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
let notifiedContexts: HookContext[] | undefined
- ctx.on('agent/queued', (subject, acceptedContent, info) => {
+ ctx.on('agent/inbox/enqueue', (subject, info) => {
if (subject !== agent || info.steering) return
// Retain the exact notification references: cloning here would test the
// listener's copy rather than the event/inbox ownership boundary.
- notifiedContent = acceptedContent
+ notifiedContent = info.content
notifiedSource = info.source
notifiedContexts = info.contexts
})
@@ -863,9 +867,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
let notifiedContexts: HookContext[] | undefined
- ctx.on('agent/queued', (subject, acceptedContent, info) => {
+ ctx.on('agent/inbox/enqueue', (subject, info) => {
if (subject !== agent || !info.steering) return
- notifiedContent = acceptedContent
+ notifiedContent = info.content
notifiedSource = info.source
notifiedContexts = info.contexts
})
@@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(request).not.toContain('caller-mutated-steering-context-without-meta')
const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message')
- const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message'
+ const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context')
expect(steeringIndex).toBeGreaterThanOrEqual(0)
expect(contextIndex).toBe(steeringIndex + 1)
diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts
index 7d4e79238e..700a4ac017 100644
--- a/packages/core/agent-loop/tests/coverage-edges.spec.ts
+++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts
@@ -47,7 +47,7 @@ describe('inbox acceptance', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let queued = 0
- ctx.on('agent/queued', () => { queued += 1 })
+ ctx.on('agent/inbox/enqueue', () => { queued += 1 })
expect(() => {
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts
index 99cae1ae77..91858e7050 100644
--- a/packages/core/agent-loop/tests/inbox.spec.ts
+++ b/packages/core/agent-loop/tests/inbox.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Inbox } from '../src/inbox.ts'
function message(text: string) {
- return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] }
+ return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
}
function resolverPair() {
@@ -25,6 +25,32 @@ describe('Inbox', () => {
expect(inbox.dequeueQueued()).toBeUndefined()
})
+ it('enqueue(msg, false) queues without waking a parked waiter', async () => {
+ const inbox = new Inbox()
+ let woke = false
+ const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true })
+ inbox.enqueue(message('quiet'), false)
+ // The item is queued, but the parked waiter was not resolved by it.
+ expect(inbox.hasQueued).toBe(true)
+ await Promise.resolve()
+ expect(woke).toBe(false)
+ // A later waking enqueue resolves the same waiter.
+ inbox.enqueue(message('loud'))
+ await waiter
+ expect(woke).toBe(true)
+ })
+
+ it('pending() snapshots queued then steering without removing them', () => {
+ const inbox = new Inbox()
+ inbox.enqueue(message('q'))
+ inbox.steer(message('s'))
+ const pending = inbox.pending()
+ expect(pending.map(p => p.steering)).toEqual([false, true])
+ // Snapshot does not drain the FIFOs.
+ expect(inbox.hasQueued).toBe(true)
+ expect(inbox.hasSteering).toBe(true)
+ })
+
it('pushes and drains steering messages separately from queued', () => {
const inbox = new Inbox()
inbox.steer(message('steer'))
diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts
index 42a2808170..b225e67b1c 100644
--- a/packages/core/agent-loop/tests/interception.spec.ts
+++ b/packages/core/agent-loop/tests/interception.spec.ts
@@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => {
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
- it('allow with additionalContexts injects separate context/message events into the turn', async () => {
+ it('allow with additionalContexts injects separate injected-context user messages into 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' })
@@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => {
await waitForIdle(ctx, agent)
const log = events(agent)
- const userMsg = log.find(e => e.type === 'user/message')
- const ctxMsg = log.find(e => e.type === 'context/message')
+ const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user')
+ const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(userMsg).toBeDefined()
- expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
- expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
- expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta)
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
@@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => {
}],
},
})
- expect(log.some(event => event.type === 'context/message')).toBe(false)
+ expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
role: 'user',
content: [
@@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => {
expect(log.some(e => e.type === 'turn/start')).toBe(true)
expect(log.some(e => e.type === 'turn/end')).toBe(true)
expect(log.some(e => e.type === 'user/message')).toBe(false)
- expect(log.some(e => e.type === 'context/message')).toBe(false)
expect(log.some(e => e.type === 'step/start')).toBe(false)
// the veto is recorded durably as a prompt/blocked in the open turn
const blocked = log.find(e => e.type === 'prompt/blocked')
@@ -340,8 +339,8 @@ describe('agent/session-start', () => {
// the injected context reached the model on the first (only) request
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
// and is recorded with the plugin source, never mislabeled as a user prompt
- const ctxMsg = events(agent).find(e => e.type === 'context/message')
- expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
+ const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
})
it('a throwing session-start listener does not abort agent construction', async () => {
@@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
- // Event order in the log: both tool/results, THEN both context/messages —
+ // Event order in the log: both tool/results, THEN both injected contexts —
// never interleaved (which would break tool-call/result adjacency).
- const types = events(agent).map(e => e.type)
- const firstResult = types.indexOf('tool/result')
- const lastResult = types.lastIndexOf('tool/result')
- const firstCtx = types.indexOf('context/message')
+ const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ const seqs = events(agent)
+ const firstResult = seqs.findIndex(e => e.type === 'tool/result')
+ const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result')
+ const firstCtx = seqs.findIndex(e => e === injected[0])
expect(firstResult).toBeGreaterThanOrEqual(0)
expect(lastResult).toBeGreaterThan(firstResult) // two results
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
// both contexts present
- const ctxTexts = events(agent)
- .filter(e => e.type === 'context/message')
- .flatMap(e => (e.type === 'context/message' ? e.data.content : []))
+ const ctxTexts = injected
+ .flatMap(e => (e.type === 'user/message' ? e.data.content : []))
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
- const contextEvents = events(agent).filter(e => e.type === 'context/message')
- expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
+ expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
})
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
@@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => {
const log = events(agent)
const resultIndex = log.findIndex(event => event.type === 'tool/result')
- const contextEvents = log.filter(event => event.type === 'context/message')
+ const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
- expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
+ expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'a' },
{ kind: 'plugin', plugin: 'b' },
])
- expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
+ expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
})
})
@@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const log = events(agent)
// session-start preamble injected
- expect(log.some(e => e.type === 'context/message'
+ expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
- // prompt allowed → user/message recorded
- expect(log.some(e => e.type === 'user/message')).toBe(true)
+ // prompt allowed → user-sourced user/message recorded
+ expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
// tool ran (echo allowed) and post-execute attached "audited" context
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
- expect(log.some(e => e.type === 'context/message'
+ expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
// NO hook/* events — a native plugin needs none
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts
index aa8bd5d6d5..cb0dcd2384 100644
--- a/packages/core/agent-loop/tests/invariant.spec.ts
+++ b/packages/core/agent-loop/tests/invariant.spec.ts
@@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => {
it('uses the step boundary rather than content appended afterward', async () => {
const { ctx, session, boundary } = await requestSetup()
- session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
+ session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts
index 5eaaa2ea5a..3fbb1d8958 100644
--- a/packages/core/agent-loop/tests/loop.spec.ts
+++ b/packages/core/agent-loop/tests/loop.spec.ts
@@ -380,7 +380,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
- // The idle inject records a self-contained turn (turn/start → context/message
+ // The idle inject records a self-contained turn (turn/start → user/message
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
await new Promise(r => setTimeout(r, 20))
expect(agent.status).toBe('idle')
@@ -416,8 +416,8 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
- const contextEvent = agent.session.events.find(event => event.type === 'context/message')
- expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
+ const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
+ expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta })
const requestText = JSON.stringify(adapter.requests[0]!.messages)
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
expect(requestText).not.toContain(' {
})
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
- visibleDuringTool = agent.session.events.some(e => e.type === 'context/message')
+ visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
return [{ type: 'text', text: 'ok' }]
},
}))
@@ -462,13 +462,13 @@ describe('agent loop', () => {
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 === 'context/message')
+ const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(contexts).toHaveLength(2)
expect(result.seq).toBeLessThan(contexts[0]!.seq)
- expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
+ expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({
meta,
})
- expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
+ expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : []))
.toEqual([
{ type: 'text', text: 'mid-turn notice' },
{ type: 'text', text: 'second notice' },
@@ -512,7 +512,7 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
- expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
+ expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
@@ -621,7 +621,7 @@ describe('agent loop', () => {
ctx.on('agent/pre-step', (subject) => {
if (subject === agent && !injected) {
injected = true
- subject.session.append('context/message', {
+ subject.session.append('user/message', {
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
@@ -639,7 +639,7 @@ describe('agent loop', () => {
// 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 === 'context/message')!.seq
+ 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)
})
@@ -1017,13 +1017,13 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
})
- it('keeps a reentrant agent/queued send as the next independent turn', async () => {
+ it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', 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' })
let nested = false
- ctx.on('agent/queued', (subject) => {
+ ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent || nested) return
nested = true
send(agent, 'queued listener message')
diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
index aad90a7dde..405808ffd3 100644
--- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts
+++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
@@ -118,7 +118,7 @@ describe('request stability across the loop', () => {
preStep()
const session = agent.session
const nodes = session.surface.nodes
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}, {
@@ -180,7 +180,7 @@ describe('request stability across the loop', () => {
const first = adapter.requests[0]!
// The inject landed in the log after the boundary: not in THIS request…
expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
- expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
+ expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
send(agent, 'second')
await waitForIdle(ctx, agent)
diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts
index 1e6bc14548..cb27b6b2f9 100644
--- a/packages/core/agent-loop/tests/request-recovery.spec.ts
+++ b/packages/core/agent-loop/tests/request-recovery.spec.ts
@@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
const order: string[] = []
ctx.on('session/event', (_session, event) => {
+ // Injected context is a plugin-sourced user/message; the direct human
+ // prompt (user source) stays untracked as before.
+ const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user'
if (
event.type === 'assistant/message' || event.type === 'tool/call'
- || event.type === 'tool/result' || event.type === 'context/message'
+ || event.type === 'tool/result' || isInjected
|| event.type === 'steering/message' || event.type === 'step/end'
) {
- if (!('step' in event.data) || event.data.step === 1) order.push(event.type)
+ if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type)
}
})
ctx.on('agent/post-step', (subject, turn, step, signal) => {
@@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => {
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
attempts.push(history.length)
- subject.session.append('context/message', {
+ subject.session.append('user/message', {
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
source: { kind: 'plugin', plugin: 'test-recovery' },
}, { surfaceOp: 'append' })
@@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => {
const ends = agent.session.events.filter(event => event.type === 'step/end')
expect(starts.map(event => event.data.step)).toEqual([1, 2])
expect(ends.map(event => event.data.step)).toEqual([1, 2])
- const recovery = agent.session.events.find(event => event.type === 'context/message')!
+ const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')!
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
})
diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts
index 35985c9185..9b95c825b0 100644
--- a/packages/core/agent-loop/tests/tool-calls.spec.ts
+++ b/packages/core/agent-loop/tests/tool-calls.spec.ts
@@ -403,11 +403,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
await waitForIdle(ctx, agent)
const log = events(agent)
- const contextTexts = log.filter(e => e.type === 'context/message')
- .map(e => (e.data.content[0] as { text: string }).text)
+ const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
+ .map(e => ((e.data as { content: { text: string }[] }).content[0]!).text)
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
const lastResult = log.findLastIndex(e => e.type === 'tool/result')
- const firstContext = log.findIndex(e => e.type === 'context/message')
+ const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
expect(lastResult).toBeLessThan(firstContext)
})
@@ -544,10 +544,11 @@ describe('tool-call scheduler: abort handling', () => {
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
])
- const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
+ 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', 'context/message', 'context/message'])
- expect(settled.filter(e => e.type === 'context/message')
+ .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(['ctx-c1', 'ctx-c2'])
})
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index b040d8cbdc..06a7e618f9 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
-`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
+`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
@@ -56,10 +56,11 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
-- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
-- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
-- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
-- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
+- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
+- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
+- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
+- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
+- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
@@ -107,6 +108,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
-- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
+- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).
diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts
index 1902f3e746..a5a7725707 100644
--- a/packages/core/agent/src/invariant.ts
+++ b/packages/core/agent/src/invariant.ts
@@ -24,6 +24,27 @@ const install: InvariantInstaller = (ctx, fail) => {
}
lastStatus.set(agent, status)
}, { global: true })
+
+ // Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped
+ // (discard) only after it entered (enqueue), so the live outstanding count
+ // per agent can never go negative. Injection bypasses the FIFOs entirely and
+ // never appears on these events.
+ const outstanding = new WeakMap()
+ ctx.on('agent/inbox/enqueue', (agent) => {
+ outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1)
+ }, { global: true })
+ ctx.on('agent/inbox/dequeue', (agent) => {
+ const count = outstanding.get(agent) ?? 0
+ if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue')
+ outstanding.set(agent, count - 1)
+ }, { global: true })
+ ctx.on('agent/inbox/discard', (agent, items) => {
+ const count = outstanding.get(agent) ?? 0
+ if (items.length > count) {
+ fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`)
+ }
+ outstanding.set(agent, count - items.length)
+ }, { global: true })
}
/**
diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts
index dc78d76ef5..f04c999cb6 100644
--- a/packages/core/agent/src/types.ts
+++ b/packages/core/agent/src/types.ts
@@ -26,10 +26,33 @@ export interface AgentOptions {
}
/**
- * Message options. An omitted source attests direct human input as `{ kind: 'user' }`
- * and may authorize policy consumers, so non-human producers must label their content.
+ * Which inbox queue a {@link Agent.send} item joins:
+ * - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
+ * - `next-step` — the item joins the active turn between steps as steering,
+ * or, when no turn is active, is promoted per its `wakeup` flag.
+ */
+export type SendTarget = 'next-turn' | 'next-step'
+
+/**
+ * Options for the unified {@link Agent.send} primitive over the
+ * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
+ * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
+ * {@link Agent.inject} (`next-step`/no-wakeup).
+ *
+ * An omitted source attests direct human input as `{ kind: 'user' }` and may
+ * authorize policy consumers, so non-human producers must label their content.
*/
export interface SendOptions {
+ /** Queue the item joins; defaults to `next-turn`. */
+ target?: SendTarget
+ /**
+ * Whether this item makes the model run: wake a parked driver (`next-turn`)
+ * or force a continuation step (`next-step` while running). Defaults to
+ * `true`. A `false` `next-turn` item queues without waking; a `false`
+ * `next-step` item attaches durable context without forcing another step
+ * (the injection preset).
+ */
+ wakeup?: boolean
source?: MessageSource
/**
* Model-facing contexts captured with this inbox item. A queued prompt exposes
@@ -37,19 +60,44 @@ export interface SendOptions {
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
+ /** Opaque JSON state retained on the durable message but hidden from the model. */
+ meta?: JsonValue
}
-/** Options specific to durable synthetic context injection. */
-export interface InjectOptions extends Omit {
- /** Opaque JSON state retained in the session event but hidden from the model. */
- meta?: JsonValue
+/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
+export type AliasSendOptions = Omit
+
+/**
+ * The resolved facts of one inbox FIFO item, carried by the `agent/inbox/*`
+ * live events. Source defaults are already applied, so these are the exact
+ * values the item was accepted with. `steering` is true for a `next-step`
+ * item drained between steps; a `next-turn` item is claimed at a turn boundary.
+ */
+export interface InboxItemInfo {
+ content: ContentBlock[]
+ source: MessageSource
+ contexts: HookContext[]
+ /** Whether the item joined the steering FIFO (`next-step`) rather than the queued FIFO. */
+ steering: boolean
+ /** Whether the item is marked to wake the driver or force a continuation. */
+ wakeup: boolean
+}
+
+/** Options for {@link Agent.cancel}. */
+export interface CancelOptions {
+ /**
+ * Preserve queued and steering inbox items instead of discarding them. The
+ * active turn is still aborted, but un-started and pending work survives for a
+ * later turn and no `agent/inbox/discard` fires.
+ */
+ keepInbox?: boolean
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (the driver is draining
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
- * transition leaves it, and `send`/`steer`/`inject` throw).
+ * transition leaves it, and `send`/`followup`/`steer`/`inject` throw).
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
@@ -58,8 +106,8 @@ export interface HookContext {
content: ContentBlock[]
source: MessageSource
/**
- * Model placement. Absent or `separate` records an independent
- * `context/message`; `prompt-prefix` prepends this context and a stable
+ * Model placement. Absent or `separate` records an independent injected
+ * `user/message`; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
@@ -109,58 +157,100 @@ export type AgentCancelCause =
/** Runtime reason carried by the signal that controls one live turn. */
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
-/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
-export interface Agent {
+/**
+ * Public agent handle; its concrete implementation is internal to
+ * `@deepseek-ai/dsh-agent-loop`. An abstract class rather than an interface so
+ * the fixed-preset aliases ({@link Agent.followup}, {@link Agent.steer},
+ * {@link Agent.inject}) are shared concrete delegates over the single abstract
+ * {@link Agent.send} primitive; concrete drivers implement `send` once.
+ */
+export abstract class Agent {
/** The single identity shared with {@link session}. */
- readonly id: SessionId
- readonly options: AgentOptions
- readonly session: Session
- readonly status: AgentStatus
+ abstract readonly id: SessionId
+ /** The provider route and model this agent's requests use. */
+ abstract readonly options: AgentOptions
+ /** The live session this agent drives; its log is the durable source of truth. */
+ abstract readonly session: Session
+ /** The current lifecycle state, mirrored on every `agent/status` transition. */
+ abstract readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
- readonly ctx: Context
+ abstract readonly ctx: Context
/**
- * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
- * ordinary message in its FIFO-ordered turn; the next claimed item waits for
- * that turn's checkpoint.
+ * The unified delivery primitive over the (`target` × `wakeup`) matrix.
+ * Detaches, validates, and freezes one lossless-JSON item, then routes it:
+ *
+ * - `next-turn` (default) queues an item that becomes the sole ordinary
+ * message of its own FIFO-ordered turn; `wakeup` (default `true`) wakes a
+ * parked driver, while `wakeup:false` queues without waking.
+ * - `next-step` with `wakeup:true` submits steering into the active turn
+ * (idle falls back to a woken `next-turn`).
+ * - `next-step` with `wakeup:false` injects durable model-facing context
+ * without running the model: an open turn joins at the current log position
+ * (deferred behind an executing tool batch until it settles), and an idle
+ * inject records a one-shot turn with its own durability checkpoint.
+ *
* Attached contexts share the same snapshot and ownership boundary. Invalid
- * input throws synchronously before notification or enqueue.
+ * input throws synchronously before any notification, enqueue, or append.
+ * @param content - the model-facing content blocks to deliver.
+ * @param options - target queue, wakeup decision, source, contexts, and meta.
*/
- send(content: ContentBlock[], options?: SendOptions): void
+ abstract send(content: ContentBlock[], options?: SendOptions): void
/**
- * Submit steering while the agent is `running`. An open turn records it at
- * the next steering checkpoint before a request or continuation decision;
- * policy may stop before another step. After turn close and its checkpoint,
- * any remainder is queued for a later turn; terminal `agent/turn-stop`,
- * cancellation, or disposal may discard it. Uses the same synchronous
- * snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
- */
- steer(content: ContentBlock[], options?: SendOptions): void
-
- /**
- * Append detached model-facing context without running the model. An open-turn
- * injection joins at the current log position unless the current tool batch is
- * executing; then it waits FIFO until that batch settles and drains before turn
- * close even when interrupted. Idle injection uses a one-shot turn and durability
- * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`.
- */
- inject(content: ContentBlock[], options?: InjectOptions): void
-
- /**
- * Clear all queued and steering work, including items waiting to start, and
- * abort the active turn. An effective call first emits
- * `agent/cancel-requested` with the resolved typed cause. The first cause wins
- * for the active turn, and `whenIdle()` resolves after cancellation reaches
- * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
- * and does not arm later work. The active turn snapshots and freezes the cause.
+ * Clear queued and steering work — unless `keepInbox` — and abort the active
+ * turn. An effective call first emits `agent/cancel-requested` with the
+ * resolved typed cause. The first cause wins for the active turn, and
+ * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
+ * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
+ * later work. The active turn snapshots and freezes the cause.
* @param cause - the stable caller intent carried by the current turn signal.
+ * @param options - cancellation options; `keepInbox` preserves pending work.
*/
- cancel(cause?: AgentCancelCause): void
+ abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
- whenIdle(): Promise
+ abstract whenIdle(): Promise
+ /**
+ * Queue an ordinary follow-up turn and wake the driver — the
+ * `next-turn`/wakeup preset of {@link send}. The item becomes the sole
+ * ordinary message of its own turn.
+ * @param content - the prompt content blocks.
+ * @param options - source and attached contexts.
+ */
+ followup(content: ContentBlock[], options?: AliasSendOptions): void {
+ this.send(content, { ...options, target: 'next-turn', wakeup: true })
+ }
+
+ /**
+ * Submit steering into the running turn — the `next-step`/wakeup preset of
+ * {@link send}. An open turn records it at the next steering checkpoint before
+ * a request or continuation decision; policy may stop before another step.
+ * After turn close and its checkpoint, any remainder is queued for a later
+ * turn; terminal `agent/turn-stop`, cancellation, or disposal may discard it.
+ * Idle steering falls back to a woken follow-up turn.
+ * @param content - the steering content blocks.
+ * @param options - source and attached contexts.
+ */
+ steer(content: ContentBlock[], options?: AliasSendOptions): void {
+ this.send(content, { ...options, target: 'next-step', wakeup: true })
+ }
+
+ /**
+ * Append detached model-facing context without running the model — the
+ * `next-step`/no-wakeup preset of {@link send}. An open-turn injection joins
+ * at the current log position unless the current tool batch is executing;
+ * then it waits FIFO until that batch settles and drains before turn close
+ * even when interrupted. Idle injection uses a one-shot turn and durability
+ * checkpoint. Disposal awaits idle checkpoints; flush failures report through
+ * `agent/error`. An omitted source defaults to `{ kind: 'plugin', plugin: '' }`.
+ * @param content - the injected context content blocks.
+ * @param options - source and durable model-hidden meta.
+ */
+ inject(content: ContentBlock[], options?: AliasSendOptions): void {
+ this.send(content, { ...options, target: 'next-step', wakeup: false })
+ }
}
declare module 'cordis' {
@@ -196,15 +286,37 @@ declare module 'cordis' {
*/
'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void
/**
- * Detached, frozen content entered the agent's inbox. Source defaults have
- * already been applied, so these are the exact values retained for the log.
- * @param agent - the agent whose inbox received the message.
- * @param content - the accepted content blocks retained by the inbox.
- * @param info - the accepted source, contexts, and whether it entered as steering.
+ * A detached, frozen item entered the agent's inbox (queued or steering
+ * FIFO). Source defaults are already applied, so `info` holds the exact
+ * accepted values. This is the enqueue-time live signal; the durable record
+ * is the eventual `user/message`/`steering/message`. Injection
+ * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.
+ * @param agent - the agent whose inbox received the item.
+ * @param info - the accepted content, source, contexts, steering, and wakeup facts.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
- 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; contexts: HookContext[]; steering: boolean }): void
+ 'agent/inbox/enqueue'(this: Scoped, agent: Agent, info: InboxItemInfo): void
+ /**
+ * The driver claimed one item out of the inbox: a queued item at a turn
+ * boundary, or steering drained between steps. Fires after the item leaves
+ * its FIFO and before it becomes a durable message.
+ * @param agent - the agent whose inbox item was claimed.
+ * @param info - the claimed item's accepted content, source, contexts, steering, and wakeup facts.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+ 'agent/inbox/dequeue'(this: Scoped, agent: Agent, info: InboxItemInfo): void
+ /**
+ * `cancel()` (without `keepInbox`) dropped pending inbox items without
+ * delivering them. Fires once per effective clearing call with every
+ * discarded item, after `agent/cancel-requested` and before the abort.
+ * @param agent - the agent whose inbox was cleared.
+ * @param items - the discarded items in FIFO order (queued then steering); empty when nothing was pending.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+ 'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItemInfo[]): void
/**
* Effective broad cancellation was requested, before queued/steering work
* is cleared or the active turn is aborted. This observe-only notification
diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts
index 509ad14a22..d157eeab57 100644
--- a/packages/core/agent/tests/agent.spec.ts
+++ b/packages/core/agent/tests/agent.spec.ts
@@ -3,26 +3,28 @@ import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
+ Agent,
agentEvents,
agentInterruptReasonOf,
} from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
+import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
-function stubAgent(rawId: string): Agent {
+function stubAgent(rawId: string, overrides: Partial = {}): Agent {
const id = SessionId(rawId)
- return {
+ // Agent is an abstract class, so its alias methods live on the prototype and
+ // object spread would drop them; build the full literal and merge overrides.
+ return Object.assign(Object.create(Agent.prototype) as Agent, {
id,
options: {},
session: new Session(id),
status: 'idle',
ctx: new Context(),
send() {},
- steer() {},
- inject() {},
cancel() {},
whenIdle() { return Promise.resolve() },
- }
+ ...overrides,
+ })
}
describe('AgentRegistry', () => {
@@ -56,7 +58,7 @@ describe('AgentRegistry', () => {
it('rejects an agent whose registry and session identities differ', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) }
+ const agent = stubAgent('agent-id', { session: new Session(SessionId('session-id')) })
expect(() => ctx.agents.enter(agent, undefined))
.toThrow('agent id "agent-id" does not match session id "session-id"')
diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts
index 3c0d147b9a..b850e8743a 100644
--- a/packages/core/agent/tests/invariant.spec.ts
+++ b/packages/core/agent/tests/invariant.spec.ts
@@ -56,3 +56,41 @@ describe('agent status invariants', () => {
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
})
})
+
+describe('agent inbox invariants', () => {
+ const info = (steering: boolean) => ({ content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true })
+
+ it('accepts a dequeue and a discard covered by prior enqueues', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('i1')
+ const at = scopeTarget(agent, agent)
+ expect(() => {
+ ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
+ ctx.emit(at, 'agent/inbox/enqueue', agent, info(true))
+ ctx.emit(at, 'agent/inbox/dequeue', agent, info(false))
+ ctx.emit(at, 'agent/inbox/discard', agent, [info(true)])
+ }).not.toThrow()
+ })
+
+ it('rejects a dequeue with no outstanding item', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('i2')
+ expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) })
+ .toThrow(/without a matching prior enqueue/)
+ })
+
+ it('rejects a discard larger than the outstanding count', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('i3')
+ const at = scopeTarget(agent, agent)
+ ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
+ expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) })
+ .toThrow(/dropped 2 items but only 1 were outstanding/)
+ })
+
+ it('accepts an empty discard against a fresh agent', async () => {
+ const ctx = await setup()
+ const agent = mockAgent('i4')
+ expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/discard', agent, []) }).not.toThrow()
+ })
+})
diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts
index 5988b58145..a12b0a513e 100644
--- a/packages/core/scope/src/scoped-events.generated.ts
+++ b/packages/core/scope/src/scoped-events.generated.ts
@@ -12,10 +12,12 @@ const scopedSubjectResolvers: Readonly args[0],
'agent/disposed': args => args[0],
'agent/error': args => args[0],
+ 'agent/inbox/dequeue': args => args[0],
+ 'agent/inbox/discard': args => args[0],
+ 'agent/inbox/enqueue': args => args[0],
'agent/post-step': args => args[0],
'agent/pre-step': args => args[0],
'agent/prompt-submit': args => args[0],
- 'agent/queued': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-prefix': args => args[0],
diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts
index 2d93bcddc5..bcb3944cbd 100644
--- a/packages/core/scope/tests/invariant.spec.ts
+++ b/packages/core/scope/tests/invariant.spec.ts
@@ -42,7 +42,9 @@ describe('scoped-dispatch invariants', () => {
'agent/created': [agent],
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
- 'agent/queued': [agent, [], { source: { kind: 'user' }, contexts: [], steering: false }],
+ 'agent/inbox/enqueue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }],
+ 'agent/inbox/dequeue': [agent, { content: [], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true }],
+ 'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'],
'agent/pre-step': [agent, 1, 1, signal],
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index c574174a20..958c226767 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
-`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
+A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
### Session event vocabulary (`types.ts`)
@@ -97,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
-The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
+The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index 9c5831bb18..5e4320f046 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -532,10 +532,10 @@ export class Session {
// trace/replay data.
switch (event.type) {
- // Injected context, ordinary prompts, and mid-turn steering project
+ // Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. A prompt envelope is model-hidden display metadata; its
- // prefix bytes are already present in content. context's `source`/`meta`
+ // prefix bytes are already present in content. The message's `source`/`meta`
// and steering's `turn` are also log-only. Do NOT
// re-add per-type framing (e.g. ``/``) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
@@ -544,7 +544,6 @@ export class Session {
// verbatim pass-through. See the deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message':
- case 'context/message':
case 'steering/message': {
return { role: 'user', content: event.data.content }
}
diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts
index 9095c8388b..fc275129f9 100644
--- a/packages/core/session/src/surface.ts
+++ b/packages/core/session/src/surface.ts
@@ -15,14 +15,13 @@ const SURFACE_EVENT_TYPES = new Set([
'user/message',
'assistant/message',
'tool/result',
- 'context/message',
'steering/message',
])
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
- * @returns true for one of the five message-producing event types.
+ * @returns true for one of the four message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 37c174ea12..3983034951 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -84,11 +84,12 @@ export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
- * was idle. The loop wraps the injected `context/message` in a one-shot turn
- * (`turn/start` → `context/message` → `turn/end`) so every event in the log
- * stays turn-enclosed — the durability/replay boundary is the turn, and a
- * bare event between turns would otherwise be indistinguishable from a crash
- * tail on reload.
+ * was idle. The loop wraps the injected `user/message` (a non-`user` source,
+ * plugin by default) in a one-shot turn (`turn/start` → `user/message` →
+ * `turn/end`) so every event in the log stays turn-enclosed — the
+ * durability/replay boundary is the turn, and a bare event between turns would
+ * otherwise be indistinguishable from a crash tail on reload. The trigger's
+ * `source` mirrors that message's producer.
*/
injection: { kind: 'injection'; source: MessageSource }
}
@@ -201,7 +202,13 @@ export interface PromptMessageEnvelope {
prefixContexts: PromptPrefixContext[]
}
-/** Shared payload for ordinary and steering prompt messages. */
+/**
+ * Shared payload for user, injected-context, and steering prompt messages. A
+ * direct human prompt, a synthetic `agent.inject()` context, and mid-turn
+ * steering all project into the model transcript as verbatim user-role content;
+ * they are told apart by `source` (a non-`user` kind marks injected context),
+ * not by event type. `meta` carries durable model-hidden producer state.
+ */
export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
content: ContentBlock[]
@@ -209,6 +216,15 @@ export interface PromptMessageData {
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
+ /**
+ * Opaque durable JSON state retained on the event but hidden from the model
+ * projection. It is the intended channel for a future framing directive (a
+ * producer declares the frame, a dedicated renderer applies it — see the
+ * deferred note in
+ * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
+ * so the surface keeps projecting `content` verbatim rather than wrapping it.
+ */
+ meta?: JsonValue
}
/**
@@ -236,29 +252,21 @@ export interface SessionEventMap {
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
- /** A user-visible prompt (the queued message claimed for this turn). */
+ /**
+ * A user-role message on the model-visible surface: a direct human prompt
+ * (the queued message claimed for this turn), a synthetic `agent.inject()`
+ * context (file-change notices, subdir AGENTS.md, skill content, cron
+ * notifications, …), or an admitted goal continuation round. All three
+ * project their `content` verbatim; `source` (with a non-`user` kind marking
+ * injected context) is the only channel that tells them apart. An idle
+ * injection wraps this event in a one-shot turn so the log stays turn-enclosed.
+ */
'user/message': PromptMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
- /**
- * In-session context injection (file-change notices, subdir AGENTS.md,
- * skill content, cron notifications, …). Rendered into the derived history
- * as a synthetic user-role message carrying `content` verbatim — NOT a
- * user prompt. `meta` is durable JSON state omitted from the model
- * projection; it is also the intended channel for any future framing
- * directive (a producer declares the frame, a dedicated renderer applies it —
- * see the deferred note in
- * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
- * so the surface keeps projecting `content` verbatim rather than wrapping it.
- */
- 'context/message': {
- content: ContentBlock[]
- source: MessageSource
- meta?: JsonValue
- }
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -321,7 +329,6 @@ export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
- | 'context/message'
| 'steering/message'
/**
@@ -339,7 +346,7 @@ export type SurfaceEvent = SessionEvent & { surfaceOp: Surface
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
- * - `'append'`: added to the tail — normal path for user/assistant/tool/context
+ * - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -374,7 +381,7 @@ export interface SurfaceIntent {
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
- * `assistant/message`, `tool/result`, `context/message`, `steering/message`).
+ * `assistant/message`, `tool/result`, `steering/message`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts
index 96d1f7048c..c2ff24936b 100644
--- a/packages/core/session/tests/derived-cache.spec.ts
+++ b/packages/core/session/tests/derived-cache.spec.ts
@@ -38,7 +38,7 @@ describe('derived-message cache', () => {
expect(beforeReplace).toHaveLength(2)
const nodes = session.surface.nodes
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts
index d880153dd3..132a250a97 100644
--- a/packages/core/session/tests/session.spec.ts
+++ b/packages/core/session/tests/session.spec.ts
@@ -62,7 +62,7 @@ describe('Session', () => {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
@@ -82,7 +82,7 @@ describe('Session', () => {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
@@ -117,9 +117,9 @@ describe('Session', () => {
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
- it('renders context and steering messages as plain user content', () => {
+ it('renders injected-context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}, { surfaceOp: 'append' })
@@ -172,7 +172,7 @@ describe('Session', () => {
version: 1,
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
}
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta,
@@ -183,7 +183,7 @@ describe('Session', () => {
content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }],
}])
const event = session.events[0]
- expect(event?.type === 'context/message' && event.data.meta).toEqual(meta)
+ expect(event?.type === 'user/message' && event.data.meta).toEqual(meta)
})
it('replays identically from a seeded event log', () => {
diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts
index b7cbe11b11..cfb84f4e18 100644
--- a/packages/core/session/tests/surface.spec.ts
+++ b/packages/core/session/tests/surface.spec.ts
@@ -444,9 +444,9 @@ describe('deriveMessages with surface', () => {
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
})
- it('context/message and steering/message appear on surface', () => {
+ it('injected-context and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
- s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
+ s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
@@ -524,7 +524,6 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType('user/message')).toBe(true)
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
expect(isSurfaceEligibleType('tool/result')).toBe(true)
- expect(isSurfaceEligibleType('context/message')).toBe(true)
expect(isSurfaceEligibleType('steering/message')).toBe(true)
expect(isSurfaceEligibleType('turn/start')).toBe(false)
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
@@ -568,7 +567,7 @@ describe('SurfaceManager.replaceGeneration', () => {
expect(s.surface.replaceGeneration).toBe(0)
const nodes = s.surface.nodes
- s.append('context/message', {
+ s.append('user/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)
diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts
index e477924f3f..874ef4f91c 100644
--- a/packages/examples/cli-demo/tests/cli.spec.ts
+++ b/packages/examples/cli-demo/tests/cli.spec.ts
@@ -365,7 +365,7 @@ describe('runOneShot and executeCli', () => {
const { ctx, agent } = await harness([textResponse('streamed')])
const other = ctx.sessions.create(SessionId('unrelated'))
let injected = false
- ctx.on('agent/queued', (subject) => {
+ ctx.on('agent/inbox/enqueue', (subject) => {
if (subject !== agent || injected) return
injected = true
agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -379,7 +379,7 @@ describe('runOneShot and executeCli', () => {
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
- expect(events.some(event => event.type === 'context/message')).toBe(false)
+ expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
})
it('emits partial data and a diagnostic for non-completed turns', async () => {
@@ -477,7 +477,7 @@ describe('runOneShot and executeCli', () => {
const queued = await harness([textResponse('unused')])
const queuedAbort = new AbortController()
- queued.ctx.on('agent/queued', (agent) => {
+ queued.ctx.on('agent/inbox/enqueue', (agent) => {
if (agent === queued.agent) queuedAbort.abort('cancel queued')
})
await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued')
diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts
index 35994ecb71..6ea767e091 100644
--- a/packages/goal/command-goal/tests/command-goal.spec.ts
+++ b/packages/goal/command-goal/tests/command-goal.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
@@ -26,11 +26,11 @@ function nextTurn(session: Session): number {
}
/** Append one idle injection using the public Agent contract's balanced shape. */
-function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
- const source: MessageSource = options?.source ?? { kind: 'user' }
+function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void {
+ const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- session.append('context/message', {
+ session.append('user/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
@@ -49,6 +49,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
ctx: new Context(),
get status() { return status },
send() {},
+ followup() {},
steer() {},
inject(content, options) { appendInjection(session, content, options) },
cancel() { status = 'idle' },
@@ -125,7 +126,7 @@ describe('/goal human command', () => {
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
- expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
+ expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
const count = test.session.events.length
await expect(run(test, ' replacement')).resolves.toEqual({
diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts
index bcb3fc2a75..a4883a0f7c 100644
--- a/packages/goal/goal-session/src/index.ts
+++ b/packages/goal/goal-session/src/index.ts
@@ -306,10 +306,10 @@ export function apply(ctx: Context): void {
requestDrive(state)
}
})
- ctx.on('agent/queued', (agent, content, info) => {
+ ctx.on('agent/inbox/enqueue', (agent, info) => {
const state = stateFor(agent)
const attempt = state.attempt
- if (attempt !== undefined && sameQueued(content, info.source, attempt)) return
+ if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
})
diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts
index f204893578..e323180e88 100644
--- a/packages/goal/goal-session/tests/goal-session.spec.ts
+++ b/packages/goal/goal-session/tests/goal-session.spec.ts
@@ -207,7 +207,9 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(2)
const rounds: number[] = []
for (const event of test.agent.session.events) {
- if (event.type === 'user/message' && event.data.source.kind === 'goal') {
+ // Round zero is a durable goal state change; positive rounds are the
+ // admitted continuation prompts this test counts.
+ if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) {
rounds.push(event.data.source.round)
}
}
@@ -287,7 +289,7 @@ describe('same-session goal driving', () => {
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
const test = await harness([])
- const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
+ const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.source.kind === 'goal') {
cancel()
agent.cancel({ kind: 'user' })
@@ -299,8 +301,10 @@ describe('same-session goal driving', () => {
expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' })
expect(test.adapter.requests).toHaveLength(0)
+ // No admitted continuation round (positive round); goal state changes
+ // (round zero) are expected in the log.
expect(test.agent.session.events.some(event => event.type === 'user/message'
- && event.data.source.kind === 'goal')).toBe(false)
+ && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false)
})
it('pauses an admitted round when cancellation aborts an active step', async () => {
@@ -334,7 +338,7 @@ describe('same-session goal driving', () => {
const warnings: string[] = []
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
let inserted = false
- test.ctx.on('agent/queued', (agent, _content, info) => {
+ test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
inserted = true
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
@@ -357,7 +361,7 @@ describe('same-session goal driving', () => {
it('makes a reserved round stale when a listener queues human work behind it', async () => {
const test = await harness([textResponse('human batch'), textResponse('later goal')])
let inserted = false
- test.ctx.on('agent/queued', (agent, _content, info) => {
+ test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
inserted = true
agent.send([{ type: 'text', text: 'human joined the pending batch' }])
@@ -375,7 +379,7 @@ describe('same-session goal driving', () => {
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
const test = await harness([textResponse('new revision')])
let edited = false
- test.ctx.on('agent/queued', (agent, _content, info) => {
+ test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || edited) return
edited = true
const current = test.ctx.goals.get(agent)
@@ -391,7 +395,7 @@ describe('same-session goal driving', () => {
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
.toBe('stale goal-round reservation')
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
- && event.data.source.kind === 'goal')
+ && event.data.source.kind === 'goal' && event.data.source.round > 0)
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
? admitted.data.source.revision
: undefined).toBe(2)
@@ -477,8 +481,14 @@ describe('same-session goal driving', () => {
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
const test = await harness([])
- vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
- throw new Error('queue rejected')
+ // inject shares send, so reject only the round send (a goal-sourced
+ // next-turn item), not the goal state-change injection that precedes it.
+ const realSend = test.agent.send.bind(test.agent)
+ vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
+ if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') {
+ throw new Error('queue rejected')
+ }
+ realSend(content, options)
})
test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
@@ -494,9 +504,13 @@ describe('same-session goal driving', () => {
it('preserves a custom agent side effect when send disarms before throwing', async () => {
const test = await harness([])
- vi.spyOn(test.agent, 'send').mockImplementationOnce(() => {
- test.ctx.goals.disarm(test.agent)
- throw new Error('queue rejected after disarm')
+ const realSend = test.agent.send.bind(test.agent)
+ vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
+ if (options?.source?.kind === 'goal' && (options.target ?? 'next-turn') === 'next-turn') {
+ test.ctx.goals.disarm(test.agent)
+ throw new Error('queue rejected after disarm')
+ }
+ realSend(content, options)
})
test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
@@ -554,7 +568,7 @@ describe('same-session goal driving', () => {
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
const test = await harness([textResponse('retry after containment')])
let armed = true
- test.ctx.on('agent/queued', (agent, _content, info) => {
+ test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
@@ -635,7 +649,7 @@ describe('same-session goal driving', () => {
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
const test = await harness([])
- const cancel = test.ctx.on('agent/queued', (agent, _content, info) => {
+ const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.source.kind !== 'goal') return
cancel()
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
@@ -689,7 +703,7 @@ describe('same-session goal driving', () => {
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
const test = await harness([])
let unloading: Promise | undefined
- test.ctx.on('agent/queued', (agent, _content, info) => {
+ test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) {
unloading = Promise.resolve(test.driver.dispose())
}
diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts
index 19200747e8..0427a41333 100644
--- a/packages/goal/goal-session/tests/invariant.spec.ts
+++ b/packages/goal/goal-session/tests/invariant.spec.ts
@@ -40,7 +40,7 @@ function view(roundsStarted: number): GoalView {
function appendChange(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
- session.append('context/message', {
+ session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
@@ -126,7 +126,7 @@ describe('goal-session prompt invariants', () => {
it('attributes an invalid durable prefix during late loading', async () => {
const { ctx, session } = await mount(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'counterfeit goal state' }],
source: changeSource,
meta: change as never,
diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts
index fe88ebcdba..2ff756249f 100644
--- a/packages/goal/goal/src/fold.ts
+++ b/packages/goal/goal/src/fold.ts
@@ -17,7 +17,7 @@ import type {
GoalSnapshotChangeMeta,
} from './types.ts'
-type ContextMessageEvent = Extract
+type UserMessageEvent = Extract
const SNAPSHOT_OPERATIONS: ReadonlySet> = new Set([
'create',
@@ -310,17 +310,18 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
}
/**
- * Decode and verify one model-visible goal context event without folding it.
- * @param event - context event whose metadata and rendered content must agree.
- * @returns validated change or `undefined` for an unrelated context event.
+ * Decode and verify one model-visible goal state change without folding it. A
+ * goal state change is a round-zero goal-sourced `user/message` carrying
+ * `goal/change` metadata; any other user message returns `undefined`. Goal
+ * metadata on a non-goal source, or a mismatched attribution or rendered body,
+ * fails replay loudly.
+ * @param event - user message whose metadata and rendered content must agree.
+ * @returns validated change, or `undefined` when the message is not a goal state change.
*/
-export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined {
+export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
const change = decodeGoalChange(event.data.meta)
+ if (change === undefined) return undefined
const source = goalSource(event.data.source)
- if (change === undefined) {
- if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
- return undefined
- }
const ref = goalChangeRef(change)
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
@@ -338,23 +339,27 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un
* @returns decoded change for pending-overlay reconciliation.
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
- if (event.type === 'context/message') {
- const change = decodeGoalEvent(event)
- if (change === undefined) return undefined
- applyGoalChange(state, change)
- return change
- }
if (event.type === 'user/message') {
- const source = goalSource(event.data.source)
- if (source !== undefined) {
- const current = state.goal
- if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
- || source.revision !== current.revision || source.round !== state.roundsStarted + 1
- || source.round > current.maxGoalRounds) {
- throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
- }
- state.roundsStarted = source.round
+ // A goal state change carries `goal/change` metadata (round zero).
+ const change = decodeGoalEvent(event)
+ if (change !== undefined) {
+ applyGoalChange(state, change)
+ return change
}
+ const source = goalSource(event.data.source)
+ if (source === undefined) return undefined
+ // A goal-sourced message without change metadata must be a positive-round
+ // admitted continuation prompt; round zero owes durable change metadata.
+ if (source.round === 0) {
+ throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
+ }
+ const current = state.goal
+ if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
+ || source.revision !== current.revision || source.round !== state.roundsStarted + 1
+ || source.round > current.maxGoalRounds) {
+ throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
+ }
+ state.roundsStarted = source.round
}
return undefined
}
diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts
index 7391c69e93..e4700452fa 100644
--- a/packages/goal/goal/src/index.ts
+++ b/packages/goal/goal/src/index.ts
@@ -370,7 +370,9 @@ export class GoalService extends Service {
/** Incrementally observe durable events without losing deferred mutations. */
private sync(session: Session, cache: GoalCache): void {
for (const event of session.events.slice(cache.observedSeq)) {
- if (event.type === 'context/message') {
+ // A goal state change is a round-zero goal-sourced user message; a
+ // positive round is a continuation prompt handled by applyGoalEvent.
+ if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts
index 49184faa8c..5a97cceae0 100644
--- a/packages/goal/goal/src/runtime.ts
+++ b/packages/goal/goal/src/runtime.ts
@@ -3,7 +3,7 @@
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
-/** Version of the goal change metadata embedded in `context/message`. */
+/** Version of the goal change metadata embedded in a round-zero `user/message`. */
export const GOAL_CHANGE_VERSION = 1
/**
diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts
index 2c6798718d..7da3c525d7 100644
--- a/packages/goal/goal/src/types.ts
+++ b/packages/goal/goal/src/types.ts
@@ -89,7 +89,7 @@ export interface GoalClearChangeMeta {
readonly clearedAt: number
}
-/** Durable metadata union carried by a goal-owned `context/message`. */
+/** Durable metadata union carried by a goal-owned round-zero `user/message`. */
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts
index 0c582645bc..357bebe227 100644
--- a/packages/goal/goal/tests/goal.e2e.ts
+++ b/packages/goal/goal/tests/goal.e2e.ts
@@ -50,11 +50,11 @@ describe('goal domain through a real cordis.yml and headless process', () => {
expect(result['result']).toContain('CLI tool round trip complete')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
- const contexts = events.filter(event => event.type === 'context/message'
+ const contexts = events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')
expect(contexts).toHaveLength(1)
const context = contexts[0]
- if (context?.type !== 'context/message') throw new Error('expected goal context event')
+ if (context?.type !== 'user/message') throw new Error('expected goal context event')
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected durable goal change')
expect(change).toMatchObject({
diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts
index ad2011fc62..18f8d5dfe2 100644
--- a/packages/goal/goal/tests/goal.spec.ts
+++ b/packages/goal/goal/tests/goal.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import GoalService, {
@@ -15,7 +15,7 @@ import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-
interface DeferredInjection {
content: ContentBlock[]
- options: InjectOptions | undefined
+ options: AliasSendOptions | undefined
}
interface StubAgent {
@@ -33,8 +33,8 @@ function nextTurn(session: Session): number {
}
/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */
-function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void {
- const source: MessageSource = options?.source ?? { kind: 'user' }
+function appendInjection(session: Session, content: ContentBlock[], options?: AliasSendOptions): void {
+ const source: MessageSource = options?.source ?? { kind: 'plugin', plugin: '' }
const context = {
content,
source,
@@ -43,12 +43,12 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In
const last = session.events.at(-1)
const open = last !== undefined && last.type !== 'turn/end'
if (open) {
- session.append('context/message', context, { surfaceOp: 'append' })
+ session.append('user/message', context, { surfaceOp: 'append' })
return
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- session.append('context/message', context, { surfaceOp: 'append' })
+ session.append('user/message', context, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
@@ -65,6 +65,7 @@ function stubAgentForSession(session: Session): StubAgent {
ctx: new Context(),
get status() { return status },
send() {},
+ followup() {},
steer() {},
inject(content, options) {
if (shouldDefer) deferred.push({ content, options })
@@ -131,10 +132,10 @@ describe('GoalService creation and replay', () => {
})
expect(goal.id).toMatch(/^goal-/)
expect(seen).toEqual(['create'])
- expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end'])
+ expect(session.events.map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
const context = session.events[1]
- expect(context?.type).toBe('context/message')
- if (context?.type !== 'context/message') throw new Error('expected goal context')
+ expect(context?.type).toBe('user/message')
+ if (context?.type !== 'user/message') throw new Error('expected goal context')
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = decodeGoalChange(context.data.meta)
if (change === undefined) throw new Error('expected decoded goal change')
@@ -266,7 +267,9 @@ describe('GoalService creation and replay', () => {
it('requires the exact live registry instance for reads and mutations', async () => {
const { ctx, agent } = await harness()
- const impostor = { ...agent, session: new Session(agent.id) }
+ // A same-id agent backed by a different session object — the live-instance
+ // check must reject it even though the ids match.
+ const impostor = stubAgentForSession(new Session(agent.id)).agent
expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({
code: 'GOAL_AGENT_NOT_LIVE',
@@ -407,8 +410,8 @@ describe('GoalService mutations', () => {
vi.setSystemTime(80)
ctx.goals.clear(agent, goal)
const clear = session.events
- .filter(event => event.type === 'context/message')
- .map(event => decodeGoalChange(event.data.meta))
+ .filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
+ .map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined)
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
expect(() => foldGoal(session.events)).not.toThrow()
@@ -454,7 +457,7 @@ describe('GoalService mutations', () => {
ctx.agents.register(stub.agent)
let observed: ReturnType
ctx.on('session/event', (session, event) => {
- if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent)
+ if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent)
})
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
@@ -517,7 +520,7 @@ describe('GoalService mutations', () => {
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- session.append('context/message', {
+ session.append('user/message', {
content: renderGoalChange(change), source, meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
@@ -594,7 +597,7 @@ describe('goal replay validation', () => {
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- session.append('context/message', {
+ session.append('user/message', {
content: overrides.content ?? renderGoalChange(change),
source,
meta: change as never,
@@ -791,7 +794,7 @@ describe('goal replay validation', () => {
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'missing' }], source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
@@ -853,7 +856,7 @@ describe('goal replay validation', () => {
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
- session.append('context/message', {
+ session.append('user/message', {
content: renderGoalChange(clear), source, meta: clear as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts
index 85f0b839d1..996743ceca 100644
--- a/packages/goal/goal/tests/invariant.spec.ts
+++ b/packages/goal/goal/tests/invariant.spec.ts
@@ -45,7 +45,7 @@ describe('goal stream invariants', () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
- session.append('context/message', {
+ session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
@@ -71,7 +71,7 @@ describe('goal stream invariants', () => {
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
expect(() => {
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'counterfeit' }],
source: changeSource,
meta: change as never,
@@ -82,7 +82,7 @@ describe('goal stream invariants', () => {
}))
expect(session.seq).toBe(1)
expect(() => {
- session.append('context/message', {
+ session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
@@ -95,7 +95,7 @@ describe('goal stream invariants', () => {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
- session.append('context/message', {
+ session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
index faaf9d1c76..171fe1c0ef 100644
--- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentStatus, AliasSendOptions } from '@deepseek-ai/dsh-agent'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -32,10 +32,11 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
get status() { return status },
ctx: new Context(),
send() {},
+ followup() {},
steer() {},
- inject(content: ContentBlock[], options?: InjectOptions) {
- const source = options?.source ?? { kind: 'user' }
- session.append('context/message', {
+ inject(content: ContentBlock[], options?: AliasSendOptions) {
+ const source = options?.source ?? { kind: 'plugin', plugin: '' }
+ session.append('user/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
@@ -225,7 +226,9 @@ describe('goal tool execution authority', () => {
it('rejects stale agent objects and agents outside running status through the executor', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
- const stale = { ...root.agent }
+ // A distinct agent object over root's exact session: same id, not the live
+ // registered instance, so the executor must reject it.
+ const stale = stubAgent('goal-tool-stale', root.agent.session).agent
const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
index b3c90021a5..5646641424 100644
--- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
+++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
@@ -35,10 +35,10 @@ function waitForIdle(ctx: Context, agent: Agent): Promise {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
-/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
+/** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */
function reminders(agent: Agent): { text: string; source: unknown }[] {
return [...agent.session.events]
- .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
+ .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user')
.map(e => ({
text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'),
source: e.data.source,
diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts
index 953b4befd0..e5858e8e54 100644
--- a/packages/hooks/hooks-claude/tests/bridge.spec.ts
+++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts
@@ -125,8 +125,8 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
// The injected context reached the model and is recorded with the plugin source.
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief')
- const ctxMsg = events(agent).find(e => e.type === 'context/message')
- expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' })
+ const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind !== 'user')
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' })
})
})
@@ -216,10 +216,10 @@ describe('hooks-claude bridge — PostToolUse', () => {
const log = events(agent)
const resultIdx = log.findIndex(e => e.type === 'tool/result')
- const ctxIdx = log.findIndex(e => e.type === 'context/message')
+ const ctxIdx = log.findIndex(e => e.type === 'user/message' && e.data.source.kind !== 'user')
expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result
const ctxMsg = log[ctxIdx]
- expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
+ expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
})
it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => {
@@ -262,7 +262,7 @@ describe('hooks-claude bridge — SessionStart', () => {
// session-start fires async (detached .then → agent.inject); wait for the
// injected context/message to actually land before sending, rather than a
// fixed sleep that flakes under load.
- await waitFor(() => events(agent).some(e => e.type === 'context/message'
+ await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts
index e303c589be..63b127e794 100644
--- a/packages/hooks/hooks-claude/tests/coverage-cases.ts
+++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts
@@ -138,9 +138,9 @@ export function defineCoverageCases(group: CoverageGroup): void {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
- // The prompt proceeded unchanged; no context/message injected.
+ // The prompt proceeded unchanged; no injected context.
expect(adapter.requests).toHaveLength(1)
- expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
})
it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
@@ -441,7 +441,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
// additionalContext also injected (the block + context arm).
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
})
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
@@ -475,7 +475,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent)
- expect(events(handle.agent).some(e => e.type === 'context/message'
+ expect(events(handle.agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
await handle.dispose()
})
@@ -496,7 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// the downstream block won: the model was never called, no user/message was
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
expect(adapter.requests).toHaveLength(0)
- expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
})
@@ -528,12 +528,12 @@ export function defineCoverageCases(group: CoverageGroup): void {
// the original prompt was replaced by the downstream rewrite
const userMsg = events(agent).find(e => e.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
- const contexts = events(agent).filter(event => event.type === 'context/message')
- expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
+ const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+ expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
- expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
+ expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
@@ -551,7 +551,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
@@ -573,12 +573,12 @@ export function defineCoverageCases(group: CoverageGroup): void {
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
- const contexts = events(agent).filter(event => event.type === 'context/message')
- expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
+ const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+ expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
- expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
+ expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
@@ -599,7 +599,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
// the bridge's context still landed (folded onto the block)
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
})
diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts
index d0f0df92f6..4cfdd93e82 100644
--- a/packages/hooks/hooks-codex/tests/coverage-cases.ts
+++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts
@@ -136,12 +136,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
expect(req).toContain('from-bridge')
expect(req).toContain('from-downstream')
expect(req).toContain('rewritten-prompt')
- const contexts = events(agent).filter(event => event.type === 'context/message')
- expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
+ const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+ expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
- expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
+ expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
})
@@ -157,7 +157,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => {
@@ -177,12 +177,12 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
- const contexts = events(agent).filter(event => event.type === 'context/message')
- expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([
+ const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
+ expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
- expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
+ expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
@@ -197,7 +197,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('SessionStart additionalContext is injected for the first request', async () => {
@@ -206,7 +206,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- await waitFor(() => events(agent).some(e => e.type === 'context/message'
+ await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
@@ -233,7 +233,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
})
})
@@ -345,7 +345,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
- expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
})
it('a throwing SessionStart inject is contained (logged)', async () => {
@@ -428,7 +428,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const r = events(agent).find(e => e.type === 'tool/result')
expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
- expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
+ expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true)
})
it('commandOf reads a non-string command arg as an empty command', async () => {
@@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await waitFor(() => existsSync(marker)) // the exit-2 hook has finished
- expect(events(agent).some(e => e.type === 'context/message'
+ expect(events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false)
})
@@ -545,7 +545,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- await waitFor(() => events(agent).some(e => e.type === 'context/message'
+ await waitFor(() => events(agent).some(e => e.type === 'user/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index 27d54c5a69..916a39baf1 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -332,7 +332,7 @@ export class PlanModeService extends Service {
const text = target
? 'The user switched this session to plan mode.'
: 'The user switched this session back to the default mode.'
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
}, { surfaceOp: 'append' })
diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts
index f1c57b5938..1f78ae3511 100644
--- a/packages/plan/plan-mode/tests/integration.spec.ts
+++ b/packages/plan/plan-mode/tests/integration.spec.ts
@@ -92,7 +92,7 @@ describe('plan mode through the agent loop', () => {
const result = findEvent(log, 'tool/result')
expect(result.data.isError).toBe(false)
expect(foldPlanMode(log)).toBe(true)
- expect(log.some(event => event.type === 'context/message')).toBe(false)
+ expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => {
@@ -115,9 +115,9 @@ describe('plan mode through the agent loop', () => {
const log = agent.session.events
expect(foldPlanMode(log)).toBe(true)
- const notices = log.filter(event => event.type === 'context/message')
+ const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
expect(notices).toHaveLength(1)
- expect(findEvent(log, 'context/message').data.content).toEqual([
+ expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
// The changed request is logged as a complete snapshot.
@@ -163,7 +163,8 @@ describe('plan mode through the agent loop', () => {
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
- expect(findEvent(log, 'context/message').data.content).toEqual([
+ const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
+ expect(notice?.type === 'user/message' && notice.data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
})
diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts
index b5dc9723de..c3c0b4d2ea 100644
--- a/packages/plan/plan-mode/tests/plan-mode.spec.ts
+++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts
@@ -95,7 +95,7 @@ function header(session: Session): void {
function noticeTexts(session: Session): string[] {
return session.events
- .filter(event => event.type === 'context/message')
+ .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
.map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
}
diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts
index 1273824033..360b71c3f8 100644
--- a/packages/pty/pty-local/tests/index.spec.ts
+++ b/packages/pty/pty-local/tests/index.spec.ts
@@ -42,7 +42,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('agent')
return {
id, options: {}, session: new Session(id), status: 'idle', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts
index b2d6a38255..ad463a0352 100644
--- a/packages/pty/pty-local/tests/local.spec.ts
+++ b/packages/pty/pty-local/tests/local.spec.ts
@@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts
index 17b0302ea6..4a690ed520 100644
--- a/packages/pty/pty/tests/service.spec.ts
+++ b/packages/pty/pty/tests/service.spec.ts
@@ -28,6 +28,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
status: 'idle',
ctx: scopeFiber.ctx,
send() {},
+ followup() {},
steer() {},
inject() {},
cancel() {},
diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts
index 38a5cb4ed6..f71a637928 100644
--- a/packages/pty/tool-pty/tests/loader-composition.spec.ts
+++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts
@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts
index 5adcaea441..0439e5f876 100644
--- a/packages/pty/tool-pty/tests/tools.spec.ts
+++ b/packages/pty/tool-pty/tests/tools.spec.ts
@@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent
diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts
index ee8b1d833f..de31980f48 100644
--- a/packages/session-query/session-query/tests/tracing.spec.ts
+++ b/packages/session-query/session-query/tests/tracing.spec.ts
@@ -107,7 +107,7 @@ function appendTraceEvents(session: Session): void {
{ surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] },
)
session.append(
- 'context/message',
+ 'user/message',
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
{ surfaceOp: 'append' },
)
@@ -309,14 +309,14 @@ describe('session event tracing', () => {
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
- 'context/message',
+ 'user/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
{ surfaceOp: 'append' },
)
TracePersistence.listFailure = new Error('list unavailable')
TracePersistence.loadFailure = new Error('load unavailable')
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 }))
- .resolves.toMatchObject({ target: { type: 'context/message' } })
+ .resolves.toMatchObject({ target: { type: 'user/message' } })
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
index 1cfb809601..a1401bbb6d 100644
--- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
+++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
@@ -68,7 +68,7 @@ describe('startInProcessRun', () => {
turn,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}, { surfaceOp: 'append' })
diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
index 7ba0551f34..c2dbe00dc9 100644
--- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
+++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
@@ -187,10 +187,10 @@ describe('dsh-subagent-spawn', () => {
expect(published).toEqual([])
})
- it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
+ it('a cancel from agent/inbox/enqueue maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
- ctx.on('agent/queued', () => { controller.abort('queued-window') })
+ ctx.on('agent/inbox/enqueue', () => { controller.abort('queued-window') })
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md
index e4866212b6..2dd0cde142 100644
--- a/packages/support/invariants/README.md
+++ b/packages/support/invariants/README.md
@@ -34,7 +34,7 @@ The current executable companions protect these relationships:
| Companion | Checks |
|---|---|
-| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. |
+| `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, inbox FIFO conservation, scoped subjects, and model-request reconstruction. |
| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. |
| `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. |
| `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. |
diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts
index 0d3eae8338..b4d57191a2 100644
--- a/packages/tasks/tasks/tests/tasks.spec.ts
+++ b/packages/tasks/tasks/tests/tasks.spec.ts
@@ -24,6 +24,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
status: 'idle' as const,
ctx: scopeFiber.ctx,
send() {},
+ followup() {},
steer() {},
inject() {},
cancel() {},
diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts
index 7e0b15ff9c..b99b7a4ee2 100644
--- a/packages/ui/acp/src/index.ts
+++ b/packages/ui/acp/src/index.ts
@@ -1333,8 +1333,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* generic fallback (title = tool name, raw args as input) when no registry is
* available (e.g. pure translator tests).
*
- * Other event types (turn/step boundaries, context/message, …) produce
- * no client update.
+ * Other event types (turn/step boundaries, injected-context user messages, …)
+ * produce no client update.
* @param sessionId - the ACP session id stamped on every emitted notification.
* @param event - the harness session event to translate.
* @param notify - sink for each produced `session/update` notification; called
@@ -1374,6 +1374,9 @@ export function streamSessionEventUpdate(
}
case 'user/message': {
if (!includeUserMessages) return
+ // Only a direct human prompt replays as a user message; injected context
+ // (plugin/goal source) is not the user's turn and produces no update.
+ if (event.data.source.kind !== 'user') return
// Replay the user's prompt so a loaded session shows both sides of each
// turn. Live prompt turns suppress this path to avoid duplicating what
// the client just sent.
@@ -1420,7 +1423,7 @@ export function streamSessionEventUpdate(
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
return
}
- // non-error turn/step boundaries, context/message, steering,
+ // non-error turn/step boundaries, injected-context user messages, steering,
// assistant/message — no direct ACP client update.
default:
return
diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts
index 1e15910ce7..6e24acce24 100644
--- a/packages/ui/acp/tests/bridge.spec.ts
+++ b/packages/ui/acp/tests/bridge.spec.ts
@@ -383,7 +383,7 @@ describe('acp bridge', () => {
},
}],
})
- expect(target.events.some(event => event.type === 'context/message')).toBe(false)
+ expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts
index 4ca6775d06..2620c893de 100644
--- a/packages/ui/acp/tests/turns.spec.ts
+++ b/packages/ui/acp/tests/turns.spec.ts
@@ -285,10 +285,10 @@ describe('acp bridge — turn outcomes', () => {
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
// On the queued prompt, synchronously inject a one-shot context turn (idle
- // inject writes turn/start{injection} → context/message → turn/end). Fire
+ // inject writes turn/start{injection} → user/message → turn/end). Fire
// once so it lands between install and the prompt turn.
let injected = false
- harness.ctx.on('agent/queued', (subject) => {
+ harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && !injected) {
injected = true
agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } })
diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts
index 40dbc81acd..5105023581 100644
--- a/packages/ui/jsonrpc/tests/server.spec.ts
+++ b/packages/ui/jsonrpc/tests/server.spec.ts
@@ -241,7 +241,7 @@ describe('HarnessSdkServer', () => {
turn: 2,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}, { surfaceOp: 'append' })
diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts
index 4a16aa87d0..d473c4ac97 100644
--- a/packages/ui/tui/src/index.ts
+++ b/packages/ui/tui/src/index.ts
@@ -1487,12 +1487,12 @@ export function createTuiChat(
let toolsExpanded = false
let streaming: StreamingAssistantComponent | undefined
let runningStatus: RunningStatus | undefined
- // Steering messages queued during the running turn (`agent/queued`) that the
- // loop has not yet drained, shown as a badge on the status line. Each entry is
- // the queued message's serialized source: a drain (`steering/message`) removes
- // one MATCHING entry, so loop-authored steering — continuation reasons enter
- // the inbox without an `agent/queued` event — cannot consume a pending user
- // message's slot. Cleared on leaving `running`, which also absorbs a
+ // Steering messages queued during the running turn (`agent/inbox/enqueue`)
+ // that the loop has not yet drained, shown as a badge on the status line. Each
+ // entry is the queued message's serialized source: a drain (`steering/message`)
+ // removes one MATCHING entry, so loop-authored steering — continuation reasons
+ // enter the inbox without an `agent/inbox/enqueue` event — cannot consume a
+ // pending user message's slot. Cleared on leaving `running`, which also absorbs a
// cancellation that discards the queue without logging drains; the status
// line exists only while running, so idle carries no badge to keep current.
const pendingSteering: string[] = []
@@ -1795,6 +1795,29 @@ export function createTuiChat(
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
switch (event.type) {
case 'user/message': {
+ // Injected context (plugin/goal source) renders as a dim context card,
+ // not a human bubble; only a direct human prompt is a user message. The
+ // boolean avoids narrowing `source`, so the label keeps its full union.
+ const source = event.data.source
+ if (source.kind !== 'user') {
+ const references = sessionReferenceCard(event.data.meta)
+ if (references !== undefined) {
+ chat.addChild(new Spacer(1))
+ chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
+ break
+ }
+ const text = displayText(contentText(event.data.content).trim())
+ if (text) {
+ // The tui type view lacks plugin-augmented source kinds (e.g. goal),
+ // so read the display label without narrowing on `kind`.
+ const labelled = source as { kind: string; plugin?: string }
+ const label = labelled.plugin ?? labelled.kind
+ chat.addChild(new Spacer(1))
+ chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0))
+ chat.addChild(new Text(palette.muted(text), 1, 0))
+ }
+ break
+ }
const text = displayText(contentText(displayPromptContent(event.data)).trim())
if (text) {
chat.addChild(new Spacer(1))
@@ -1819,22 +1842,6 @@ export function createTuiChat(
}
break
}
- case 'context/message': {
- const references = sessionReferenceCard(event.data.meta)
- if (references !== undefined) {
- chat.addChild(new Spacer(1))
- chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
- break
- }
- const text = displayText(contentText(event.data.content).trim())
- if (text) {
- const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
- chat.addChild(new Spacer(1))
- chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0))
- chat.addChild(new Text(palette.muted(text), 1, 0))
- }
- break
- }
case 'prompt/blocked':
appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
break
@@ -1919,7 +1926,6 @@ export function createTuiChat(
const isSurface = event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
- || event.type === 'context/message'
|| event.type === 'steering/message'
if (isSurface && !active.has(event.seq)) continue
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
@@ -2554,7 +2560,7 @@ export function createTuiChat(
// A queued steering message reached the model as it drained; drop its
// entry from the badge. Matching by source keeps loop-authored steering
// (e.g. continuation reasons), which logs here without a matching
- // `agent/queued` increment, from consuming a pending user slot.
+ // `agent/inbox/enqueue` increment, from consuming a pending user slot.
const drained = pendingSteering.indexOf(JSON.stringify(event.data.source))
if (drained >= 0) {
pendingSteering.splice(drained, 1)
@@ -2568,7 +2574,7 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
- const disposeQueued = ctx.on('agent/queued', (subject, _content, info) => {
+ const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => {
if (subject !== agent || !info.steering) return
pendingSteering.push(JSON.stringify(info.source))
refreshStatus()
diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts
index c6da283236..c8755e4bb3 100644
--- a/packages/ui/tui/tests/harness.ts
+++ b/packages/ui/tui/tests/harness.ts
@@ -153,6 +153,10 @@ export async function createTuiTestHarness {
type: 'text',
text: '\n\n## My request:\n',
})
- expect(target.session.events.some(event => event.type === 'context/message')).toBe(false)
+ expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
const snapshot = await terminal.snapshot({ includeScrollback: true })
if (REFRESHING) {
diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts
index 9a2f3b51e1..1c718a736f 100644
--- a/packages/ui/tui/tests/tui.snapshot.ts
+++ b/packages/ui/tui/tests/tui.snapshot.ts
@@ -451,7 +451,7 @@ describe('TUI terminal-state snapshots', () => {
session.append('todo/write', {
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
})
- session.append('context/message', {
+ session.append('user/message', {
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}, { surfaceOp: 'append' })
@@ -567,7 +567,7 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
- harness.session.append('context/message', {
+ harness.session.append('user/message', {
content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts
index 22a50c9ccf..667db4abb3 100644
--- a/packages/ui/tui/tests/tui.spec.ts
+++ b/packages/ui/tui/tests/tui.spec.ts
@@ -374,8 +374,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
- result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
- result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
+ result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
appendAssistant(result.session, [])
result.session.append('step/end', { turn: 1, step: 1 })
@@ -552,16 +552,16 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).not.toContain('queued')
const queueSteering = (text: string): void => {
- result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, contexts: [], steering: true })
+ result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
}
const drainSteering = (text: string): void => {
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}
// A steering queue for a different agent never touches this status line.
- const other = { ...result.agent, id: SessionId('other') } as Agent
+ const other = { ...result.agent, id: SessionId('other') } as unknown as Agent
result.terminal.output = ''
- result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, contexts: [], steering: true })
+ result.ctx.emit('agent/inbox/enqueue', other, { content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -574,7 +574,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// A non-steering queue (an idle-style send) leaves the badge untouched.
result.terminal.output = ''
- result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, contexts: [], steering: false })
+ result.ctx.emit('agent/inbox/enqueue', result.agent, { content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true })
drainSteering('first')
await tick()
expect(result.terminal.output).toContain('1 queued')
@@ -594,7 +594,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).toContain('1 queued')
- // A loop-authored steering event (plugin source, no matching agent/queued)
+ // A loop-authored steering event (plugin source, no matching agent/inbox/enqueue)
// cannot consume a pending user slot, even when it drains first.
result.terminal.output = ''
result.session.append('steering/message', {
@@ -627,7 +627,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const idle = await setup()
// A steering queue arriving while idle has no status line to badge, so the
// refresh is a no-op beyond requesting a render.
- idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, contexts: [], steering: true })
+ idle.ctx.emit('agent/inbox/enqueue', idle.agent, { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
await tick()
expect(idle.terminal.output).not.toContain('Executing tools')
@@ -1221,7 +1221,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
expect(result.terminal.output).not.toContain('hidden non-reference prefix')
- result.session.append('context/message', {
+ result.session.append('user/message', {
content: [{ type: 'text', text: 'secret full snapshot payload' }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
@@ -1240,13 +1240,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
]
for (const [meta, text] of invalidCards) {
- result.session.append('context/message', {
+ result.session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta,
}, { surfaceOp: 'append' })
}
- result.session.append('context/message', {
+ result.session.append('user/message', {
content: [{ type: 'text', text: 'same-label snapshot' }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
@@ -1658,7 +1658,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const events = await setup()
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
- const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
+ const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
@@ -2021,7 +2021,7 @@ describe('tool cards and surface replay', () => {
turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
- result.session.append('context/message', {
+ result.session.append('user/message', {
content: [{ type: 'text', text: 'summary replacement' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
@@ -2207,7 +2207,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
@@ -2231,7 +2231,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -2265,14 +2265,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -2302,7 +2302,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -2344,7 +2344,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx,
- send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
+ send() {}, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 40f1a22377..2eee7e0bb0 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -36,6 +36,7 @@ export const LINK_MAP: Record = {
ContinuationStop: 'core.md',
GenerateOptions: 'core.md',
HookContext: 'core.md',
+ InboxItemInfo: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmFailure: 'llm-streaming.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index c93f70cf81..0c6175d61c 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -911,7 +911,7 @@ function renderLifecycle(): string {
' participant Persistence',
' participant SDK as UI or SDK listener',
' User->>Agent: send(content)',
- ` Agent-->>SDK: ${mermaidCode('agent/queued')}`,
+ ` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`,
' Agent->>Driver: queued work wakes driver',
` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
` Driver->>Session: ${mermaidCode('turn/start')}`,
@@ -988,7 +988,7 @@ function renderToolPipeline(): string {
` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`,
` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`,
- ' context["Active-batch additionalContexts FIFO
context/message after recorded tool results"]',
+ ' context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`,
' allResults["Tool batch settled
recorded tool/result events complete"]',
' presentResult["UI completed card
presentResult(args, result)"]',
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index cc85d48ba5..0ddfff5aa6 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -263,7 +263,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-goal',
source: 'packages/goal/tool-goal/src/index.ts',
requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'],
- writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'],
+ writes: ['tool/call', 'user/message goal snapshot for mutations', 'tool/result'],
async mount(ctx) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
@@ -336,7 +336,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-tasks',
source: 'packages/tasks/tool-tasks/src/index.ts',
requires: ['ctx.tools', 'ctx.tasks', 'ctx.systemPrompt'],
- writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
+ writes: ['tool/call', 'tool/result', 'user/message via agent.inject() for background completion notices'],
async mount(ctx) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index f95f47e580..5014148504 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -66,6 +66,11 @@
"symbol": "SessionEvent",
"source": "packages/core/session/src/types.ts"
},
+ {
+ "doc": "docs/core-data-structures/core.md",
+ "symbol": "SendTarget",
+ "source": "packages/core/agent/src/types.ts"
+ },
{
"doc": "docs/core-data-structures/core.md",
"symbol": "SendOptions",
@@ -73,12 +78,22 @@
},
{
"doc": "docs/core-data-structures/core.md",
- "symbol": "AgentCancelCause",
+ "symbol": "AliasSendOptions",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
- "symbol": "InjectOptions",
+ "symbol": "InboxItemInfo",
+ "source": "packages/core/agent/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/core.md",
+ "symbol": "CancelOptions",
+ "source": "packages/core/agent/src/types.ts"
+ },
+ {
+ "doc": "docs/core-data-structures/core.md",
+ "symbol": "AgentCancelCause",
"source": "packages/core/agent/src/types.ts"
},
{