diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
index 7c3ed96243..e0e804e953 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-22-unified-send-and-coalesced-user-messages.md: 9eb355128b217a0ea8dc09daf4e83334f6aeaa10
-2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 679c9100aa49777b7b601725bdfc077f5f2dab0e
+2026-07-22-unified-send-and-coalesced-user-messages.md: eaef3d7e8efd8362c5029d777701c5db59c1ee5b
+2026-07-22-unified-send-and-coalesced-user-messages.zh.md: f84fe22f457677b8f8463417b2931cfd0ec77508
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
index 9eb355128b..eaef3d7e8e 100644
--- 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
@@ -36,7 +36,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj
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`.
-`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every FIFO exit publishes exactly one lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` for it — both at the in-turn stop point and on the post-turn drain of late steering — and a loop-authored continuation reason is snapshotted and frozen like a public send. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog.
+`wakeup` is the "should the model run" signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is ever left hanging). `SendOptions.meta` on a queued or steering send is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally not on the live `AgentMessage` event, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` (both at the in-turn stop point and on the post-turn drain of late steering), and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like a public send. Injection (`next-step`/no-wakeup) validates its payload up front — before opening the idle one-shot turn, honoring the "invalid input throws before any append" contract — and rejects attached `contexts` (which belong only to inbox messages) rather than silently dropping them. `gen-cordis-api` collects exported classes (public members, body-stripped) so the now-class `Agent` and its transitive shapes still appear in the model-facing API catalog.
## Related
diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
index 679c9100aa..f84fe22f45 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
@@ -36,7 +36,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。代价是:`Agent` 变成了抽象类,因此对象字面量形式的测试替身必须提供 `followup`,且无法在不重新做类型转换的情况下展开一个类类型的值(原型方法不可枚举);goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变——空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
-`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每一次 FIFO 退出都恰好发布一个生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`——既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时——而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。
+`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会有等待者被永久挂起)。排队 send 或 steering send 上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 事件上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`(既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时),而 dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像一次对外 send 那样被快照并冻结。注入(`next-step`/no-wakeup)会预先校验其载荷——在打开空闲状态的一次性轮次之前,从而遵守“无效输入在任何追加之前抛出”的契约——并拒绝附带的 `contexts`(`contexts` 只属于 inbox 消息),而不是静默丢弃它们。`gen-cordis-api` 收集导出的类(公开成员,剥除方法体),因此如今已是类的 `Agent` 及其传递涉及的形状仍会出现在面向模型的 API 目录中。
## 相关
diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md
index 03b91d653e..ff47ef3b19 100644
--- a/packages/compact/compact/README.md
+++ b/packages/compact/compact/README.md
@@ -33,7 +33,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
## Surface contract
-`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
+`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
1. appends `compact/start` (log-only) — acquires the lock,
2. summarizes the range,
diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md
index 2327da81b2..f4938982a1 100644
--- a/packages/context/time-context/README.md
+++ b/packages/context/time-context/README.md
@@ -18,9 +18,9 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o
## Timing semantics
-The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
+The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
-Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
+Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md
index a7245df91f..67ab9b4245 100644
--- a/packages/context/workspace-context/README.md
+++ b/packages/context/workspace-context/README.md
@@ -28,7 +28,7 @@ Instructions from: AGENTS.md
```
-Newly reached scopes use a durable raw `context/message`:
+Newly reached scopes use a durable injected `user/message` (plugin source):
```md
@@ -42,11 +42,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when
A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
-The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
+The plugin owns the complete `` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
## State And Refresh
-Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
+Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
@@ -111,7 +111,7 @@ Prefix-stable within one loop instance because the baseline is frozen. A new or
#### What the model sees
-After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
+After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file.
##### Additional instruction template
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index baa2e3f08d..9bc24de5da 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -248,14 +248,22 @@ export class ReactLoopAgent extends Agent {
/** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
private injectContext(content: ContentBlock[], options?: SendOptions): void {
+ // Injection is synthetic durable context, not an inbox message: attached
+ // contexts belong only to queued/steering sends, so reject them rather than
+ // silently dropping a value the option type structurally permits.
+ if (options?.contexts !== undefined && options.contexts.length > 0) {
+ throw new TypeError('agent inject (next-step/no-wakeup) does not accept attached contexts')
+ }
const source = options?.source ?? { kind: 'plugin', plugin: '' }
- const context = {
+ // Detach and validate the payload BEFORE any append, so malformed input
+ // throws without opening a one-shot turn or mutating the session (the
+ // unified send contract: invalid input throws before any append).
+ const accepted = this.acceptContext({
content,
source,
...options?.meta !== undefined ? { meta: options.meta } : {},
- }
+ })
if (isTurnOpen(this.session)) {
- const accepted = this.acceptContext(context)
// Provider protocols require every assistant tool-call batch to be
// followed only by its tool results. Historical interrupted batches do
// not own new context; only the currently executing batch may defer it.
@@ -267,39 +275,35 @@ export class ReactLoopAgent extends Agent {
return
}
// No turn open: wrap the injection in a one-shot turn so every event stays
- // turn-enclosed (the durability/replay boundary is the turn).
+ // turn-enclosed (the durability/replay boundary is the turn). The payload is
+ // validated above, so both appends commit together; the finally still owes
+ // a turn/end (the turn-enclosure invariant) even if a post-commit observer
+ // throws after turn/start.
const turn = lastTurnNumber(this.session) + 1
- // Once turn/start enters the log, a turn/end is owed even if the message
- // append fails acceptance or pre-commit validation. The finally re-checks
- // the log and closes only a turn that actually opened; post-commit observers
- // 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('user/message', context, { surfaceOp: 'append' })
+ this.session.append('user/message', accepted, { 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.
+ // Close the turn if turn/start committed. With the payload validated up
+ // front both appends commit together, so the turn is always open here;
+ // the guard remains the turn-enclosure backstop.
+ /* v8 ignore next -- unopened turn is unreachable after up-front validation; kept as the enclosure backstop. */
if (isTurnOpen(this.session)) {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
- // Decide the durability checkpoint from the log: an accepted one-shot
- // turn must be flushed even when its message append was the failing step.
- const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
- // Keep inject() synchronous: report checkpoint failures live instead of
- // rejecting the caller, and track the task so disposal still drains it.
- if (turnRecorded) {
- // Through the store's flush (the carrier owner), never a raw parallel.
- const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
- const rendered = errorChain(error)
- const err = error instanceof Error ? error : new Error(rendered)
- this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
- agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
- })
- this.pendingIdleFlushes.add(flush)
- // Retire on either settlement path.
- const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
- void flush.then(retire, retire)
- }
+ // Flush the one-shot turn through the store (the carrier owner), never a
+ // raw parallel. Keep inject() synchronous: report checkpoint failures live
+ // instead of rejecting the caller, and track the task so disposal drains it.
+ const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
+ const rendered = errorChain(error)
+ const err = error instanceof Error ? error : new Error(rendered)
+ this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
+ agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
+ })
+ this.pendingIdleFlushes.add(flush)
+ // Retire on either settlement path.
+ const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
+ void flush.then(retire, retire)
}
}
@@ -434,6 +438,18 @@ export class ReactLoopAgent extends Agent {
*/
private [stopDriver](): Promise | void {
if (this._status !== 'disposed') {
+ // Discard any still-pending inbox items before disposal so every enqueued
+ // id gets a terminal lifecycle event; a disposed agent never dequeues
+ // them. Emitted while still published (before the status flip below), and
+ // only when there is a public lifecycle to observe it.
+ if (this.published) {
+ const discarded = this.#inbox.pending()
+ if (discarded.length > 0) {
+ const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
+ agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
+ }
+ }
+ this.#inbox.clear()
this._status = 'disposed'
this.resolveDisposed()
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts
index 3cb82944e4..c0a6f4d694 100644
--- a/packages/core/agent-loop/src/inbox.ts
+++ b/packages/core/agent-loop/src/inbox.ts
@@ -29,7 +29,14 @@ export interface InboxMessage {
* @returns the live-event message for enqueue/dequeue/discard.
*/
export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage {
- return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
+ // Frozen: the fused emitter passes this exact object to every listener in
+ // turn, so one listener must not be able to mutate a field (`id`, `steering`,
+ // `content`, …) a later listener then observes. `message` is already a frozen
+ // inbox record, so its nested fields need no re-clone.
+ return Object.freeze({
+ id: message.id, content: message.content, source: message.source,
+ contexts: message.contexts, steering, wakeup: message.wakeup,
+ })
}
/**
diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts
index 608f1bad60..3b8942d27c 100644
--- a/packages/core/agent-loop/tests/agent.spec.ts
+++ b/packages/core/agent-loop/tests/agent.spec.ts
@@ -98,6 +98,27 @@ describe('Agent', () => {
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
+ it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
+ const adapter = new MockAdapter(['hang'])
+ const ctx = await harness(adapter)
+ let agent!: Agent
+ const discarded: string[] = []
+ ctx.on('agent/inbox/discard', (subject, messages) => {
+ if (subject === agent) discarded.push(...messages.map(m => m.id))
+ })
+ const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+ agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
+ }, { inject: ['agentLoop'] }))
+
+ // A quiet (non-waking) item stays parked in the inbox; disposal must drop it
+ // WITH a discard so its enqueued id is not left dangling forever.
+ const id = agent.send([{ type: 'text', text: 'never runs' }], { target: 'next-turn', wakeup: false })
+ await fiber.dispose()
+ await driverDone(agent)
+
+ expect(discarded).toEqual([id])
+ })
+
it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -177,24 +198,22 @@ describe('Agent', () => {
warn.mockRestore()
})
- it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
+ it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
- // Non-serializable injected content makes Session.append throw AFTER
- // turn/start was recorded. The turn/end must still be appended (finally),
- // AND the durability checkpoint must still fire — the balanced turn is in
- // memory and a crash before the next turn/dispose would otherwise lose it.
+ // Non-serializable injected content is rejected by the up-front snapshot
+ // BEFORE any append (the unified send contract: invalid input throws before
+ // mutating the log). No one-shot turn opens and no durability checkpoint fires.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
- }).toThrow(/non-JSON-serializable/)
- const types = agent.session.events.map(e => e.type)
- expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
- await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
- expect(flushes).toBe(1) // checkpoint fired despite the throw
+ }).toThrow(/losslessly JSON-serializable/)
+ expect(agent.session.events).toHaveLength(0)
+ await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance
+ expect(flushes).toBe(0) // nothing was appended, so no checkpoint
})
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
@@ -244,13 +263,27 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
- // A non-serializable source makes the turn/start append throw BEFORE the
- // event is pushed (Session.append validates before push), so NO turn opens.
- // The finally's isTurnOpen() guard sees no open turn and appends nothing —
- // the log stays empty, not left with a dangling turn/start.
+ // A non-serializable source is rejected by the up-front snapshot BEFORE any
+ // append, so NO turn opens and the log stays empty.
expect(() => {
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
- }).toThrow(/non-JSON-serializable/)
+ }).toThrow(/losslessly JSON-serializable/)
+ expect(agent.session.events).toHaveLength(0)
+ })
+
+ it('inject() rejects attached contexts (they belong to inbox messages, not injection)', async () => {
+ const adapter = new MockAdapter([textResponse('ok')])
+ const ctx = await harness(adapter)
+ const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
+
+ // contexts structurally compile on AliasSendOptions but injection cannot
+ // carry them, so they are rejected rather than silently dropped.
+ expect(() => {
+ agent.inject([{ type: 'text', text: 'x' }], {
+ source: { kind: 'plugin', plugin: 'p' },
+ contexts: [{ content: [{ type: 'text', text: 'ctx' }], source: { kind: 'plugin', plugin: 'p' } }],
+ } as never)
+ }).toThrow(/does not accept attached contexts/)
expect(agent.session.events).toHaveLength(0)
})
diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts
index 791eae3bda..289cd2f6d5 100644
--- a/packages/core/agent-loop/tests/inbox.spec.ts
+++ b/packages/core/agent-loop/tests/inbox.spec.ts
@@ -1,11 +1,20 @@
import { describe, expect, it } from 'vitest'
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
-import { Inbox } from '../src/inbox.ts'
+import { Inbox, agentMessage } from '../src/inbox.ts'
function message(text: string) {
return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
}
+describe('agentMessage', () => {
+ it('returns a frozen payload so a listener cannot mutate it for later listeners', () => {
+ const payload = agentMessage(message('m'), false)
+ expect(Object.isFrozen(payload)).toBe(true)
+ expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow()
+ expect(payload.id).toBe(AgentMessageId('m'))
+ })
+})
+
function resolverPair() {
let r!: () => void
const p = new Promise((resolve) => { r = resolve })
diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md
index e50044d135..1df6d08f5c 100644
--- a/packages/goal/goal/README.md
+++ b/packages/goal/goal/README.md
@@ -19,7 +19,7 @@ Event-sourced same-session goal state. The service retains one current completio
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
-Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
+Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The round-zero `user/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md
index ef5e4e5846..e7bd79732e 100644
--- a/packages/guard/repeat-tool-guard/README.md
+++ b/packages/guard/repeat-tool-guard/README.md
@@ -30,11 +30,11 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de
## Reminder delivery
-Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata.
+Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata.
## Testing
-Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript.
+Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as injected `user/message`s in the ACP transcript.
## Model Experience
diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md
index 42a642caf0..3a84807285 100644
--- a/packages/hooks/hook-protocol/README.md
+++ b/packages/hooks/hook-protocol/README.md
@@ -27,7 +27,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
-Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks Agent Note.
+Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `user/message` is the durable evidence) — see the hooks Agent Note.
## Model Experience
diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md
index 2725954185..0f24508d14 100644
--- a/packages/plan/plan-mode/README.md
+++ b/packages/plan/plan-mode/README.md
@@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
-`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state.
+`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state.
## Model and human surfaces