From 3c2e07a92d48c712f707603e78bab4954e03a33f Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 08:38:28 +0800
Subject: [PATCH 1/5] fix(goal): make publication reentrancy-safe
---
...rsisted-same-session-goal-domain.i18n.yaml | 4 +-
...7-19-persisted-same-session-goal-domain.md | 5 +-
...9-persisted-same-session-goal-domain.zh.md | 5 +-
docs/cordis-catalog/services.md | 2 +-
docs/glossary.md | 2 +-
packages/goal/goal/README.md | 3 +-
packages/goal/goal/src/index.ts | 47 +++++++---
packages/goal/goal/tests/goal.spec.ts | 92 +++++++++++++++++++
website/zh-CN/api/harness/goals.md | 24 ++---
9 files changed, 152 insertions(+), 32 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml
index 86c1ae0715..d398150b73 100644
--- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.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-19-persisted-same-session-goal-domain.md: 3404e24c3cc5880f0eeddbdbf35e01ad32063b74
-2026-07-19-persisted-same-session-goal-domain.zh.md: fb12aad5d7c035a2befc30ffd650dcd336233104
+2026-07-19-persisted-same-session-goal-domain.md: 1484a919851c18ce978c93c068f6096bbf3b733f
+2026-07-19-persisted-same-session-goal-domain.zh.md: 8bcedc6cab6b9219a33a655e8fcbc53762b7413f
diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md
index 3404e24c3c..1484a91985 100644
--- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md
+++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md
@@ -22,7 +22,7 @@ Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `c
The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired.
-When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; the durable log remains authoritative after restart.
+When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; reentrant append observers project each mutation exactly once. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart.
### Lifecycle and live activation
@@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id.
## Testing
-Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate.
+Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate.
## Alternatives considered
@@ -59,4 +59,5 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r
- This domain records state but does not schedule goal rounds, cancel active turns, or classify abnormal stops.
- The actor that records `complete` or `blocked` is authoritative; an independent evaluator or completion certificate is deferred to a policy consumer.
- There is one current goal per session; parallel objective graphs and cross-session goal storage are absent.
+- Plugins share one trusted process boundary. Direct session writers can counterfeit goal records; strict replay detects inconsistency and fails goal access at the offending record, but does not isolate plugins or repair the log.
- `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path.
diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md
index fb12aad5d7..8bcedc6cab 100644
--- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md
@@ -22,7 +22,7 @@ Status: implemented
回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。
-当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重启后仍以持久日志为准。
+当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重入的追加观察器对每次变更只投影一次。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。
### 生命周期与实时激活态
@@ -36,7 +36,7 @@ Status: implemented
## 测试
-单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。
+单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。
## 考虑过的替代方案
@@ -59,4 +59,5 @@ Status: implemented
- 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。
- 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。
- 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。
+- 插件共享同一个受信任的进程边界。直接写入会话的插件可以伪造目标记录;严格回放会检测不一致并在违规记录处使目标访问失败,但不会隔离插件或修复日志。
- `GOAL_CHANGE_VERSION` 在首次发布前不承诺兼容性,也不提供迁移路径。
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 934086dd97..7d33e49c13 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -577,7 +577,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef
Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md)
-Source: [`packages/goal/goal/src/index.ts:97`](../../packages/goal/goal/src/index.ts)
+Source: [`packages/goal/goal/src/index.ts:104`](../../packages/goal/goal/src/index.ts)
## `ctx.llm` — `LlmService`
diff --git a/docs/glossary.md b/docs/glossary.md
index 480ef461ff..0166f01f63 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -26,4 +26,4 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i
- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes.
- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps.
-- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session.
+- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round). Round counters belong to that policy and do not count every turn in a session.
diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md
index 4247ccb5e6..0e0807f684 100644
--- a/packages/goal/goal/README.md
+++ b/packages/goal/goal/README.md
@@ -21,7 +21,7 @@ At most one goal is current. Creation produces an active revision-one goal and a
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, 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. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
+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.
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation.
@@ -51,3 +51,4 @@ Append-only within an epoch: each mutation follows the reusable request prefix a
- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas.
- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer.
- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear.
+- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation.
diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts
index 726ceb6c53..c8583894ef 100644
--- a/packages/goal/goal/src/index.ts
+++ b/packages/goal/goal/src/index.ts
@@ -64,12 +64,19 @@ export interface ResolvedConfig {
defaultMaxGoalRounds: number
}
+/** One accepted mutation waiting to enter or be observed in the session log. */
+interface PendingGoalChange {
+ readonly change: GoalChangeMeta
+ readonly activation: GoalActivation
+ applied: boolean
+}
+
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
interface GoalCache {
readonly state: GoalFoldState
activation: GoalActivation
observedSeq: number
- readonly pending: GoalChangeMeta[]
+ readonly pending: PendingGoalChange[]
}
/** Validate a caller-visible positive safe-integer round cap. */
@@ -358,15 +365,21 @@ export class GoalService extends Service {
const change = decodeGoalEvent(event)
if (change !== undefined) {
const pending = cache.pending[0]
- if (pending !== undefined && sameChange(pending, change)) {
+ if (pending !== undefined && sameChange(pending.change, change)) {
+ if (!pending.applied) {
+ applyGoalChange(cache.state, change)
+ cache.activation = pending.activation
+ pending.applied = true
+ }
cache.pending.shift()
+ cache.observedSeq += 1
continue
}
}
}
applyGoalEvent(cache.state, event)
+ cache.observedSeq += 1
}
- cache.observedSeq = session.seq
}
/** Build a new revision with one replacement phase. */
@@ -464,14 +477,26 @@ export class GoalService extends Service {
const meta = snapshotJsonValue(change) as JsonValue | undefined
/* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */
if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable')
- agent.inject(renderGoalChange(change), {
- source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
- envelope: 'raw',
- meta,
- })
- cache.pending.push(change)
- applyGoalChange(cache.state, change)
- cache.activation = activation
+ const pending: PendingGoalChange = { change, activation, applied: false }
+ cache.pending.push(pending)
+ try {
+ agent.inject(renderGoalChange(change), {
+ source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
+ envelope: 'raw',
+ meta,
+ })
+ } catch (error: unknown) {
+ const index = cache.pending.indexOf(pending)
+ /* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
+ if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
+ cache.pending.splice(index, 1)
+ throw error
+ }
+ if (!pending.applied) {
+ applyGoalChange(cache.state, change)
+ cache.activation = activation
+ pending.applied = true
+ }
this.sync(agent.session, cache)
const goal = this.view(cache)
const notification: GoalChanged = {
diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts
index c8aa5bc3e3..4dcae8938a 100644
--- a/packages/goal/goal/tests/goal.spec.ts
+++ b/packages/goal/goal/tests/goal.spec.ts
@@ -235,6 +235,25 @@ describe('GoalService creation and replay', () => {
expect(() => foldGoal(session.events)).not.toThrow()
})
+ it('removes the service and its session-start listener with the providing fiber', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ const fiber = await ctx.plugin(GoalService)
+ const first = ctx.goals
+ const stub = stubAgent('goal-hmr')
+ ctx.agents.register(stub.agent)
+ const goal = first.create(stub.agent, { objective: 'survive service reload' })
+
+ await fiber.dispose()
+ expect(ctx.get('goals')).toBeUndefined()
+ agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume')
+ expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
+
+ await ctx.plugin(GoalService)
+ expect(ctx.goals).not.toBe(first)
+ expect(ctx.goals.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'disarmed' })
+ })
+
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) }
@@ -404,6 +423,46 @@ describe('GoalService mutations', () => {
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
})
+ it('publishes a mutation consistently to a reentrant session observer', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(GoalService)
+ const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
+ 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)
+ })
+
+ const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
+
+ expect(observed).toEqual(created)
+ expect(ctx.goals.get(stub.agent)).toEqual(created)
+ expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } })
+ })
+
+ it('rolls back a pending mutation when injection rejects before append', async () => {
+ const ctx = new Context()
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(GoalService)
+ const stub = stubAgent('goal-rejected-injection')
+ const append = stub.agent.inject.bind(stub.agent)
+ let reject = true
+ stub.agent.inject = (content, options) => {
+ if (reject) throw new Error('injection rejected')
+ append(content, options)
+ }
+ ctx.agents.register(stub.agent)
+
+ expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected')
+ reject = false
+ expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({
+ objective: 'second attempt',
+ revision: 1,
+ })
+ })
+
it('rejects deferred goal mutations that enter the log out of FIFO order', async () => {
const test = await harness()
test.setDeferred(true)
@@ -447,6 +506,39 @@ describe('GoalService mutations', () => {
activation: 'disarmed',
})
})
+
+ it('reports the same corrupt unseen event after committing its valid prefix', async () => {
+ const { ctx, agent, session } = await harness()
+ expect(ctx.goals.get(agent)).toBeUndefined()
+ const change: GoalSnapshotChangeMeta = {
+ kind: 'goal/change',
+ version: 1,
+ operation: 'create',
+ goal: {
+ id: GoalId('goal-valid-prefix'),
+ revision: 1,
+ objective: 'valid prefix',
+ phase: 'active',
+ maxGoalRounds: 4,
+ },
+ roundsStarted: 0,
+ createdAt: 12,
+ updatedAt: 12,
+ }
+ appendInjection(session, renderGoalChange(change), {
+ source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 },
+ envelope: 'raw',
+ meta: change as never,
+ })
+ appendInjection(session, [{ type: 'text', text: 'corrupt' }], {
+ source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 },
+ envelope: 'raw',
+ meta: { ...change, operation: 'edit', extra: true } as never,
+ })
+
+ expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
+ expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
+ })
})
describe('goal replay validation', () => {
diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md
index 146cb04193..8789074e36 100644
--- a/website/zh-CN/api/harness/goals.md
+++ b/website/zh-CN/api/harness/goals.md
@@ -6,7 +6,7 @@
Goal service (`ctx.goals`) backed exclusively by the owning session log.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L97)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L104)
### ctx.goals.resolveCreate(request)
@@ -25,7 +25,7 @@ Materialize deployment defaults and validate one create request.
**Returns** detached, fully resolved create specification.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L122)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L129)
### ctx.goals.get(agent)
@@ -45,7 +45,7 @@ Read the current goal for one exact live agent.
**Returns** a fresh view or `undefined` when no goal is current.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L142)
### ctx.goals.create(agent, request)
@@ -67,7 +67,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha
**Returns** the created live view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L156)
### ctx.goals.edit(agent, ref, request)
@@ -90,7 +90,7 @@ Edit objective and/or round cap without changing phase.
**Returns** the edited view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L181)
### ctx.goals.pause(agent, ref)
@@ -111,7 +111,7 @@ Pause an active goal and disarm automatic continuation.
**Returns** the paused view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L202)
### ctx.goals.resume(agent, ref)
@@ -133,7 +133,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg
**Returns** the active view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L213)
### ctx.goals.complete(agent, ref)
@@ -154,7 +154,7 @@ Mark a current non-complete goal complete and disarm it.
**Returns** the completed view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L238)
### ctx.goals.block(agent, ref)
@@ -175,7 +175,7 @@ Mark an active goal blocked and disarm it.
**Returns** the blocked view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L255)
### ctx.goals.markUsageLimited(agent, ref)
@@ -196,7 +196,7 @@ Mark an active goal stopped by an external usage limit.
**Returns** the usage-limited view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L265)
### ctx.goals.markBudgetLimited(agent, ref)
@@ -217,7 +217,7 @@ Mark an active goal stopped at its configured round cap.
**Returns** the budget-limited view.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275)
### ctx.goals.clear(agent, ref)
@@ -238,4 +238,4 @@ Clear the current goal while retaining a durable tombstone and history.
**Returns** the tombstone ref whose revision is one past the cleared snapshot.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L302)
From 89f9900be1ad5d5b02c376c9dd61eaf9bc143e01 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 08:47:05 +0800
Subject: [PATCH 2/5] fix(goal): preserve interactive human turns
---
...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +-
.../2026-07-19-model-facing-goal-tools.md | 7 +-
.../2026-07-19-model-facing-goal-tools.zh.md | 7 +-
docs/cordis-catalog/events.md | 30 ++++-----
docs/event-producer-consumer.md | 30 ++++-----
docs/tool-catalog.md | 6 +-
.../fixtures/goal/tool-goal/scripted-llm.ts | 4 +-
packages/core/agent/README.md | 2 +-
packages/core/agent/src/types.ts | 5 +-
packages/goal/tool-goal/README.md | 5 +-
packages/goal/tool-goal/src/authority.ts | 6 +-
packages/goal/tool-goal/src/index.ts | 47 +++++++------
.../goal/tool-goal/tests/tool-goal.e2e.ts | 2 +-
.../goal/tool-goal/tests/tool-goal.spec.ts | 67 +++++++++++++++++--
website/zh-CN/api/harness/events.md | 30 ++++-----
15 files changed, 163 insertions(+), 89 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
index af951c46ff..f857c5360e 100644
--- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.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-19-model-facing-goal-tools.md: 3d9a33566d3ae27f803cbe56b7464bd1da17c1eb
-2026-07-19-model-facing-goal-tools.zh.md: 45ca2dfdc3523a3598add845224cda6f58d2c738
+2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1
+2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038
diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md
index 3d9a33566d..e37eaabecf 100644
--- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md
+++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md
@@ -22,13 +22,13 @@ The prompt tells the model that it may infer goal intent from a direct human req
All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state.
-A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution.
+An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding.
### Execution authority
Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments.
-Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model.
+Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: `Agent.send()` and `steer()` default an omitted source to `{ kind: 'user' }`, so non-human producers must label their own content. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model.
Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately.
@@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g
## Testing
-Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text.
+Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text.
## Alternatives considered
@@ -61,5 +61,6 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred.
- These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors.
+- Goal-round authority is dormant unless a separately mounted continuation driver admits goal-sourced user turns; this tool package never manufactures that authority itself.
- Human slash-command discovery and rendering are deferred to the command-surface layer.
- A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together.
diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md
index 45ca2dfdc3..c0d289271d 100644
--- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md
@@ -22,13 +22,13 @@ Status: implemented
三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。
-成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。
+自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。
### 执行权限
每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。
-创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。
+创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。
完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。
@@ -38,7 +38,7 @@ Status: implemented
## 测试
-单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。
+单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。
## 考虑过的替代方案
@@ -61,5 +61,6 @@ Status: implemented
- 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。
- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。
+- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。
- 面向人类的斜杠命令发现与渲染延期到命令表面层。
- 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index c90eacde5e..cc27559e84 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -33,7 +33,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:147`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
@@ -53,7 +53,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:156`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -75,7 +75,7 @@ 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:311`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts)
### `agent/post-step` — serial
@@ -98,7 +98,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:264`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — serial
@@ -121,7 +121,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:204`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
@@ -142,7 +142,7 @@ Allow, rewrite, or block one drained 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:214`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts)
### `agent/queued` — emit
@@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
-Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
@@ -186,7 +186,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:226`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall
@@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens
Types: [Agent](../core-data-structures/core.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:278`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts)
### `agent/session-prefix` — waterfall
@@ -237,7 +237,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:241`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -259,7 +259,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:188`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -279,7 +279,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:165`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts)
### `agent/step-result` — waterfall
@@ -301,7 +301,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:252`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts)
### `agent/turn-continuation` — waterfall
@@ -322,7 +322,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:288`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts)
### `agent/turn-stop` — serial
@@ -343,7 +343,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:298`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index d48349cdf0..b7a7f6bb12 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -8,21 +8,21 @@ 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:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) |
-| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../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:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) |
-| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../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:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
-| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../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) |
-| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../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/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) |
+| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
+| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../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:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) |
+| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../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:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
+| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../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) |
+| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../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:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 2bd4f5d3de..3b63dceb1c 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -410,7 +410,7 @@ Create one persisted same-session completion goal when the current direct human
},
"max_goal_rounds": {
"type": "number",
- "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default."
+ "description": "Optional positive safe-integer limit on automatic continuation rounds."
}
},
"required": [
@@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `get_goal`
-Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.
+Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.
```json
{
@@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `update_goal`
-Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.
+Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.
```json
{
diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts
index 7b635ba0bb..310737050d 100644
--- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts
+++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts
@@ -1,4 +1,4 @@
-/** Deterministic adapter that creates, reads, then pauses one goal. */
+/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */
import type { Context } from 'cordis'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
@@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter {
if (goal === undefined) throw new Error('scripted goal state missing')
return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' })
}
- if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE')
+ if (prompt.text === 'pause') return textReply('GOAL PAUSED')
return textReply('UNEXPECTED PROMPT')
}
}
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index c2b46f210d..6d2df3d957 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
-- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source 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 (`agent/prompt-submit` still rewrites by returning replacement content).
+- `agent.send(content, options?)` — queue a message; starts a turn when idle. 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 and resolved source 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 (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `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(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts
index 702861f407..e9ee359da8 100644
--- a/packages/core/agent/src/types.ts
+++ b/packages/core/agent/src/types.ts
@@ -25,7 +25,10 @@ export interface AgentOptions {
model?: string
}
-/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */
+/**
+ * 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.
+ */
export interface SendOptions {
source?: MessageSource
}
diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md
index 42f62b7ce9..1ac03ac04a 100644
--- a/packages/goal/tool-goal/README.md
+++ b/packages/goal/tool-goal/README.md
@@ -10,12 +10,14 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
-A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation.
+An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop.
## Authority
Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
+`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
+
Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately.
## Config
@@ -70,4 +72,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged.
- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal.
- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred.
- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers.
+- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them.
- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together.
diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts
index a2238aab9c..41fe713dc6 100644
--- a/packages/goal/tool-goal/src/authority.ts
+++ b/packages/goal/tool-goal/src/authority.ts
@@ -62,7 +62,11 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
return { agent, ...openTurn(agent) }
}
-/** Whether an accepted human message appears in the current root-agent turn. */
+/**
+ * Whether host-attested human input appears in the current root-agent turn.
+ * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
+ * producers must supply their own source rather than inheriting this authority.
+ */
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
if (!ctx.agents.roots().includes(execution.agent)) return false
return execution.events.some(event =>
diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts
index 29068a4afa..5c395898b7 100644
--- a/packages/goal/tool-goal/src/index.ts
+++ b/packages/goal/tool-goal/src/index.ts
@@ -50,8 +50,8 @@ const CREATE_DESCRIPTION =
+ 'trivial single-turn work. Execution rejects non-human and subagent authority.'
const GET_DESCRIPTION =
- 'Read the current same-session goal, including its exact id/revision, durable phase, admitted '
- + 'round count, cap, and live process-local activation. Call this before updating a goal.'
+ 'Read the current same-session goal, including its exact id/revision, objective, phase, completed '
+ + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.'
/** Render policy guidance with its deployment-selected blocked threshold. */
function guidance(blockedAfter: number): string {
@@ -107,13 +107,14 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } }
}
-/** Remember whether one successful mutation makes this turn terminal. */
+/** Remember whether one autonomous terminal report should stop this turn. */
function observeMutation(
terminalTurns: WeakMap,
execution: GoalToolExecution,
goal: GoalView,
+ autonomousTerminal: boolean,
): void {
- if (goal.phase === 'active' && goal.activation === 'armed') {
+ if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) {
terminalTurns.delete(execution.agent)
return
}
@@ -123,6 +124,8 @@ function observeMutation(
/** Register the three Codex-shaped goal tools and their shared policy section. */
export function apply(ctx: Context, config: Config): void {
const resolved = resolveConfig(config)
+ // A stale entry cannot match a later loop turn because turn numbers increase
+ // monotonically within the agent's fixed session.
const terminalTurns = new WeakMap()
ctx.on('agent/turn-stop', (agent, turn) => {
if (terminalTurns.get(agent) !== turn) return undefined
@@ -160,7 +163,7 @@ export function apply(ctx: Context, config: Config): void {
},
max_goal_rounds: {
type: 'number',
- description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.',
+ description: 'Optional positive safe-integer limit on automatic continuation rounds.',
},
},
execute(args, exec) {
@@ -170,7 +173,7 @@ export function apply(ctx: Context, config: Config): void {
objective: args.objective,
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
})
- observeMutation(terminalTurns, execution, goal)
+ observeMutation(terminalTurns, execution, goal, false)
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
},
presentCall: args => present('Create goal', 'other', args.objective),
@@ -179,8 +182,8 @@ export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'update_goal',
description: 'Update the exact current goal revision. edit, pause, and resume require a direct '
- + 'top-level human turn. complete and blocked additionally accept the exact admitted goal '
- + 'round. blocked is rejected before the configured minimum round count; the model remains '
+ + 'top-level human request. During an automatic continuation of the current goal, complete '
+ + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains '
+ 'responsible for judging that the same condition persisted across those rounds.',
parameters: {
goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' },
@@ -204,27 +207,33 @@ export function apply(ctx: Context, config: Config): void {
if (args.action === 'edit') {
requireDirectHuman(ctx, execution)
const goal = ctx.goals.edit(execution.agent, ref, replacements)
- observeMutation(terminalTurns, execution, goal)
+ observeMutation(terminalTurns, execution, goal, false)
return Promise.resolve([{
type: 'text',
text: renderGoal(goal),
}])
}
+ if (args.action === 'pause' || args.action === 'resume') {
+ requireDirectHuman(ctx, execution)
+ if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
+ throw new HarnessError(
+ 'objective and max_goal_rounds are valid only with action edit',
+ 'GOAL_TOOL_INVALID_UPDATE',
+ )
+ }
+ const goal = args.action === 'pause'
+ ? ctx.goals.pause(execution.agent, ref)
+ : ctx.goals.resume(execution.agent, ref)
+ observeMutation(terminalTurns, execution, goal, false)
+ return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
+ }
+ const authority = completionAuthority(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit',
'GOAL_TOOL_INVALID_UPDATE',
)
}
- if (args.action === 'pause' || args.action === 'resume') {
- requireDirectHuman(ctx, execution)
- const goal = args.action === 'pause'
- ? ctx.goals.pause(execution.agent, ref)
- : ctx.goals.resume(execution.agent, ref)
- observeMutation(terminalTurns, execution, goal)
- return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
- }
- const authority = completionAuthority(ctx, execution)
if (args.action === 'blocked' && authority.kind === 'goal-round'
&& authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) {
throw new HarnessError(
@@ -236,7 +245,7 @@ export function apply(ctx: Context, config: Config): void {
const goal = args.action === 'complete'
? ctx.goals.complete(execution.agent, ref)
: ctx.goals.block(execution.agent, ref)
- observeMutation(terminalTurns, execution, goal)
+ observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round')
return Promise.resolve([{ type: 'text', text: renderGoal(goal) }])
},
presentCall: args => present(
diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts
index 34f7bf243c..b19a4ee851 100644
--- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts
@@ -101,7 +101,7 @@ describe('goal tools through a real Loader, app, and stdio process', () => {
expect(stdout).toContain('goal-tools e2e ready.')
expect(stdout).toContain('GOAL CREATED')
expect(stdout).toContain(PAUSED_RESULT)
- expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE')
+ expect(stdout).toContain('GOAL PAUSED')
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
expect(logs).toHaveLength(1)
diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
index 2532b16a15..3950bf1e63 100644
--- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
@@ -7,7 +7,7 @@ import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
-import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -20,8 +20,8 @@ interface StubAgent {
}
/** Build one registry-compatible live agent whose injections append in place. */
-function stubAgent(rawId: string): StubAgent {
- const session = new Session(SessionId(rawId))
+function stubAgent(rawId: string, supplied?: Session): StubAgent {
+ const session = supplied ?? new Session(SessionId(rawId))
let status: AgentStatus = 'running'
const agent: Agent = {
id: session.id,
@@ -219,6 +219,42 @@ describe('goal tool execution authority', () => {
expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
+ 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 }
+ const staleResult = await execute(ctx, 'get_goal', {}, stale, stale)
+ expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
+
+ root.setStatus('idle')
+ const idleResult = await execute(ctx, 'get_goal', {}, root.agent)
+ expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
+ })
+
+ it('treats a fork resumed as a runtime root as direct-human authority', async () => {
+ const { ctx, root } = await harness()
+ const originalTurn = openTurn(root, { kind: 'user' })
+ const created = ctx.goals.create(root.agent, { objective: 'resume the fork' })
+ closeTurn(root, originalTurn)
+ const forkId = SessionId('goal-tool-resumed-fork')
+ const forkSession = new Session(forkId, root.session.events, {
+ version: SESSION_FORMAT_VERSION,
+ id: forkId,
+ createdAt: Date.now(),
+ parentSession: root.session.id,
+ seedLength: root.session.seq,
+ })
+ const fork = stubAgent(forkId, forkSession)
+ ctx.agents.register(fork.agent)
+ expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' })
+
+ openTurn(fork, { kind: 'user' }, '继续这个目标')
+ const resumed = await execute(ctx, 'update_goal', {
+ goal_id: created.id, revision: created.revision, action: 'resume',
+ }, fork.agent)
+ expect(resultGoal(resumed)).toMatchObject({ id: created.id, revision: 2, phase: 'active' })
+ })
+
it('rejects calls before a turn and after its end boundary', async () => {
const { ctx, root } = await harness()
const before = await execute(ctx, 'get_goal', {}, root.agent)
@@ -237,6 +273,10 @@ describe('goal tool execution authority', () => {
goal_id: 'goal-missing', revision: 1, action: 'complete',
}, root.agent)
expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
+ const malformed = await execute(ctx, 'update_goal', {
+ goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe',
+ }, root.agent)
+ expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
it('accepts direct human steering in a goal-sourced root turn', async () => {
@@ -290,16 +330,29 @@ describe('goal tool state transitions', () => {
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined()
})
- it('stops the current turn after a successful stopped-state mutation', async () => {
+ it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
const { ctx, root } = await harness()
- openTurn(root, { kind: 'user' })
+ const humanTurn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' })
const paused = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'pause',
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
- expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' })
- expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined()
+ expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn)).toBeUndefined()
+ const resumed = resultGoal(await execute(ctx, 'update_goal', {
+ goal_id: created.id, revision: 2, action: 'resume',
+ }, root.agent))
+ closeTurn(root, humanTurn)
+
+ const roundTurn = openTurn(root, {
+ kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1,
+ })
+ const complete = await execute(ctx, 'update_goal', {
+ goal_id: created.id, revision: resumed['revision'], action: 'complete',
+ }, root.agent)
+ expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
+ expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' })
+ expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined()
})
it('rearms a restored active goal only after a new direct human prompt', async () => {
diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md
index ebe71dc559..a880973ba5 100644
--- a/website/zh-CN/api/harness/events.md
+++ b/website/zh-CN/api/harness/events.md
@@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o
- `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L150)
### agent/disposed
@@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef
- `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L159)
### agent/error
@@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
- `step` — the step at which the failure surfaced.
- `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L314)
### agent/post-step
@@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in
- `step` — the open step number.
- `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L267)
### agent/pre-step
@@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending
- `step` — the pending step number.
- `signal` — the turn abort signal.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L207)
### agent/prompt-submit
@@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca
- `content` — the drained message's blocks, as queued.
- `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L217)
### agent/queued
@@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already
- `content` — the accepted content blocks retained by the inbox.
- `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L178)
### agent/request
@@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha
- `step` — the step whose request this is.
- `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L229)
### agent/request-error
@@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens
- `retryAttempt` — zero-based number of prior recovery retries.
- `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L281)
### agent/session-prefix
@@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i
- `prefix` — the frozen seed; return an extended replacement.
- `signal` — aborts composition when the step is torn down.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L244)
### agent/session-start
@@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
- `agent` — the agent whose session lifecycle began.
- `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L191)
### agent/status
@@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
- `agent` — the agent whose status flipped.
- `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L168)
### agent/step-result
@@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
- `step` — the step that produced the message.
- `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L255)
### agent/turn-continuation
@@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s
- `turn` — the turn being continued or stopped.
- `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L291)
### agent/turn-stop
@@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a
- `agent` — the agent whose composed continuation outcome may be stopped.
- `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L301)
## agent-loop/*
From 979fbbd96108a8fa6979992a1ac86cd14c2f612c Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:07:15 +0800
Subject: [PATCH 3/5] fix(goal): harden driver cancellation settlement
---
...9-same-session-goal-round-driver.i18n.yaml | 4 +-
...26-07-19-same-session-goal-round-driver.md | 8 +--
...07-19-same-session-goal-round-driver.zh.md | 8 +--
packages/goal/goal-session/README.md | 7 +--
packages/goal/goal-session/src/index.ts | 16 ++++--
.../goal-session/tests/goal-session.spec.ts | 49 ++++++++++++++++++-
6 files changed, 74 insertions(+), 18 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml
index 9a5cf40202..d18d50594b 100644
--- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.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-19-same-session-goal-round-driver.md: 2fc27ab28bff5e192e43d30afc6cd796fd3394e1
-2026-07-19-same-session-goal-round-driver.zh.md: e9610c94eca48fb4d06be505a8cdfffb6c272012
+2026-07-19-same-session-goal-round-driver.md: 94c0a870c46d6b7f618faf3f0a4b01c141bec358
+2026-07-19-same-session-goal-round-driver.zh.md: 9fdff05ffb722ef683bc06acee606f7897e5afc6
diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md
index 2fc27ab28b..94c0a870c4 100644
--- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md
+++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md
@@ -39,7 +39,7 @@ The driver classifies one closed goal-owned turn as follows:
| Turn result | Action |
|---|---|
| durable `completed` | continue while active/armed and under cap |
-| broad cancellation / `aborted` | pause and disarm |
+| cancellation of a reserved/admitted goal round, or its `aborted` result | pause and disarm |
| `error` with code `RATE_LIMIT` | mark `usage-limited` |
| other `error` | block |
| `max-tokens` | block |
@@ -52,9 +52,9 @@ No abnormal outcome requests an automatic retry. A later human prompt can ask to
### Durability and cancellation seam
-Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver associates it with the exact attempt and disarms before the next idle decision.
+Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver finds that exact closed turn even when a concurrent one-shot injection appended a later turn, associates the failure with the exact attempt, and disarms before the next idle decision.
-Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation and pause an active armed goal before the loop destroys the queued-work evidence.
+Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation before the loop destroys the queued-work evidence. When that reservation is a queued or admitted goal attempt, cancellation durably pauses the goal; when cancellation belongs to unrelated human work with no goal attempt, it only removes process-local activation. If the pause mutation throws, the driver falls back to disarming rather than allowing cancelled automatic work to restart.
This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it.
@@ -68,7 +68,7 @@ An inbox acceptance can win the microtask race immediately before plugin unload
## Testing
-The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage.
+The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, unrelated-human cancellation, failed-pause fallback, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints including a later one-shot injection, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage.
A keyless Loader/stdio process test mounts the real goal domain, goal tools, goal driver, agent loop, persistence, and deterministic adapter through `cordis.yml`. One human turn creates a two-round goal; round one stops normally; round two reads the exact ref and completes it. The external JSONL assertion proves one session, round sources `1, 2`, unchanged round revision, final complete revision, five model steps, and no extra request after the terminal completion tool.
diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md
index e9610c94ec..9fdff05ffb 100644
--- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md
@@ -39,7 +39,7 @@ Status: implemented
| 轮次结果 | 动作 |
|---|---|
| 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 |
-| 广义取消 / `aborted` | 暂停并解除激活 |
+| 取消已预留/接纳的目标回合,或该回合产生 `aborted` 结果 | 暂停并解除激活 |
| 代码为 `RATE_LIMIT` 的 `error` | 标记为 `usage-limited` |
| 其他 `error` | 阻塞 |
| `max-tokens` | 阻塞 |
@@ -52,9 +52,9 @@ Status: implemented
### 持久性与取消接缝
-每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;驱动器把它关联到精确尝试,并在下一次空闲决策前解除激活。
+每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;即使并发的一次性注入已追加后续轮次,驱动器仍会找到该精确的已关闭轮次,把失败关联到精确尝试,并在下一次空闲决策前解除激活。
-广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿清除预留并暂停 active 且 armed 的目标,之后循环才销毁排队工作证据。
+广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿在循环销毁排队工作证据前清除预留。若该预留是排队中或已接纳的目标尝试,取消会持久暂停目标;若取消属于没有目标尝试的无关人类工作,则只移除进程内激活态。若暂停变更抛错,驱动器会回退到解除激活,避免已取消的自动工作重新启动。
该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。
@@ -68,7 +68,7 @@ Status: implemented
## 测试
-单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。
+单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、无关人类工作取消、暂停失败回退、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败(包括后续一次性注入)、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。
无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实目标领域、目标工具、目标驱动器、agent loop、持久化和确定性适配器。一个人类轮次创建两回合目标;第一回合正常停止;第二回合读取精确引用并完成目标。测试从外部检查 JSONL,证明只有一个会话、回合来源依次为 `1, 2`、回合修订号不变、最终完成修订正确、共有五个模型步骤,并且终止性的完成工具后没有额外请求。
diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md
index d962e84029..0f3f0861b6 100644
--- a/packages/goal/goal-session/README.md
+++ b/packages/goal/goal-session/README.md
@@ -30,7 +30,8 @@ The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, t
| Durable turn outcome | Goal action | Automatic retry |
|---|---|---|
| `completed` with goal still active and armed | admit the next round, or mark `budget-limited` at the cap | yes |
-| broad cancellation / `aborted` | `paused` | no |
+| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no |
+| cancellation with no goal-round attempt | keep durable phase; disarm activation | no |
| `error` with `RATE_LIMIT` | `usage-limited` | no |
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` | no |
| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no |
@@ -39,11 +40,11 @@ A goal mutation made during its round supersedes settlement of the older revisio
## Lifecycle and durability
-`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver disarms before another round can start.
+`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start.
Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling.
-Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step, allowing this plugin to pause and disarm the exact active goal. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed.
+Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed.
## Model Experience
diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts
index 09ff85cac4..524bd5160a 100644
--- a/packages/goal/goal-session/src/index.ts
+++ b/packages/goal/goal-session/src/index.ts
@@ -276,8 +276,8 @@ export function apply(ctx: Context): void {
/** Mark a post-turn persistence failure before idle scheduling can run. */
ctx.on('agent/error', (agent, turn) => {
const state = stateFor(agent)
- const last = agent.session.events.at(-1)
- if (last?.type !== 'turn/end' || last.data.turn !== turn) return
+ const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn)
+ if (!closed) return
if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn)
disarm(state)
})
@@ -312,11 +312,21 @@ export function apply(ctx: Context): void {
})
ctx.on('agent/cancel-requested', (agent, reason) => {
const state = stateFor(agent)
+ const attempt = state.attempt
state.attempt = undefined
state.competingQueued = false
const goal = currentGoal(state)
if (goal?.phase === 'active' && goal.activation === 'armed') {
- applyOutcome(state, goal, { kind: 'pause', reason })
+ if (attempt === undefined) {
+ disarm(state)
+ return
+ }
+ try {
+ applyOutcome(state, goal, { kind: 'pause', reason })
+ } catch (error: unknown) {
+ ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
+ disarm(state)
+ }
}
})
ctx.on('goal/changed', (agent) => {
diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts
index 13d6d9bf3f..a1ff54901a 100644
--- a/packages/goal/goal-session/tests/goal-session.spec.ts
+++ b/packages/goal/goal-session/tests/goal-session.spec.ts
@@ -402,12 +402,17 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
- it('disarms an admitted round whose closing durability checkpoint fails', async () => {
+ it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => {
const test = await harness([textResponse('not durable')])
+ let injected = false
test.ctx.on('session/flush', (session) => {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message'
- && lastStart.data.trigger.source.kind === 'goal') {
+ && lastStart.data.trigger.source.kind === 'goal' && !injected) {
+ injected = true
+ test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], {
+ source: { kind: 'plugin', plugin: 'test' },
+ })
return Promise.reject(new Error('round flush failed'))
}
})
@@ -421,6 +426,10 @@ describe('same-session goal driving', () => {
expect(goal?.phase).toBe('active')
expect(test.adapter.requests).toHaveLength(1)
+ const turns = test.agent.session.events.filter(event => event.type === 'turn/start')
+ const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal')
+ const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin')
+ expect(injectedTurn).toBeGreaterThan(goalTurn)
})
it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
@@ -559,6 +568,42 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
+ it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => {
+ const test = await harness(['hang'])
+ test.agent.send([{ type: 'text', text: 'inspect something first' }])
+ await waitForRequests(test.adapter, 1)
+ const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
+
+ test.agent.cancel('cancel the inspection')
+ await test.agent.whenIdle()
+
+ expect(test.ctx.goals.get(test.agent)).toMatchObject({
+ id: created.id,
+ revision: created.revision,
+ phase: 'active',
+ activation: 'disarmed',
+ roundsStarted: 0,
+ })
+ })
+
+ 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) => {
+ if (agent !== test.agent || info.source.kind !== 'goal') return
+ cancel()
+ vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
+ throw new Error('pause failed')
+ })
+ agent.cancel('cancel the reserved goal round')
+ })
+ test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' })
+
+ const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed')
+
+ expect(goal).toMatchObject({ phase: 'active', revision: 1, roundsStarted: 0 })
+ expect(test.adapter.requests).toHaveLength(0)
+ })
+
it('blocks admission when downstream cancellation clears the reservation', async () => {
const test = await harness([])
let cancelled = false
From 6f4f459c73b336d0b82ba88d7e3b895e345f697b Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:30:51 +0800
Subject: [PATCH 4/5] test(goal): cover terminal update validation
---
packages/goal/tool-goal/tests/tool-goal.spec.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
index 3950bf1e63..044fe2369a 100644
--- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
@@ -384,6 +384,13 @@ describe('goal tool state transitions', () => {
objective: 'not valid for pause',
}, root.agent)
expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
+ const terminalUpdate = await execute(ctx, 'update_goal', {
+ goal_id: created.id,
+ revision: created.revision,
+ action: 'complete',
+ max_goal_rounds: 2,
+ }, root.agent)
+ expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
const malformedRef = await execute(ctx, 'update_goal', {
goal_id: '', revision: 0, action: 'edit', objective: 'x',
}, root.agent)
From 2dd3b1dfba6921216ba7d0168a15ac277a4e4cd8 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:24:02 +0800
Subject: [PATCH 5/5] fix(commands): harden registry and UI ordering
---
...7-19-plugin-command-registration.i18n.yaml | 4 +-
.../2026-07-19-plugin-command-registration.md | 6 +-
...26-07-19-plugin-command-registration.zh.md | 6 +-
docs/config-catalog.md | 2 +-
docs/cordis-catalog/events.md | 5 +-
docs/cordis-catalog/services.md | 2 +-
docs/event-producer-consumer.md | 2 +-
.../advanced-toolchain/stdout.expected.jsonl | 2 +-
.../bash-spill/stdout.expected.jsonl | 2 +-
.../both-mode-turn/stdout.expected.jsonl | 2 +-
.../cancel-tool-calls/stdout.expected.jsonl | 2 +-
.../snapshots/cancel/stdout.expected.jsonl | 2 +-
.../code-mode-turn/stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../config-options/stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../error-finish/stdout.expected.jsonl | 2 +-
.../escalation-approved/stdout.expected.jsonl | 2 +-
.../escalation-rejected/stdout.expected.jsonl | 2 +-
.../snapshots/fs-edit/stdout.expected.jsonl | 2 +-
.../fs-policy-reject/stdout.expected.jsonl | 2 +-
.../fs-read-window/stdout.expected.jsonl | 2 +-
.../snapshots/fs-read/stdout.expected.jsonl | 2 +-
.../fs-terminal-card/stdout.expected.jsonl | 2 +-
.../fs-write-overwrite/stdout.expected.jsonl | 2 +-
.../snapshots/fs-write/stdout.expected.jsonl | 2 +-
.../snapshots/handshake/stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../hook-cc-pretool-ask/stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../model-switching/stdout.expected.jsonl | 2 +-
.../multi-turn/stdout.expected.jsonl | 2 +-
.../parallel-tool-calls/stdout.expected.jsonl | 2 +-
.../stdout.expected.jsonl | 2 +-
.../repeat-tool-guard/stdout.expected.jsonl | 2 +-
.../skill-load/stdout.expected.jsonl | 2 +-
.../subagent-fork/stdout.expected.jsonl | 2 +-
.../subagent-mixed/stdout.expected.jsonl | 2 +-
.../subagent-multi/stdout.expected.jsonl | 2 +-
.../subagent-spawn/stdout.expected.jsonl | 2 +-
.../snapshots/text-turn/stdout.expected.jsonl | 2 +-
.../snapshots/todo-plan/stdout.expected.jsonl | 2 +-
.../tool-call-turn/stdout.expected.jsonl | 2 +-
.../workflow-run/stdout.expected.jsonl | 2 +-
.../workspace-context/stdout.expected.jsonl | 2 +-
.../workspace-edit/stdout.expected.jsonl | 2 +-
packages/ui/acp/README.md | 4 +-
packages/ui/acp/src/index.ts | 59 +++++++++++++++++--
packages/ui/acp/tests/commands.spec.ts | 33 ++++++++---
packages/ui/acp/tests/edges.spec.ts | 5 +-
packages/ui/commands/README.md | 2 +-
packages/ui/commands/src/index.ts | 52 +++++++++++++---
packages/ui/commands/tests/commands.spec.ts | 53 +++++++++++++++--
packages/ui/tui/src/index.ts | 8 ++-
packages/ui/tui/tests/tui.spec.ts | 29 +++++++++
website/zh-CN/api/harness/commands.md | 10 ++--
website/zh-CN/api/harness/events.md | 5 +-
67 files changed, 284 insertions(+), 101 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml
index e75c1f020f..48d880e666 100644
--- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.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-19-plugin-command-registration.md: 5b599358036054c644985474c9c16518fcb5042a
-2026-07-19-plugin-command-registration.zh.md: 08c9e13d122bc56c9feae35244e0f903c3b76b32
+2026-07-19-plugin-command-registration.md: 821f22405fd6a4bc0b8bfd3c5edf773be844899d
+2026-07-19-plugin-command-registration.zh.md: 7a2ed82eb1a8f55d4d701a68c4053e5d412b7d04
diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md
index 5b59935803..821f22405f 100644
--- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md
+++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md
@@ -26,7 +26,7 @@ A `CommandDefinition` contains a lowercase name without `/`, a non-empty descrip
An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph.
-Registration and removal emit the unfiltered `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers.
+Registration and removal emit the unfiltered, non-vetoing `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. The registry contains and logs each observer failure independently, so a broken UI refresh cannot roll back another plugin's mutation or starve a later observer. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers.
### Direct dispatch and cancellation
@@ -42,7 +42,7 @@ Each submitted command owns an `AbortController`. TUI disposal aborts outstandin
### ACP mapping
-The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`.
+The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; a new session's RPC response introduces its server-generated id before the snapshot is enqueued. Every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`.
ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`.
@@ -50,7 +50,7 @@ One model prompt or direct command may be in flight per ACP session, independent
## Testing
-The registry suite covers syntax boundaries, immutable normalization, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, change-notification rollback, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage.
+The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage.
TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes.
diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md
index 08c9e13d12..7a2ed82eb1 100644
--- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md
@@ -26,7 +26,7 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形
无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。
-注册和移除会发出未过滤的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。
+注册和移除会发出未过滤、不可否决的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。注册表会分别隔离并记录每个观察者失败,因此损坏的 UI 刷新无法回滚另一插件的变更,也无法阻止后续观察者。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。
### 直接分派与取消
@@ -42,7 +42,7 @@ TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit
### ACP 映射
-桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。
+桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;新会话的 RPC 响应会先引入服务端生成的 id,随后快照才会入队。每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。
ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。
@@ -50,7 +50,7 @@ ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的
## 测试
-注册表测试覆盖语法边界、不可变规范化、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知回滚、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。
+注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。
TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index d40c155123..5a46c699e5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -27,7 +27,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
-Source: [`packages/ui/acp/src/index.ts:217`](../packages/ui/acp/src/index.ts)
+Source: [`packages/ui/acp/src/index.ts:246`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 4c67788d82..3d23d95105 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -414,18 +414,19 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app
### `commands/change` — emit
-A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view.
+A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation.
```ts cordis-catalog
/**
* A command was registered or unregistered. This is an unfiltered registry
* notification because a global or scoped change may affect any UI view.
+ * Observer failures are contained and cannot veto the registry mutation.
* @mode emit
*/
'commands/change'(): void
```
-Source: [`packages/ui/commands/src/index.ts:93`](../../packages/ui/commands/src/index.ts)
+Source: [`packages/ui/commands/src/index.ts:94`](../../packages/ui/commands/src/index.ts)
## `fs/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index adff7993e5..113e4cc62d 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -382,7 +382,7 @@ async execute( agent: Agent, surface: CommandSurface, line: string, signal: Abor
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md)
-Source: [`packages/ui/commands/src/index.ts:216`](../../packages/ui/commands/src/index.ts)
+Source: [`packages/ui/commands/src/index.ts:235`](../../packages/ui/commands/src/index.ts)
## `ctx.compact` — `CompactService` (abstract seam)
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index b949abcb03..dfa2d1f11b 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:302`](../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) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:312`](../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:31`](../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:93`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`emit`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
+| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:94`](../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:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
index 50d688089e..6410d07df3 100644
--- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}}
diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
index 875ff05303..739deae606 100644
--- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
index 6c6e1d6158..d6964687d1 100644
--- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
index 1e1131398b..4d770492c5 100644
--- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
index 0277ef2f90..c17327002f 100644
--- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
@@ -1,5 +1,5 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
index 2e06d6839a..003205b364 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
index a41a9b8b3b..043607a926 100644
--- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
index 4e2f95accf..eb9762f95a 100644
--- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}}
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 50c05a4d18..e8b173bff2 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
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"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.\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(reason?: string): void;\n whenIdle(): Promise;\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 type ContextEnvelope = 'context' | 'raw';\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 envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\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 }\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 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"}}}
diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
index 80ab62b674..928ce6e2c9 100644
--- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl
@@ -1,4 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}}
diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
index e169016a1c..de4c76482c 100644
--- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
index 5a60174abb..e99bb3a5bb 100644
--- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
index 451fe781dd..40223acda3 100644
--- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
index 39ddf764de..635a185739 100644
--- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
index 2f2e1bc667..c2dcf2a731 100644
--- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
index 3fed936cfc..4ce2099ace 100644
--- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
index 51bb5f7c65..3244ee21c3 100644
--- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
index 9e0d057b2a..f8c7edd015 100644
--- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
index 927bb99975..e2e3bef55d 100644
--- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
index 1390e424d6..dab4affa7e 100644
--- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl
@@ -1,3 +1,3 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
index 89e476b3e8..653a7340f8 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
index b13a498bc0..2aa177932e 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
index 5acbeb7202..262ce3e2ea 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
index 623127e083..094e78be67 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
index 19ec84b738..a6accd8f18 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl
@@ -1,4 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
index 3f75a15f57..44a5da1302 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
index bf478888b4..aad4e1a5b1 100644
--- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
index b8b6abe2c3..05fc598709 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
index c8c34ab6ec..bc4266cc04 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
index 8e303806a9..73e123a566 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
index 19ec84b738..a6accd8f18 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl
@@ -1,4 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
index 6107da23da..2c602e4cc1 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}}
diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
index ef02915d01..96df6e2ef4 100644
--- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
index e3a5aacbeb..e50144c03e 100644
--- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
index 57dd33f320..a9da0b4a4f 100644
--- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
index 53048fa11d..946a91f75a 100644
--- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
index 2232cd3a1c..53197fe328 100644
--- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
index bc40beb3fe..f1c2fd0634 100644
--- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
index 8436971982..3e17783db5 100644
--- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
index a332ea7a7c..dd21b9674f 100644
--- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
index ded1ec01cc..7bfd214919 100644
--- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
index d25e78d0db..4916b87bc8 100644
--- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
index d1f79bacac..22e034b251 100644
--- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
index dc7b4dbe25..6171c640e4 100644
--- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
index 0cae765592..ee40cae2fd 100644
--- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
index dce9237834..9183664a0f 100644
--- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
index 73604add34..24333f95a0 100644
--- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
index 8108f47bb0..3b233174fc 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
index ff2610f1f5..38c5dcdfc1 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md
index 3e74236f81..d74a0fc573 100644
--- a/packages/ui/acp/README.md
+++ b/packages/ui/acp/README.md
@@ -41,9 +41,9 @@ One id-keyed record map plus exact agent-object checks route every event, prompt
## Human commands
-After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
+After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
-ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
+ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
## Session config options
diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts
index 3d4c060060..459dc50a43 100644
--- a/packages/ui/acp/src/index.ts
+++ b/packages/ui/acp/src/index.ts
@@ -17,6 +17,7 @@ import {
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
+ type AnyMessage,
type AuthenticateRequest,
type AvailableCommand,
type CancelNotification,
@@ -94,6 +95,34 @@ function renderThrown(value: unknown): string {
}
}
+/** Return a server-created session id carried by an outbound success response. */
+function responseSessionId(message: AnyMessage): SessionId | undefined {
+ if (!('result' in message) || typeof message.result !== 'object' || message.result === null
+ || !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') {
+ return undefined
+ }
+ return SessionId(message.result.sessionId)
+}
+
+/** Observe messages only after the wrapped ACP transport has written them. */
+function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream {
+ const writer = stream.writable.getWriter()
+ return {
+ readable: stream.readable,
+ writable: new WritableStream({
+ async write(message) {
+ await writer.write(message)
+ onWritten(message)
+ },
+ /* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream;
+ preserve the wrapped Stream contract for other consumers nonetheless */
+ close: () => writer.close(),
+ abort: (reason: unknown) => writer.abort(reason),
+ /* v8 ignore stop */
+ }),
+ }
+}
+
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
@@ -393,6 +422,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessions = new Map()
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
const loadingIds = new Set()
+ // A new-session response introduces its server-generated id to the client;
+ // keep its initial command snapshot pending until that response is written.
+ const pendingCommandSnapshots = new Map()
// Async creation checks this after awaits to avoid publishing after teardown.
let closed = false
// Each new or loaded session snapshots the latest connection capability.
@@ -499,10 +531,23 @@ export function apply(ctx: Context, config: AcpConfig): void {
})
}
+ /** Enqueue a new session's first command snapshot behind its written RPC response. */
+ const announceInitialCommands = (message: AnyMessage): void => {
+ const sessionId = responseSessionId(message)
+ if (sessionId === undefined) return
+ const rec = pendingCommandSnapshots.get(sessionId)
+ if (rec === undefined) return
+ pendingCommandSnapshots.delete(sessionId)
+ notifyCommands(rec)
+ }
+
// Registration and HMR removal can affect global or one scoped view; refresh
- // every bridge-owned session and let the registry resolve each exact agent.
+ // every announced bridge-owned session and let the registry resolve each
+ // exact agent. A pending new-session snapshot will read the latest registry.
ctx.on('commands/change', () => {
- for (const rec of sessions.values()) notifyCommands(rec)
+ for (const rec of sessions.values()) {
+ if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec)
+ }
})
/** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
@@ -711,7 +756,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw internalError('connection closed during session/new')
}
- sessions.set(sessionId, {
+ const record: SessionRecord = {
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
@@ -720,8 +765,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
inflight: undefined,
commandAbort: undefined,
pendingSwitches: {},
- })
- notifyCommands(requireSession(sessionId))
+ }
+ sessions.set(sessionId, record)
+ pendingCommandSnapshots.set(sessionId, record)
const configOptions = configOptionsFor(handle.agent, directory)
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
},
@@ -998,7 +1044,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
Writable.toWeb(process.stdout) as WritableStream,
Readable.toWeb(process.stdin) as ReadableStream,
)
- conn = new AgentSideConnection(makeAgent, stream)
+ conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands))
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
@@ -1036,6 +1082,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// installed yet) must observe this after its await and refuse to install a
// post-teardown record. Set even when there are no live sessions.
closed = true
+ pendingCommandSnapshots.clear()
const recs = [...sessions.values()]
sessions.clear()
if (recs.length === 0) return Promise.resolve()
diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts
index 487b4b59f0..946ee67950 100644
--- a/packages/ui/acp/tests/commands.spec.ts
+++ b/packages/ui/acp/tests/commands.spec.ts
@@ -41,13 +41,15 @@ describe('ACP plugin commands', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
- expect(commandUpdates(harness, sessionId).at(-1)?.update).toEqual({
- sessionUpdate: 'available_commands_update',
- availableCommands: [{
- name: 'inspect',
- description: 'Inspect the session',
- input: { hint: '' },
- }],
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
+ sessionUpdate: 'available_commands_update',
+ availableCommands: [{
+ name: 'inspect',
+ description: 'Inspect the session',
+ input: { hint: '' },
+ }],
+ })
})
const dispose = harness.ctx.commands.register({
@@ -88,6 +90,23 @@ describe('ACP plugin commands', () => {
})
})
+ it('coalesces registry changes before a new session command snapshot is announced', async () => {
+ harness = await makeBridgeHarness({ storageDir })
+ await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+ const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+
+ harness.ctx.commands.register({
+ name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
+ })
+
+ await vi.waitFor(() => {
+ expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
+ expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
+ availableCommands: [{ name: 'raced' }],
+ })
+ })
+ })
+
it('executes a known single-text command directly and never sends it to the model', async () => {
harness = await makeBridgeHarness({ storageDir })
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))
diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts
index 35dd54a634..fdbaf241e6 100644
--- a/packages/ui/acp/tests/edges.spec.ts
+++ b/packages/ui/acp/tests/edges.spec.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await vi.waitFor(() => {
+ expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true)
+ })
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md
index 2c1f358dfa..5b6c8b9425 100644
--- a/packages/ui/commands/README.md
+++ b/packages/ui/commands/README.md
@@ -4,7 +4,7 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu
## Service contract
-`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal emits `commands/change` so live adapters can refresh discovery.
+`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface.
diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts
index 97ec0c3587..529dea11b4 100644
--- a/packages/ui/commands/src/index.ts
+++ b/packages/ui/commands/src/index.ts
@@ -88,6 +88,7 @@ declare module 'cordis' {
/**
* A command was registered or unregistered. This is an unfiltered registry
* notification because a global or scoped change may affect any UI view.
+ * Observer failures are contained and cannot veto the registry mutation.
* @mode emit
*/
'commands/change'(): void
@@ -115,6 +116,15 @@ function abortError(signal: AbortSignal): Error {
return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
}
+/** Render arbitrary thrown values without trusting their string coercion. */
+function renderThrown(value: unknown): string {
+ try {
+ return String(value)
+ } catch {
+ return ''
+ }
+}
+
/** Stop awaiting an uncooperative handler once its owning UI request aborts. */
function withAbort(promise: Promise, signal: AbortSignal): Promise {
if (signal.aborted) return Promise.reject(abortError(signal))
@@ -133,7 +143,7 @@ function withAbort(promise: Promise, signal: AbortSignal): Promise {
signal.removeEventListener('abort', onAbort)
reject(error instanceof Error
? error
- : new Error('command handler rejected with a non-Error value'))
+ : new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
},
)
})
@@ -144,17 +154,26 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
if (!COMMAND_NAME.test(definition.name)) {
throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
}
+ if (typeof definition.description !== 'string') {
+ throw new TypeError(`command "${definition.name}" description must be a string`)
+ }
if (definition.description.trim().length === 0) {
throw new TypeError(`command "${definition.name}" description must not be empty`)
}
if (typeof definition.handler !== 'function') {
throw new TypeError(`command "${definition.name}" handler must be a function`)
}
- const input = definition.input === undefined
- ? undefined
- : Object.freeze({ hint: definition.input.hint })
- if (input !== undefined && input.hint.trim().length === 0) {
- throw new TypeError(`command "${definition.name}" input hint must not be empty`)
+ const rawInput: unknown = definition.input
+ let input: CommandInputDescriptor | undefined
+ if (rawInput !== undefined) {
+ if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput)
+ || typeof rawInput.hint !== 'string') {
+ throw new TypeError(`command "${definition.name}" input hint must be a string`)
+ }
+ if (rawInput.hint.trim().length === 0) {
+ throw new TypeError(`command "${definition.name}" input hint must not be empty`)
+ }
+ input = Object.freeze({ hint: rawInput.hint })
}
const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)]
if (surfaces.length === 0) {
@@ -240,9 +259,9 @@ export class CommandService extends Service {
yield () => {
layer.delete(registered.definition.name)
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
- this.ctx.emit('commands/change')
+ this.notifyChange()
}
- this.ctx.emit('commands/change')
+ this.notifyChange()
}.bind(this), 'commands.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
return dispose
@@ -314,6 +333,23 @@ export class CommandService extends Service {
}
return layer
}
+
+ /** Notify every registry observer without making UI refresh load-bearing. */
+ private notifyChange(): void {
+ // Cordis emit uses Array.map: one synchronous throw starves later listeners,
+ // and returned promises are discarded. Registry notifications are
+ // non-vetoing, so contain each callback independently.
+ for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) {
+ try {
+ const returned: unknown = callback()
+ void Promise.resolve(returned).catch((error: unknown) => {
+ this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`)
+ })
+ } catch (error: unknown) {
+ this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`)
+ }
+ }
+ }
}
export default CommandService
diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts
index 67f9d3d0e2..e3da186492 100644
--- a/packages/ui/commands/tests/commands.spec.ts
+++ b/packages/ui/commands/tests/commands.spec.ts
@@ -107,7 +107,7 @@ describe('CommandService', () => {
expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
})
- it('emits on registration and disposal and rolls back when notification fails', async () => {
+ it('notifies on registration and disposal while containing broken observers', async () => {
const ctx = await mount()
const changed = vi.fn()
ctx.on('commands/change', changed)
@@ -116,11 +116,39 @@ describe('CommandService', () => {
dispose()
expect(changed).toHaveBeenCalledTimes(2)
- const explode = ctx.on('commands/change', () => { throw new Error('observer failed') })
- expect(() => ctx.commands.register(command('rollback'))).toThrow('observer failed')
- explode()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+ ctx.on('commands/change', () => { throw new Error('observer threw') })
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
+ ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
+ const afterFailures = vi.fn()
+ ctx.on('commands/change', afterFailures)
+ const removeContained = ctx.commands.register(command('contained'))
const { agent } = await mintAgentScope(ctx, 'a')
- expect(ctx.commands.find(agent, 'tui', 'rollback')).toBeUndefined()
+ expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined()
+ expect(afterFailures).toHaveBeenCalledTimes(1)
+ await vi.waitFor(() => {
+ expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
+ expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
+ })
+ removeContained()
+ expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined()
+ expect(afterFailures).toHaveBeenCalledTimes(2)
+ })
+
+ it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
+ const ctx = await mount()
+ expect(() => ctx.commands.register({
+ ...command('description-type'),
+ description: undefined,
+ } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
+ expect(() => ctx.commands.register({
+ ...command('hint-type'),
+ input: { hint: 42 },
+ } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
+ expect(() => ctx.commands.register({
+ ...command('input-type'),
+ input: null,
+ } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
})
it('passes exact invocation context and detaches valid handler results', async () => {
@@ -187,7 +215,20 @@ describe('CommandService', () => {
handler: () => Promise.reject('not an Error'),
})
await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal))
- .rejects.toThrow('command handler rejected with a non-Error value')
+ .rejects.toThrow('command handler rejected with a non-Error value: not an Error')
+
+ const hostile = { toString(): string { throw new Error('cannot render') } }
+ ctx.commands.register({
+ name: 'reject-hostile',
+ description: 'Reject an unrenderable value',
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
+ handler: () => Promise.reject(hostile),
+ })
+ await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal))
+ .rejects.toMatchObject({
+ message: 'command handler rejected with a non-Error value: ',
+ cause: hostile,
+ })
})
it('observes an abort triggered synchronously inside the handler', async () => {
diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts
index 2ace80165c..2fe7398931 100644
--- a/packages/ui/tui/src/index.ts
+++ b/packages/ui/tui/src/index.ts
@@ -1229,6 +1229,7 @@ export function createTuiChat(
commandControllers.add(controller)
void ctx.commands.execute(agent, 'tui', text, controller.signal).then(
(result) => {
+ if (disposed) return
if (result === undefined) {
appendNotice(`Unknown command: ${text}`, 'warning')
} else if (result.text !== undefined && result.text !== '') {
@@ -1342,7 +1343,12 @@ export function createTuiChat(
} catch (error: unknown) {
disposed = true
detachListeners()
- void commandFiber.dispose()
+ void commandFiber.dispose().catch(
+ /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
+ (cleanupError: unknown) => {
+ ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${renderThrown(cleanupError)}`)
+ },
+ )
clearStatus()
disposeUserInteraction()
ui.stop()
diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts
index bf63aeaeb9..be67e117cd 100644
--- a/packages/ui/tui/tests/tui.spec.ts
+++ b/packages/ui/tui/tests/tui.spec.ts
@@ -510,6 +510,35 @@ describe('pi-tui chat lifecycle and transcript', () => {
await result.ctx.fiber.dispose()
})
+ it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
+ const result = await setup()
+ let started!: () => void
+ const ready = new Promise((resolve) => { started = resolve })
+ let resolveCommand!: (result: { kind: 'success'; text: string }) => void
+ result.ctx.commands.register({
+ name: 'late-success',
+ description: 'Resolve while the TUI closes',
+ surfaces: ['tui'],
+ handler: () => new Promise((resolve) => {
+ resolveCommand = resolve
+ started()
+ }),
+ })
+
+ result.terminal.send('/late-success')
+ result.terminal.send('\r')
+ await ready
+ resolveCommand({ kind: 'success', text: 'must not render after disposal' })
+ // Let the command boundary accept the result before disposal, but leave the
+ // TUI continuation queued so the success-side disposal guard owns the race.
+ await Promise.resolve()
+ await result.controller.dispose()
+ await tick()
+
+ expect(result.terminal.output).not.toContain('must not render after disposal')
+ await result.ctx.fiber.dispose()
+ })
+
it('cancels before /exit while running and handles agent errors/disposal', async () => {
const result = await setup({ status: 'running' })
result.terminal.send('/exit')
diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md
index 6f8ed33dfa..e59a2203da 100644
--- a/website/zh-CN/api/harness/commands.md
+++ b/website/zh-CN/api/harness/commands.md
@@ -6,7 +6,7 @@
Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L216)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L235)
### ctx.commands.register(definition)
@@ -25,7 +25,7 @@ Register a global or calling-agent-scoped command.
**Returns** the exact effect disposer that unregisters this definition.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L229)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L248)
### ctx.commands.list(agent, surface)
@@ -46,7 +46,7 @@ List the effective immutable command descriptors for one agent and surface.
**Returns** name-sorted descriptors after scoped shadowing and surface filtering.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L257)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L276)
### ctx.commands.find(agent, surface, name)
@@ -69,7 +69,7 @@ Resolve one effective command definition.
**Returns** the scoped shadow or global definition when visible on the surface.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L272)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L291)
### ctx.commands.execute(agent, surface, line, signal)
@@ -94,4 +94,4 @@ Parse and execute a known command without sending it to the model.
**Returns** a detached result, or `undefined` when syntax/name/surface does not resolve.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L285)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L304)
diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md
index 7ad96d6a81..35bf5480c7 100644
--- a/website/zh-CN/api/harness/events.md
+++ b/website/zh-CN/api/harness/events.md
@@ -482,14 +482,15 @@ Ask composed answerers for one decision. Return an outcome to claim the request
/**
* A command was registered or unregistered. This is an unfiltered registry
* notification because a global or scoped change may affect any UI view.
+ * Observer failures are contained and cannot veto the registry mutation.
* @mode emit
*/
'commands/change'(): void
```
-A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view.
+A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L93)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L94)
## fs/*