diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
index 4e105ef598..85b7f3a60a 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
@@ -19,16 +19,16 @@ Key choices recorded here because they are durable, contested, and surprising:
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
-- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
-- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
+- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation.
+- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
## Alternatives considered
-Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
+Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
## Consequences
-Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim.
+Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml
new file mode 100644
index 0000000000..cae5b75cb4
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-24-recursive-python-sdk-session-notifications.md: c90213659391b565acd043a1be64e225f8babd31
+2026-07-24-recursive-python-sdk-session-notifications.zh.md: 214a5ef924dcc9da3a97aab6385837acd2b364d9
diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md
new file mode 100644
index 0000000000..c902136593
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md
@@ -0,0 +1,29 @@
+# Agent Note: Recursive Python SDK session notifications
+
+Status: implemented
+
+English | [中文](2026-07-24-recursive-python-sdk-session-notifications.zh.md)
+
+## Problem
+
+The Python SDK filtered turn notifications by comparing each payload directly with the root session id. This admitted a direct child's lifecycle because its parent id named the root, but rejected a grandchild's lifecycle and every descendant `session.event`. The JSON-RPC server still emitted those notifications, so they accumulated on the low-level global queue while high-level consumers lost nested trajectory relationships and completion states.
+
+## Decision
+
+`HarnessClient` records every valid `subagent.started` child-to-parent edge before dispatching the notification. A later `subagent.finished` routes by its immutable parent id but never rewrites current ancestry, so an older run that settles after its child id has been reused cannot displace the replacement session. Other session notifications resolve their session id by walking that client-lifetime ancestry graph to the requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process.
+
+`Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response.
+
+## Alternatives considered
+
+**Add a root session id to every JSON-RPC notification.** The server already provides exact immediate-parent edges, and duplicating transitive ancestry on the wire would make every producer responsible for client subscription state.
+
+**Limit subagents to one level.** A deployment can set `maxDepth: 1`, but changing the SDK to depend on that policy would silently misreport valid recursive compositions.
+
+**Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree.
+
+**Expose and index every subagent run id on the JSON-RPC wire.** Exact run identity is useful when a client must correlate two concurrent outcomes for the same child, but session-tree routing already has the authoritative start edge and each terminal notification's immutable parent. Expanding the protocol is unnecessary for this ownership decision.
+
+## Consequences
+
+High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one current parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, ancestry reuse across subscriptions, and reused child ids whose older runs settle out of order.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md
new file mode 100644
index 0000000000..214a5ef924
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Python SDK 递归会话通知
+
+Status: implemented
+
+[English](2026-07-24-recursive-python-sdk-session-notifications.md) | 中文
+
+## 问题
+
+Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较来过滤轮次通知。直接子 agent 的生命周期通知因 parent ID 指向根会话而能够通过,但孙级生命周期通知与所有后代 `session.event` 都会被拒绝。JSON-RPC 服务器仍会发出这些通知,因此它们会堆积在底层全局队列中,而高层消费者会丢失嵌套轨迹的关系与结束状态。
+
+## 决策
+
+`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。
+
+`Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。
+
+## 考虑过的替代方案
+
+**在每条 JSON-RPC 通知中加入根会话 ID。** 服务器已经提供精确的直接父子关系;在线路协议中重复传递祖先关系,会迫使每个生产者承担客户端订阅状态的职责。
+
+**把 subagent 限制为一层。** 部署可以设置 `maxDepth: 1`,但让 SDK 依赖该策略,会对合法的递归组合产生静默误报。
+
+**只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。
+
+**在 JSON-RPC 线路上公开并索引每个 subagent run ID。** 当客户端必须关联同一 child 的两个并发结果时,精确 run 身份很有价值;但会话树路由已经拥有权威 start 关系和每条终止通知中不可变的 parent。没有必要为这一归属决策扩展协议。
+
+## 后果
+
+高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。
diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
index ff57904358..de005e4cd9 100644
--- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
+++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
@@ -36,7 +36,7 @@ One serialized operation reads the provider-neutral `SessionPersistence` snapsho
Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources.
-The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned.
+The derived schema has its own application id and monotonic schema version. Persistent and TEMP session metadata store the integer `SessionHeader.createdAt` contract in strict `INTEGER` columns. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned.
Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption.
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 e53c591aa5..70ca07b414 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: 7cc3907d708115207e166455ea988120a03d768b
-2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca
+2026-07-19-model-facing-goal-tools.md: 286329390a058c0302520fd2203e5becb8c81395
+2026-07-19-model-facing-goal-tools.zh.md: b0b4fc99ada3597fbab58081f52309e21dd43bac
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 7cc3907d70..286329390a 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
@@ -16,11 +16,11 @@ The surface also needs to preserve the separation between durable state and live
### Tools and model contract
-`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code.
+`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
-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.
+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; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state.
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.
@@ -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/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, 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 replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
+Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe 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/partial-edit/pause/resume behavior including strict-schema fillers, conditional blocker explanations, 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 replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
## Alternatives considered
@@ -48,6 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not.
- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective.
- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal.
+- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action.
## Consequences
@@ -56,6 +57,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives.
- Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate.
- Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance.
+- Strict-schema provider fillers interoperate without allowing meaningful cross-action updates.
## Known limitations and deferred work
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 1a38116035..b0b4fc99ad 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
@@ -16,11 +16,11 @@ Status: implemented
### 工具与模型契约
-`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。
+`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
-三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。
+三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。
自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。
@@ -38,7 +38,7 @@ Status: implemented
## 测试
-单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。
+单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。
## 考虑过的替代方案
@@ -48,6 +48,7 @@ Status: implemented
- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。
- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。
- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。
+- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。
## 后果
@@ -56,6 +57,7 @@ Status: implemented
- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。
- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。
- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。
+- 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。
## 已知限制与延期工作
diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml
new file mode 100644
index 0000000000..233cc3901c
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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-24-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0
+2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf
diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md
new file mode 100644
index 0000000000..652c3d410a
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md
@@ -0,0 +1,75 @@
+# Agent Note: Separate context injection from turn execution
+
+Status: proposed
+
+English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md)
+
+## Problem
+
+The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules.
+
+Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer.
+
+Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.”
+
+`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance.
+
+## Proposal
+
+Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop.
+
+Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape.
+
+Prompt and tool extension points may still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results.
+
+Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content.
+
+## Injection lifecycle
+
+When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request.
+
+When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary.
+
+A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence.
+
+The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail.
+
+## Extension and caller semantics
+
+`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements.
+
+Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam.
+
+Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules.
+
+This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event.
+
+## Alternatives considered
+
+**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery.
+
+**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers.
+
+**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution.
+
+**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content.
+
+**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path.
+
+## Acceptance criteria
+
+- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts.
+- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`.
+- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay.
+- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush.
+- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them.
+- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains.
+- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics.
+
+## Risks
+
+- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries.
+- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering.
+- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract.
+- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam.
+- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically.
diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md
new file mode 100644
index 0000000000..1064e7a869
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md
@@ -0,0 +1,75 @@
+# Agent Note: 将上下文注入与轮次执行分离
+
+Status: proposed
+
+[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文
+
+## 问题
+
+agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。
+
+将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。
+
+空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。
+
+`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。
+
+## 提案
+
+将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。
+
+移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。
+
+提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。
+
+每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。
+
+## 注入生命周期
+
+轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。
+
+没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。
+
+空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。
+
+因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。
+
+## 扩展点与调用方语义
+
+`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。
+
+调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。
+
+跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。
+
+本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。
+
+## 曾考虑的替代方案
+
+**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。
+
+**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。
+
+**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。
+
+**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。
+
+**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。
+
+## 验收标准
+
+- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。
+- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。
+- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。
+- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。
+- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。
+- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。
+- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。
+
+## 风险
+
+- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。
+- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。
+- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。
+- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。
+- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 23fac905ba..e639eeba3e 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1246,7 +1246,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:593`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:595`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md
index f45eb0417a..740fab5f4d 100644
--- a/docs/core-data-structures/persistence.md
+++ b/docs/core-data-structures/persistence.md
@@ -53,7 +53,7 @@ interface SessionHeader {
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
readonly id: SessionId
- /** Unix epoch milliseconds when the session was created. */
+ /** Non-negative safe-integer Unix epoch milliseconds when the session was created. */
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
readonly cwd?: string
diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts
index a1ce4b6cc5..80fffa4d73 100644
--- a/examples/headless-agent/tests/headless.snapshot.ts
+++ b/examples/headless-agent/tests/headless.snapshot.ts
@@ -222,18 +222,25 @@ describe('headless stream-json snapshots', () => {
const records = parseJsonl(logs[0]?.content ?? '')
const calls = records.filter(record => record.type === 'tool/call')
.map(record => (record.data as JsonObject | undefined)?.name)
- expect(calls).toEqual(['create_goal', 'get_goal'])
+ expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal'])
+ const probeResult = records.find(record => record.type === 'tool/result'
+ && (record.data as JsonObject | undefined)?.callId === 'call_goal_probe')
+ const probeData = probeResult?.data as JsonObject | undefined
+ expect(probeData?.isError).toBe(true)
+ expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
const goalChanges = records.filter((record) => {
if (record.type !== 'user/message') return false
const data = record.data as JsonObject | undefined
- const meta = data?.meta as JsonObject | undefined
- return meta?.kind === 'goal/change'
+ const source = data?.source as JsonObject | undefined
+ const change = source?.change as JsonObject | undefined
+ return source?.kind === 'goal' && change?.kind === 'goal/change'
})
expect(goalChanges).toHaveLength(1)
const data = goalChanges[0]?.data as JsonObject | undefined
- const meta = data?.meta as JsonObject | undefined
- const goal = meta?.goal as JsonObject | undefined
- expect(meta?.operation).toBe('create')
+ const source = data?.source as JsonObject | undefined
+ const change = source?.change as JsonObject | undefined
+ const goal = change?.goal as JsonObject | undefined
+ expect(change?.operation).toBe('create')
expect(goal).toMatchObject({
objective: 'Finish the headless goal-tool snapshot proof',
phase: 'active',
diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json
index 5263ccd4e2..8449d44c4c 100644
--- a/examples/headless-agent/tests/snapshots/goal-tools/input.json
+++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json
@@ -2,7 +2,7 @@
"steps": [
{
"op": "prompt",
- "text": "Create a durable goal to finish the snapshot proof, then inspect it."
+ "text": "Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."
}
]
}
diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json
index aec5204c7d..c4716ba7b0 100644
--- a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json
+++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json
@@ -1,4 +1,14 @@
[
+ {
+ "kind": "chunks",
+ "chunks": [
+ { "type": "block-start", "index": 0, "blockType": "tool-call" },
+ { "type": "tool-call-delta", "index": 0, "id": "call_goal_probe", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" },
+ { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_probe", "name": "update_goal", "arguments": "{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}" } },
+ { "type": "usage", "usage": { "inputTokens": 15, "outputTokens": 6 } },
+ { "type": "finish", "reason": { "kind": "tool-calls" } }
+ ]
+ },
{
"kind": "chunks",
"chunks": [
diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
index 5005192ec8..55b4078534 100644
--- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
+++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
@@ -1,35 +1,45 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal to","messageSeqs":[1],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[1],"source":{"kind":"fallback"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_probe","name":"update_goal","argumentsDelta":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15,"outputTokens":6}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[11],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
-{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":42,"time":0,"data":{"turn":1,"step":4}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":43,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":100,"outputTokens":20}}
diff --git a/examples/package.json b/examples/package.json
index 1b4e28c4fd..bd87263340 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-jsonrpc": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-llm-deepseek": "workspace:*",
+ "@deepseek-ai/dsh-llm-pi-ai": "workspace:*",
"@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/dsh-lsp": "workspace:*",
"@deepseek-ai/dsh-lsp-local": "workspace:*",
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index ceb323bbca..5ec91d6343 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -120,8 +120,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
if (record.id !== id) {
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
}
- if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
- throw new Error('session header createdAt must be a finite number')
+ if (typeof record.createdAt !== 'number'
+ || !Number.isSafeInteger(record.createdAt)
+ || record.createdAt < 0) {
+ throw new Error('session header createdAt must be a non-negative safe integer')
}
if (record.cwd !== undefined) {
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index f4a999e59a..ce3db1aece 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -36,7 +36,7 @@ export interface SessionHeader {
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
readonly id: SessionId
- /** Unix epoch milliseconds when the session was created. */
+ /** Non-negative safe-integer Unix epoch milliseconds when the session was created. */
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
readonly cwd?: string
diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts
index df53195f92..56dc84dd2d 100644
--- a/packages/core/session/tests/session.spec.ts
+++ b/packages/core/session/tests/session.spec.ts
@@ -721,7 +721,7 @@ describe('Session', () => {
{ header: 1, error: /not a plain JSON record/ },
{ header: null, error: /not a plain JSON record/ },
{ header: { ...base, version: 1 }, error: /header version/ },
- { header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
+ { header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
@@ -926,7 +926,7 @@ describe('SessionStore', () => {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
- expect(typeof session.header.createdAt).toBe('number')
+ expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
})
@@ -965,7 +965,10 @@ describe('SessionStore', () => {
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
- { meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
+ { meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
+ { meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
+ { meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
+ { meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md
index 6e0b25c567..346ac07dcc 100644
--- a/packages/goal/tool-goal/README.md
+++ b/packages/goal/tool-goal/README.md
@@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
-- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
+- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action.
-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.
+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. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.
diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts
index 904411dbfe..e62510b06f 100644
--- a/packages/goal/tool-goal/src/index.ts
+++ b/packages/goal/tool-goal/src/index.ts
@@ -130,6 +130,16 @@ function resolveConfig(config: Config): ResolvedConfig {
return { blockedAfterConsecutiveRounds: blockedAfter }
}
+/** Whether optional text is meaningful rather than a strict-schema empty filler. */
+function hasText(value: string | undefined): value is string {
+ return value !== undefined && value !== ''
+}
+
+/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */
+function hasRoundCap(value: number | undefined): value is number {
+ return value !== undefined && value !== 0
+}
+
/** Build the exact compare-and-set ref from model arguments. */
function goalRef(goalId: string, revision: number): GoalRef {
if (goalId.length === 0 || goalId !== goalId.trim()
@@ -247,12 +257,12 @@ export function apply(ctx: Context, config: Config): void {
const execution = goalToolExecution(ctx, exec)
const ref = goalRef(args.goal_id, args.revision)
const replacements = {
- ...args.objective === undefined ? {} : { objective: args.objective },
- ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
+ ...hasText(args.objective) ? { objective: args.objective } : {},
+ ...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {},
}
if (args.action === 'edit') {
requireDirectHuman(ctx, execution)
- if (args.blocked_reason !== undefined) {
+ if (hasText(args.blocked_reason)) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
const goal = ctx.goals.edit(execution.agent, ref, replacements)
@@ -260,7 +270,7 @@ export function apply(ctx: Context, config: Config): void {
}
if (args.action === 'pause' || args.action === 'resume') {
requireDirectHuman(ctx, execution)
- if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
+ if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked',
'GOAL_TOOL_INVALID_UPDATE',
@@ -272,13 +282,13 @@ export function apply(ctx: Context, config: Config): void {
return Promise.resolve(goalValue(goal))
}
const authority = completionAuthority(ctx, execution)
- if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
+ if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit',
'GOAL_TOOL_INVALID_UPDATE',
)
}
- if (args.action === 'complete' && args.blocked_reason !== undefined) {
+ if (args.action === 'complete' && hasText(args.blocked_reason)) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
if (args.action === 'blocked'
@@ -305,7 +315,11 @@ export function apply(ctx: Context, config: Config): void {
presentCall: args => present(
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
'other',
- args.blocked_reason ?? args.objective ?? args.goal_id,
+ hasText(args.blocked_reason)
+ ? args.blocked_reason
+ : hasText(args.objective)
+ ? args.objective
+ : hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id,
),
}))
}
diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
index 4f7d7d35bb..6354e53ac6 100644
--- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
@@ -144,8 +144,17 @@ describe('goal tool registration and presentation', () => {
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
+ expect(ctx.tools.get('update_goal')?.presentCall?.({
+ goal_id: 'goal-1', revision: 2, action: 'edit',
+ objective: 'ship', max_goal_rounds: 0, blocked_reason: '',
+ })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' })
+ expect(ctx.tools.get('update_goal')?.presentCall?.({
+ goal_id: 'goal-1', revision: 2, action: 'edit',
+ objective: '', max_goal_rounds: 8, blocked_reason: '',
+ })).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'resume',
+ objective: '', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
})
@@ -425,6 +434,77 @@ describe('goal tool state transitions', () => {
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
})
+ it('accepts only empty fillers in fields unused by the selected action', async () => {
+ const { ctx, root } = await harness()
+ openTurn(root, { kind: 'user' })
+ let goal = ctx.goals.create(root.agent, { objective: 'valid' })
+
+ const edited = await execute(ctx, 'update_goal', {
+ goal_id: goal.id,
+ revision: goal.revision,
+ action: 'edit',
+ objective: 'edited',
+ max_goal_rounds: 0,
+ blocked_reason: '',
+ }, root.agent)
+ expect(resultGoal(edited)).toMatchObject({ objective: 'edited' })
+ goal = ctx.goals.get(root.agent)!
+
+ const capped = await execute(ctx, 'update_goal', {
+ goal_id: goal.id,
+ revision: goal.revision,
+ action: 'edit',
+ objective: '',
+ max_goal_rounds: 8,
+ blocked_reason: '',
+ }, root.agent)
+ expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 })
+ goal = ctx.goals.get(root.agent)!
+
+ const paused = await execute(ctx, 'update_goal', {
+ goal_id: goal.id,
+ revision: goal.revision,
+ action: 'pause',
+ objective: '',
+ max_goal_rounds: 0,
+ blocked_reason: '',
+ }, root.agent)
+ expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' })
+ goal = ctx.goals.get(root.agent)!
+
+ const resumed = await execute(ctx, 'update_goal', {
+ goal_id: goal.id,
+ revision: goal.revision,
+ action: 'resume',
+ objective: '',
+ max_goal_rounds: 0,
+ blocked_reason: '',
+ }, root.agent)
+ expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' })
+ goal = ctx.goals.get(root.agent)!
+
+ const blocked = await execute(ctx, 'update_goal', {
+ goal_id: goal.id,
+ revision: goal.revision,
+ action: 'blocked',
+ objective: '',
+ max_goal_rounds: 0,
+ blocked_reason: 'actual blocker',
+ }, root.agent)
+ expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' })
+ goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 })
+
+ const complete = await execute(ctx, 'update_goal', {
+ goal_id: goal.id,
+ revision: goal.revision,
+ action: 'complete',
+ objective: '',
+ max_goal_rounds: 0,
+ blocked_reason: '',
+ }, root.agent)
+ expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' })
+ })
+
it('allows exact goal rounds to complete but not edit or pause', async () => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })
diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts
index 2a34a1ce80..48f99e6610 100644
--- a/packages/session-persistence/session-persistence-jsonl/src/format.ts
+++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts
@@ -84,6 +84,9 @@ function isHeaderLine(value: unknown): value is HeaderLine {
&& typeof (value as { version?: unknown }).version === 'number'
&& typeof (value as { id?: unknown }).id === 'string'
&& typeof (value as { createdAt?: unknown }).createdAt === 'number'
+ && Number.isSafeInteger((value as { createdAt: number }).createdAt)
+ && (value as { createdAt: number }).createdAt >= 0
+ && !Object.is((value as { createdAt: number }).createdAt, -0)
&& typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
&& (value as { delegationDepth: number }).delegationDepth >= 0
diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
index 2b49b7d55b..5e9f446379 100644
--- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
+++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
@@ -559,6 +559,26 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
})
+ it.each([
+ ['fractional', 1.5],
+ ['negative', -1],
+ ['unsafe', Number.MAX_SAFE_INTEGER + 1],
+ ])('rejects a session header with a %s createdAt', (_label, createdAt) => {
+ const log = JSON.stringify({
+ type: 'session',
+ version: 0,
+ id: 'invalid-created-at',
+ createdAt,
+ delegationDepth: 0,
+ }) + '\n'
+ expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
+ })
+
+ it('rejects a session header with negative-zero createdAt', () => {
+ const log = '{"type":"session","version":0,"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n'
+ expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
+ })
+
it.each([
['missing', undefined],
['a string', '1'],
diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md
index f1f4bc1f7b..011926d4b9 100644
--- a/packages/session-persistence/session-persistence-sqlite/README.md
+++ b/packages/session-persistence/session-persistence-sqlite/README.md
@@ -8,9 +8,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
## Storage model
-Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
+Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
-The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
+The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations.
On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory.
@@ -55,5 +55,5 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p
- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers.
- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
-- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve).
+- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve).
- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts
index 8b8dcd78e0..754d9d7e63 100644
--- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts
+++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts
@@ -17,7 +17,10 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
-export const SCHEMA_VERSION = 8
+export const SCHEMA_VERSION = 10
+
+/** SQLite application id protecting unrelated databases from persistence writes. */
+export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -63,9 +66,10 @@ export interface EventRow {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
/**
- * Open the database and apply its schema and pragmas. A zero `user_version` is
- * stamped with {@link SCHEMA_VERSION}; every other non-current version rejects
- * rather than being migrated in place.
+ * Open the database and apply its schema and pragmas. An empty database with a
+ * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
+ * unversioned database and every other non-current version reject rather than
+ * being migrated in place.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and all three tables ensured.
@@ -83,51 +87,81 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
db.exec('PRAGMA foreign_keys = ON')
+ let began = false
+ try {
+ db.exec('BEGIN IMMEDIATE')
+ began = true
+ // Validate while holding the write lock so no other connection can change
+ // schema ownership between inspection and initialization.
+ const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
+ const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
+ const { count: userObjectCount } = db.prepare(
+ "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'",
+ ).get() as { count: number }
+ if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
+ throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
+ }
+ if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
+ throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
+ }
+ if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
+ throw new Error(
+ `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
+ )
+ }
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS persistence_state (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ store_id TEXT NOT NULL
+ ) STRICT;
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ version INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ cwd TEXT,
+ parent_session TEXT,
+ seed_length INTEGER,
+ delegation_depth INTEGER,
+ incarnation TEXT NOT NULL,
+ revision INTEGER NOT NULL
+ ) STRICT;
+
+ CREATE TABLE IF NOT EXISTS events (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ seq INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ time INTEGER NOT NULL,
+ data TEXT NOT NULL,
+ source_event_seqs TEXT,
+ surface_op TEXT,
+ PRIMARY KEY (session_id, seq)
+ ) STRICT
+ `)
+ db.prepare(
+ 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
+ ).run(randomUUID())
+ if (onDisk === 0) {
+ db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
+ }
+ db.exec('COMMIT')
+ began = false
+ } catch (error: unknown) {
+ /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */
+ if (began) {
+ /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */
+ try {
+ db.exec('ROLLBACK')
+ } catch {
+ // The original SQLite failure remains the actionable cause.
+ }
+ }
+ throw error
+ }
// The validated union is safe to interpolate into a non-bindable PRAGMA.
+ // Apply it only after ownership validation and initialization commit.
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
- // `PRAGMA user_version` always returns exactly one row { user_version }.
- const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
- if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
- throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
- }
- if (onDisk === 0) {
- // Stamp fresh or pre-versioning databases.
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- }
- db.exec(`
- CREATE TABLE IF NOT EXISTS persistence_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- store_id TEXT NOT NULL
- ) STRICT
- `)
- db.prepare(
- 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
- ).run(randomUUID())
- db.exec(`
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- version INTEGER NOT NULL,
- created_at INTEGER NOT NULL,
- cwd TEXT,
- parent_session TEXT,
- seed_length INTEGER,
- delegation_depth INTEGER,
- incarnation TEXT NOT NULL,
- revision INTEGER NOT NULL
- ) STRICT
- `)
- db.exec(`
- CREATE TABLE IF NOT EXISTS events (
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
- seq INTEGER NOT NULL,
- type TEXT NOT NULL,
- time INTEGER NOT NULL,
- data TEXT NOT NULL,
- source_event_seqs TEXT,
- surface_op TEXT,
- PRIMARY KEY (session_id, seq)
- ) STRICT
- `)
}
/**
@@ -136,6 +170,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
* @returns the header, `NULL` columns mapped to omitted optional fields.
*/
export function rowToMeta(row: SessionRow): SessionHeader {
+ if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) {
+ throw new Error('stored session createdAt must be a non-negative safe integer')
+ }
return {
version: row.version,
id: row.id as SessionId,
diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
index 3976e71549..8a47c7917f 100644
--- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
@@ -4,10 +4,18 @@ import { existsSync } from 'node:fs'
import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
-import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
+import {
+ openDatabase,
+ rowToEvent,
+ rowToMeta,
+ scanRows,
+ SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
+ type EventRow,
+} from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -150,6 +158,22 @@ describe('scanRows', () => {
})
})
+describe('rowToMeta', () => {
+ it('rejects fractional stored creation metadata', () => {
+ expect(() => rowToMeta({
+ id: 'fractional',
+ version: 0,
+ created_at: 1.5,
+ cwd: null,
+ parent_session: null,
+ seed_length: null,
+ incarnation: 'fractional',
+ revision: 1,
+ delegation_depth: null,
+ })).toThrow('stored session createdAt must be a non-negative safe integer')
+ })
+})
+
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const path = await freshDbPath()
@@ -304,6 +328,121 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
})
+ it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
+ const path = await freshDbPath()
+ const legacy = new DatabaseSync(path)
+ legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
+ legacy.close()
+
+ expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
+
+ const unchanged = new DatabaseSync(path)
+ expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
+ expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
+ expect(unchanged.prepare(
+ "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'",
+ ).get()).toEqual({ name: 'sessions' })
+ unchanged.close()
+ })
+
+ it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => {
+ const path = await freshDbPath()
+ const unrelated = new DatabaseSync(path)
+ unrelated.exec('CREATE TABLE sqliteX (value TEXT)')
+ unrelated.exec("INSERT INTO sqliteX VALUES ('safe')")
+ unrelated.close()
+
+ expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
+
+ const unchanged = new DatabaseSync(path)
+ expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
+ expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
+ expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
+ expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
+ unchanged.close()
+ })
+
+ it('rejects view-only and foreign-application unversioned databases without mutation', async () => {
+ const viewPath = await freshDbPath()
+ const viewOnly = new DatabaseSync(viewPath)
+ viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value')
+ viewOnly.close()
+
+ expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/)
+ const unchangedView = new DatabaseSync(viewPath)
+ expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
+ expect(unchangedView.prepare(
+ "SELECT type FROM sqlite_schema WHERE name = 'foreign_view'",
+ ).get()).toEqual({ type: 'view' })
+ unchangedView.close()
+
+ const applicationPath = await freshDbPath()
+ const foreignApplication = new DatabaseSync(applicationPath)
+ foreignApplication.exec('PRAGMA application_id = 12345')
+ foreignApplication.close()
+
+ expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/)
+ const unchangedApplication = new DatabaseSync(applicationPath)
+ expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
+ expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
+ expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
+ unchangedApplication.close()
+ })
+
+ it('rejects a current-version database with a foreign application identity', async () => {
+ const path = await freshDbPath()
+ const foreign = new DatabaseSync(path)
+ foreign.exec('PRAGMA application_id = 12345')
+ foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
+ foreign.close()
+
+ expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/)
+
+ const unchanged = new DatabaseSync(path)
+ expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
+ expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
+ expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
+ unchanged.close()
+ })
+
+ it('rolls back schema objects and identity stamps when initialization fails', async () => {
+ const path = await freshDbPath()
+ const conflicting = new DatabaseSync(path)
+ conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
+ conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
+ conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id")
+ conflicting.close()
+
+ expect(() => openDatabase(path, 'wal')).toThrow()
+
+ const unchanged = new DatabaseSync(path)
+ expect(unchanged.prepare(
+ "SELECT type FROM sqlite_schema WHERE name = 'persistence_state'",
+ ).get()).toEqual({ type: 'view' })
+ expect(unchanged.prepare(
+ "SELECT type FROM sqlite_schema WHERE name = 'sessions'",
+ ).get()).toBeUndefined()
+ expect(unchanged.prepare(
+ "SELECT type FROM sqlite_schema WHERE name = 'events'",
+ ).get()).toBeUndefined()
+ expect(unchanged.prepare('PRAGMA application_id').get())
+ .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
+ expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
+ expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
+ unchanged.close()
+ })
+
+ it('stamps the persistence application identity with the schema version', async () => {
+ const path = await freshDbPath()
+ openDatabase(path, 'wal').close()
+
+ const db = new DatabaseSync(path)
+ expect(db.prepare('PRAGMA application_id').get())
+ .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
+ expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
+ db.close()
+ })
+
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
// Version 3 identified two incompatible sibling layouts, so it is always rejected.
const path = await freshDbPath()
@@ -442,7 +581,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
- expect(SCHEMA_VERSION).toBe(8)
+ expect(SCHEMA_VERSION).toBe(10)
})
it('keeps the revision stable for an empty repair hook', async () => {
diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts
index c839ff3c21..0fbbf7e293 100644
--- a/packages/session-persistence/session-persistence/src/coordinator.ts
+++ b/packages/session-persistence/session-persistence/src/coordinator.ts
@@ -180,6 +180,9 @@ export class PersistenceCoordinator {
if (snapshot === undefined) {
return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable'))
}
+ if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) {
+ return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer'))
+ }
return this.serialize(snapshot.id, () => this.createCore(snapshot))
}
diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts
index ae07bf77aa..cf72f4d028 100644
--- a/packages/session-persistence/session-persistence/tests/contract.ts
+++ b/packages/session-persistence/session-persistence/tests/contract.ts
@@ -84,6 +84,22 @@ export function runPersistenceContract(name: string, make: () => Promise {
+ const { persistence, dispose } = await make()
+ try {
+ const m = { ...meta('fractional-created-at'), createdAt: 1.5 }
+ await expect(persistence.create(m))
+ .rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
+
+ const valid = meta('fractional-created-at')
+ await persistence.create(valid)
+ await persistence.append(valid.id, oneTurnLog())
+ expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt)
+ } finally {
+ await dispose()
+ }
+ })
+
it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => {
const { persistence, dispose } = await make()
try {
diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts
index b88e04b536..47f6374ba6 100644
--- a/packages/session-query/session-query-sqlite/src/schema.ts
+++ b/packages/session-query/session-query-sqlite/src/schema.ts
@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */
-export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 3
+export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
@@ -78,7 +78,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode)
function listUserTables(db: DatabaseSync): string[] {
const rows = db.prepare(
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' ORDER BY name",
).all() as Array<{ name: string }>
return rows.map(row => row.name)
}
diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
index 1923c6f3eb..1812a85428 100644
--- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
@@ -1148,6 +1148,26 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
expect(stillForeign.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' })
stillForeign.close()
+ const wildcardPath = await temporaryPath('sqlite-wildcard.db')
+ const wildcard = new DatabaseSync(wildcardPath)
+ wildcard.exec('PRAGMA journal_mode = WAL')
+ wildcard.exec('CREATE TABLE sqliteX(value TEXT)')
+ wildcard.exec("INSERT INTO sqliteX VALUES ('safe')")
+ wildcard.close()
+ const wildcardCtx = new Context()
+ await wildcardCtx.plugin(SessionStore)
+ await expect(wildcardCtx.plugin(SessionQuerySqlite, {
+ path: wildcardPath,
+ journalMode: 'delete',
+ })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
+ expect(wildcardCtx.sessionQuery).toBeUndefined()
+ const stillWildcard = new DatabaseSync(wildcardPath)
+ expect(stillWildcard.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
+ expect(stillWildcard.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
+ expect(stillWildcard.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
+ expect(stillWildcard.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' })
+ stillWildcard.close()
+
const otherAppPath = await temporaryPath('other-app.db')
const otherApp = new DatabaseSync(otherAppPath)
otherApp.exec('PRAGMA application_id = 123')
diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts
index 395aeca3fd..c890e8f835 100644
--- a/packages/ui/tui/tests/tui.spec.ts
+++ b/packages/ui/tui/tests/tui.spec.ts
@@ -1908,10 +1908,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Folder · docs/')
})
result.terminal.send('\t')
- await vi.waitFor(() => {
- expect(result.terminal.output).toContain('File · design notes.md')
- })
+ result.terminal.output = ''
result.terminal.send('\t')
+ await vi.waitFor(() => {
+ expect(result.terminal.output).toContain('@"docs/design notes.md"')
+ })
await tick()
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 21eb750454..edd75466c5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -273,6 +273,9 @@ importers:
'@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:*
version: link:../packages/llm/llm-deepseek
+ '@deepseek-ai/dsh-llm-pi-ai':
+ specifier: workspace:*
+ version: link:../packages/llm/llm-pi-ai
'@deepseek-ai/dsh-llm-replay':
specifier: workspace:*
version: link:../packages/support/llm-replay
diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml
index 956d6f8ff8..ed74d60087 100644
--- a/python/sdk/README.i18n.yaml
+++ b/python/sdk/README.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
-README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b
-README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534
+README.md: bfa31a712acd6fccf1458a0a80fc2ff80dfe114e
+README.zh.md: 11ebcdd133b2fd839b73f50ef2be2e531e8bbc2a
diff --git a/python/sdk/README.md b/python/sdk/README.md
index 23d15d617b..bfa31a712a 100644
--- a/python/sdk/README.md
+++ b/python/sdk/README.md
@@ -34,9 +34,7 @@ with DeepSeekHarness(
`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. The bundled default composition registers `deepseek`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog.
-`TurnResult.final_response` is the text content from the last
-`assistant/message` event in the turn. Use `TurnResult.events` for the complete
-event stream, including intermediate assistant messages and tool activity.
+`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response.
The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them.
diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md
index 4f6aef1383..11ebcdd133 100644
--- a/python/sdk/README.zh.md
+++ b/python/sdk/README.zh.md
@@ -30,7 +30,7 @@ with DeepSeekHarness(
`provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。
-`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。
+`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` 与 `on_notification` 会按线上的原始顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复。
同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。
diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py
index 2b44a50c64..d96e974bc3 100644
--- a/python/sdk/src/deepseek_harness/api.py
+++ b/python/sdk/src/deepseek_harness/api.py
@@ -143,7 +143,10 @@ class Session:
notifications.append(notification)
if on_notification is not None:
on_notification(notification)
- if notification.method == "session.event":
+ if (
+ notification.method == "session.event"
+ and notification.payload.get("sessionId") == self.id
+ ):
event = notification.payload.get("event")
if isinstance(event, dict):
events.append(event)
diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py
index e552c8b685..8d4ec7f848 100644
--- a/python/sdk/src/deepseek_harness/client.py
+++ b/python/sdk/src/deepseek_harness/client.py
@@ -47,6 +47,7 @@ class HarnessClient:
self._notification_subscribers: dict[
str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None]
] = {}
+ self._session_parents: dict[str, str] = {}
self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue()
self._stderr_lines: deque[str] = deque(maxlen=400)
self._reader_thread: threading.Thread | None = None
@@ -62,6 +63,8 @@ class HarnessClient:
def start(self) -> None:
if self._proc is not None:
return
+ with self._lock:
+ self._session_parents.clear()
args = list(self.config.launch_args_override or self._default_launch_args())
env = os.environ.copy()
if self.config.env:
@@ -143,7 +146,7 @@ class HarnessClient:
payload,
response_model=_SessionPromptResponse,
on_notification=on_notification,
- notification_filter=_notification_belongs_to_session(session_id),
+ notification_filter=self._notification_belongs_to_session_tree(session_id),
notification_subscription=notification_subscription,
)
@@ -193,7 +196,8 @@ class HarnessClient:
return NotificationSubscription(self, subscription_id, notifications)
def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription":
- return self.subscribe_notifications(_notification_belongs_to_session(session_id))
+ """Subscribe to a session and descendants discovered from subagent lifecycle edges."""
+ return self.subscribe_notifications(self._notification_belongs_to_session_tree(session_id))
def next_request(self) -> IncomingRequest:
item = self._requests.get()
@@ -352,6 +356,7 @@ class HarnessClient:
params = message.get("params")
notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
with self._lock:
+ self._record_session_relationship_locked(notification)
subscribers = list(self._notification_subscribers.items())
delivered = False
for subscription_id, (subscriber, predicate) in subscribers:
@@ -439,6 +444,52 @@ class HarnessClient:
with self._lock:
self._notification_subscribers.pop(subscription_id, None)
+ def _record_session_relationship_locked(self, notification: Notification) -> None:
+ if notification.method != "subagent.started":
+ return
+ parent_id = notification.payload.get("parentSessionId")
+ child_id = notification.payload.get("childSessionId")
+ if (
+ isinstance(parent_id, str)
+ and parent_id
+ and isinstance(child_id, str)
+ and child_id
+ and parent_id != child_id
+ ):
+ self._session_parents[child_id] = parent_id
+
+ def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter:
+ def belongs(notification: Notification) -> bool:
+ payload = notification.payload
+ if notification.method in {"subagent.started", "subagent.finished"}:
+ parent_id = payload.get("parentSessionId")
+ if (
+ isinstance(parent_id, str)
+ and self._session_is_descendant_of(parent_id, session_id)
+ ):
+ return True
+ return payload.get("childSessionId") == session_id
+ related_id = payload.get("sessionId")
+ return (
+ isinstance(related_id, str)
+ and self._session_is_descendant_of(related_id, session_id)
+ )
+
+ return belongs
+
+ def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool:
+ current = session_id
+ visited: set[str] = set()
+ while current not in visited:
+ if current == root_session_id:
+ return True
+ visited.add(current)
+ parent = self._session_parents.get(current)
+ if parent is None:
+ return False
+ current = parent
+ return False
+
class NotificationSubscription:
def __init__(
@@ -491,15 +542,3 @@ class _ShutdownResponse(BaseModel):
def _int_or_none(value: object) -> int | None:
return value if isinstance(value, int) else None
-
-
-def _notification_belongs_to_session(session_id: str) -> NotificationFilter:
- def belongs(notification: Notification) -> bool:
- payload = notification.payload
- return (
- payload.get("sessionId") == session_id
- or payload.get("parentSessionId") == session_id
- or payload.get("childSessionId") == session_id
- )
-
- return belongs
diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py
index f66abf4b36..d5460b8683 100644
--- a/python/sdk/tests/test_client.py
+++ b/python/sdk/tests/test_client.py
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest
-from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig
+from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification
def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None:
@@ -198,6 +198,65 @@ for line in sys.stdin:
]
+def test_session_run_collects_nested_subagent_tree_without_polluting_root_events(
+ tmp_path: Path,
+) -> None:
+ script = tmp_path / "fake_runtime.py"
+ script.write_text(
+ """
+import json
+import sys
+
+for line in sys.stdin:
+ msg = json.loads(line)
+ method = msg.get("method")
+ if method == "initialize":
+ print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
+ elif method == "session/prompt":
+ root = (msg.get("params") or {})["sessionId"]
+ print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "grandchild", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "grandchild response"}]}}}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": root, "status": "ok"}}), flush=True)
+ print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True)
+ elif method == "shutdown":
+ print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
+ break
+""".strip()
+ )
+
+ seen: list[str] = []
+ with DeepSeekHarness(
+ launch_args_override=(sys.executable, str(script)),
+ cwd=str(tmp_path),
+ ) as harness:
+ result = harness.run(
+ "delegate recursively",
+ session_id="main",
+ on_notification=lambda notification: seen.append(notification.method),
+ )
+ assert harness.client._notifications.qsize() == 0
+
+ assert result.status == "ok"
+ assert result.final_response == "root response"
+ assert [event["data"]["content"][0]["text"] for event in result.events] == ["root response"]
+ assert [notification.method for notification in result.notifications] == [
+ "subagent.started",
+ "session.event",
+ "subagent.started",
+ "session.event",
+ "subagent.finished",
+ "subagent.finished",
+ "session.event",
+ "session.finished",
+ ]
+ assert seen == [notification.method for notification in result.notifications]
+
+
def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None:
script = tmp_path / "fake_runtime.py"
script.write_text(
@@ -356,6 +415,92 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
assert notification.payload["sessionId"] == "other"
+def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
+ client = HarnessClient()
+ with client.subscribe_session_notifications("main") as first:
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "subagent.started",
+ "params": {"parentSessionId": "main", "childSessionId": "child"},
+ })
+ assert first.next().payload["childSessionId"] == "child"
+
+ with client.subscribe_session_notifications("main") as second:
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "subagent.started",
+ "params": {"parentSessionId": "child", "childSessionId": "grandchild"},
+ })
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "session.event",
+ "params": {"sessionId": "grandchild", "event": {"type": "assistant/message"}},
+ })
+ assert second.next().payload["childSessionId"] == "grandchild"
+ assert second.next().payload["sessionId"] == "grandchild"
+
+ assert client._notifications.qsize() == 0
+
+
+def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None:
+ client = HarnessClient()
+ old_seen: list[Notification] = []
+ new_seen: list[Notification] = []
+ with (
+ client.subscribe_session_notifications("old-parent") as old_subscription,
+ client.subscribe_session_notifications("new-parent") as new_subscription,
+ ):
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "subagent.started",
+ "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"},
+ })
+ old_subscription.drain(old_seen.append)
+ new_subscription.drain(new_seen.append)
+ assert [notification.method for notification in old_seen] == ["subagent.started"]
+ assert new_seen == []
+
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "subagent.started",
+ "params": {"parentSessionId": "new-parent", "childSessionId": "reused-child"},
+ })
+ old_subscription.drain(old_seen.append)
+ new_subscription.drain(new_seen.append)
+ assert [notification.method for notification in new_seen] == ["subagent.started"]
+
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "subagent.finished",
+ "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"},
+ })
+ old_subscription.drain(old_seen.append)
+ new_subscription.drain(new_seen.append)
+ assert [notification.method for notification in old_seen] == [
+ "subagent.started",
+ "subagent.finished",
+ ]
+ assert [notification.method for notification in new_seen] == ["subagent.started"]
+
+ client._handle_message({
+ "jsonrpc": "2.0",
+ "method": "session.event",
+ "params": {"sessionId": "reused-child", "event": {"type": "assistant/message"}},
+ })
+ old_subscription.drain(old_seen.append)
+ new_subscription.drain(new_seen.append)
+
+ assert [notification.method for notification in old_seen] == [
+ "subagent.started",
+ "subagent.finished",
+ ]
+ assert [notification.method for notification in new_seen] == [
+ "subagent.started",
+ "session.event",
+ ]
+ assert client._notifications.qsize() == 0
+
+
def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None:
script = tmp_path / "fake_bridge.py"
script.write_text(