diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
index 9232de3c69..8f9aa62e49 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.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 .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
-2026-06-14-session-persistence.md: cef11271f26c304ad484d7851801bc69d0c1dfda
-2026-06-14-session-persistence.zh.md: b6c2467888d0d348aa492c265542565563b75fab
+2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956
+2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5
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 cef11271f2..62228bd2f5 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
@@ -29,7 +29,7 @@ Key durable, contested choices:
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`; cold reads accept the current version or a complete static adjacent-version decoder path and reject future versions or missing steps. The format decoder owns historical header and event conversion, while the Coordinator owns operation-specific recovery after decoding ([Session log versioning](2026-08-10-session-log-version-mechanism.md)). The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise. Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
+Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
## Consequences
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
index b6c2467888..ebf004333c 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
@@ -29,7 +29,7 @@ Status: implemented
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
-格式版本控制:header 携带一个 `version`;冷读取接受当前版本或完整的静态相邻版本 decoder 路径,并拒绝未来版本或缺失步骤。Format decoder 负责历史 header 和 event 转换,Coordinator 只在解码后负责各操作自己的 recovery([Session log 版本机制](2026-08-10-session-log-version-mechanism.zh.md))。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
+格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
## 后果
diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
index 23c4ea4142..a9d00bc3b9 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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 .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
-2026-07-05-reconstructable-requests.md: 3f49ba71a6b98a84b05530c900e902b0cf9f6449
-2026-07-05-reconstructable-requests.zh.md: 7b8a9df65b60f975bc3ae60b2c1b0c3a8cc22e95
+2026-07-05-reconstructable-requests.md: 3786de02d06c0b6c094297ae89ac3f84053e408d
+2026-07-05-reconstructable-requests.zh.md: 851045aca7dababd0da859f3b04b721c65382fc3
diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
index 3f49ba71a6..3786de02d0 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
+++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
@@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
-`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
+`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
-Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start` and records the final message batch as `user/message` events. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
+Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
**The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
@@ -42,6 +42,7 @@ Like MiniCode, the conversation advances append-only and resets only when model-
- **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
- **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
- **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation.
+- **A lightweight series marker referencing the previous header**: reduced repeated prompt and tool bytes, but a window beginning at that marker could not render or reconstruct the request without fetching its predecessor. A self-contained full snapshot preserves one representation for persistence, partial history, and snapshot pinning.
- **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values.
## Consequences
@@ -52,5 +53,5 @@ Like MiniCode, the conversation advances append-only and resets only when model-
- `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel.
- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction.
-- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
-- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.
+- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
+- Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths.
diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
index 7b8a9df65b..851045aca7 100644
--- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
@@ -22,9 +22,9 @@ Status: implemented
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
-`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
+`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
-每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,并把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
+每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
**已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
@@ -42,6 +42,7 @@ Status: implemented
- **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因违规必须在接口层面不可表达而否决。
- **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。
- **自定义 header-delta 编解码器**(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。
+- **引用前一个 header 的轻量 series 标记**:减少重复的提示词与工具字节,但从该标记开始的窗口若不再读取前序,就无法渲染或重建请求。自包含的完整快照让持久化、局部历史和快照固定共用一种表示。
- **Header 快照上的叙事性变更字段列表**:可以通过比较连续快照推导。`reason` 仍保留,因为实例边界无法从快照值推导。
## 后果
@@ -52,5 +53,5 @@ Status: implemented
- `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。
- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。
- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。
-- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
-- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
+- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
+- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
\ No newline at end of file
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
index e7aa813ad5..eea93dc866 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: e6c0e790a361265870a04ee63301b9f11940c648
-2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77
+2026-08-09-client-conversation-node-assembly.md: ea2505d4a72f483a9df6fcd78d7e5c9a96b02f5c
+2026-08-09-client-conversation-node-assembly.zh.md: b87f127d753cadf2805ed5cd948fc58ad01830aa
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
index e6c0e790a3..ea2505d4a7 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
@@ -260,6 +260,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` |
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set |
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
+| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
@@ -276,6 +277,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
|---|---|---|---|
| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices |
| Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
+| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor |
| Assistant | RAF for chunks, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Matches support fallback without `step/start`; Location close produces interruption presentation |
| Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key |
| Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key |
@@ -288,6 +290,8 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox.
+Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history.
+
Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.
Assistant, Turn Tail, and Deliverables demonstrate layered Location data composition. Assistant writes `assistant-step` data for each Step; Turn Tail derives `turn-tail` data from those Step values; Deliverables independently maintains `deliverables` data for the same Turn. Consumers read only declaration-merged keys, do not scan another business's Nodes, and cannot obtain the provider's Context State.
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
index 702ddba001..b87f127d75 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
@@ -260,6 +260,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice |
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 |
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
+| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent Map;Dispatch Event 用 `rootCallId` 精确路由 |
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
@@ -276,6 +277,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
|---|---|---|---|
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 |
| Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 |
+| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 |
| Assistant | chunk 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 |
| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key |
| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
@@ -288,6 +290,8 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。
+Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。
+
Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。
Assistant、Turn Tail 和 Deliverables 展示了 Location data 的分层组合。Assistant 负责写好每个 Step 的 `assistant-step` data;Turn Tail 从这些 Step values 计算 `turn-tail` data;Deliverables 独立维护同一 Turn 的 `deliverables` data。消费者只读取声明合并后的 key,不扫描其他业务 Node,也不取得提供方的 Context State。
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
index ee8c71110e..85793a0b5c 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: dfbe5c1926cf683a34ec6694f188f57c44b9ca10
-2026-08-10-session-log-version-mechanism.zh.md: 00d58757d3ea4bf1689a0847613557613d40ebf6
+2026-08-10-session-log-version-mechanism.md: 81108ceaf23405c8f2def9aaef88505d635808a3
+2026-08-10-session-log-version-mechanism.zh.md: cbb127420e2695853fdc2ad0bb98a7a0bf230b5b
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
index dfbe5c1926..81108ceaf2 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
@@ -14,21 +14,13 @@ Session logs must be upgradable after release, and the runtime that ships first
**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
-**Read rules by direction.** Equal version: decode normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: require a complete chain of static n→n+1 `SessionFormatMigration` classes; a missing migration refuses the read and names the gap. The registry is part of the build rather than Cordis composition, so one build has the same durable read capability under every plugin set.
-
-**Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each migration class carries static adjacent `from`/`to` versions. One fresh instance handles one decode attempt: `header()` runs once, `event()` maps each input record to exactly one lossless-JSON output with the same seq, and optional `finish()` validates accumulated state after EOF. Instance fields may retain header and earlier-event facts without sharing state across sessions, concurrent reads, or revision retries. Header-only reads stop after `header()` and never call `finish()`, so that method validates EOF state rather than releasing resources. Any version conversion reads the complete event stream and applies the requested suffix only after all migrations; an equal-version read retains backend suffix seek. The decoder validates each output header version and each migration's seq preservation, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain.
-
-**A future format bump adds one format-owned migration.** The change adds `format-migrations/vN-to-vN+1.ts`, exports its class from the static `SESSION_FORMAT_MIGRATIONS` array, and increments `SESSION_FORMAT_VERSION`. The migration owns every old header and event variant it accepts, its instance state, and explicit failure for malformed input. It cannot add, remove, reorder, or renumber events: durable references use seq as event identity. A format change that alters facts consumed by a projection increments that projection's `stateVersion`; unchanged projections retain their cache rows. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version migrations. This decoder maps the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to canonical `compaction/*` events while preserving the rest of each record.
-
-**Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing.
-
-**Replacement is an internal backend compare-and-swap.** `replaceStored(expectedRevision, meta, events)` accepts a streaming current-format log and checks storage identity plus the source revision at the commit boundary. JSONL writes and fsyncs a sibling temporary artifact, rechecks the source revision immediately before the atomic replace, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX; like every other coordinator freshness check, the recheck adds no cross-process writer exclusion — JSONL assumes one live writer per session. SQLite stages the event iterator, then rechecks and replaces the header and event rows in one transaction. A failed commit leaves one complete old or new log; retaining a permanent pre-upgrade copy is a separate recovery policy, not part of the format migration API.
+**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
## Consequences
-Format v0 carries direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema; and the static streaming migration decoder with an empty adjacent-version registry. `SESSION_FORMAT_VERSION` remains 0 until a real v0→v1 step lands. The decoder and backend replacement APIs therefore have direct tests without manufacturing a format bump. Writers do not yet set `ignorable` because no producer needs it. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers; the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header fields or decoding any event record, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
+What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
## Alternatives considered
@@ -36,6 +28,3 @@ Format v0 carries direction-aware refusal with the raw-log path; the unknown-eve
- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists.
-- **Materializing migrations as header and event arrays** — makes the framework proportional to complete log size in memory even when each transformation is record-local. Repeatable revision-bound readers plus one-at-a-time event transforms preserve retry semantics without imposing that allocation.
-- **Version-specific conversion in `PersistenceCoordinator`** — mixes format decoding with operation-specific crash recovery and duplicates behavior across inspect, suffix read, cold continuation, and live adoption. The shared decoder produces only current-format data; each consumer retains its own recovery intent.
-- **A mandatory permanent backup for every upgrade** — is not needed for atomicity and cannot promise the same physical representation across JSONL and SQLite. Backends may add recovery copies as a separate product policy without changing migrations.
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
index 00d58757d3..cbb127420e 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
@@ -14,21 +14,13 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
-**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatMigration` 类组成完整链路,缺失任何 migration 都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。
-
-**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每个 migration class 用静态且相邻的 `from`/`to` 标识版本。每次 decode 都创建一个新实例:`header()` 调用一次;`event()` 把每条输入记录映射为一条 seq 相同、可无损表示为 JSON 的输出;可选的 `finish()` 在 EOF 后验证累计状态。实例字段可以保留 header 与之前事件的事实,而不会在 Session、并发读取或 revision retry 之间共享状态。只读 header 时在 `header()` 后结束,绝不调用 `finish()`,因此该方法用于验证 EOF 状态而不是释放资源。只要发生版本转换,就读取完整事件流,并在所有 migration 完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version 和每个 migration 是否保持 seq,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。
-
-**以后每次 format bump 只增加一个格式 migration。**改动新增 `format-migrations/vN-to-vN+1.ts`,把它的 class 导出到静态 `SESSION_FORMAT_MIGRATIONS` 数组,并递增 `SESSION_FORMAT_VERSION`。Migration 自己负责它接受的所有旧 header 和 event 变体、实例状态,以及对畸形输入的明确失败。它不能增加、删除、重排事件或重编号:持久引用以 seq 作为事件身份。如果格式变化影响了某个 projection 消费的事实,就递增该 projection 的 `stateVersion`;未受影响的 projection 保留 cache 记录。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本 migration 的模板。该 decoder 将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称映射为规范的 `compaction/*` 事件,并保留每条记录的其余内容。
-
-**Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。
-
-**Replacement 是 backend 内部的 compare-and-swap。**`replaceStored(expectedRevision, meta, events)` 接受流式当前格式日志,并在提交边界检查存储身份和 source revision。JSONL 写入并 fsync 同目录临时 artifact,在原子替换路径前立即复核 source revision,然后原子替换(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录;与协调器的其他新鲜性检查一样,复核不提供跨进程写者排他——JSONL 假定每个 session 同时只有一个 live writer。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。
+**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
## 影响
-Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 15)和 BFF 线上 schema 接受;以及使用空相邻版本注册表的静态流式 migration decoder。`SESSION_FORMAT_VERSION` 保持 0,直到真实 v0→v1 步骤合入。Decoder 和 backend replacement API 因此可以直接测试,不需要制造一次 format bump。写入侧目前不写 `ignorable`,因为还没有生产者需要它。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话;拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 字段、解码任何 event record 之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
+v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
## 曾考虑的替代方案
@@ -36,6 +28,3 @@ Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的
- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。
-- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加逐事件转换保留重试语义,又不强制这笔分配。
-- **在 `PersistenceCoordinator` 内写版本转换**:会把格式解码和各操作不同的 crash recovery 混在一起,并在 inspect、suffix read、cold continuation 和 live adoption 间复制行为。共享 decoder 只产出当前格式数据,各 consumer 保留自己的 recovery intent。
-- **每次升级都强制永久备份**:原子性不依赖永久副本,而且 JSONL 与 SQLite 无法承诺相同的物理表示。Backend 可以把恢复副本作为独立产品策略加入,不需要修改 migration。
diff --git a/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.i18n.yaml b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.i18n.yaml
new file mode 100644
index 0000000000..3af8585b6e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.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 .agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md
+2026-08-17-web-system-prompt-opaque-body.md: 9b1992dd8eb1062a3b35666b332178985aa91653
+2026-08-17-web-system-prompt-opaque-body.zh.md: 6f2fbba9d3f286e8c3073197a144d347f732cbc1
diff --git a/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md
new file mode 100644
index 0000000000..9b1992dd8e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.md
@@ -0,0 +1,29 @@
+# Agent Note: System prompt expands into the opaque context body
+
+Status: implemented
+
+English | [中文](2026-08-17-web-system-prompt-opaque-body.zh.md)
+
+## Problem
+
+The Chat `System prompt` row shares `DisclosureRow` chrome with context injection and needs an expanded body for the request's system field. Rendering that field as Markdown would restyle it — headings, emphasis, lists — so a reader would see a rendered document the model never received. Context injection already solves the same job with a 141px code-block scrollport and `
` text that keeps the bytes and line breaks the model read, so the row needs that presentation, not a second one.
+
+## Decision
+
+`SystemPromptRow` mounts the same expanded body as an opaque context injection. It reuses `ContextInjectionRow.module.css` for the 141px Figma 10:2482 scrollport and renders the durable `request/header` system string through `OpaqueBody` as one text block, so the disclosure shows model-facing text with its real line breaks and the same 20_000-character display bound. The row stays collapsed by default and still has no streaming path. It does not grow a producer label, form marker, or source-field list: the system field is one joined string on the header, not a sourced `user/message`.
+
+## Alternatives considered
+
+**Render settled Markdown in a card-styled body.** The chrome could match, but Markdown rewrites what the model read. A heading or bold span is a different document from the request bytes.
+
+**Split the joined system string into snapshot sections.** The durable header stores only the assembled text. Inventing section boundaries in the client would attribute prose the log does not name, and a resumed or foreign header could not reconstruct them.
+
+**Render through `ContextInjectionRow` itself.** That row is for sourced user-role messages: it titles a role, shows a producer, and chooses a form body. The system field is a different durable fact and has none of those fields.
+
+## Consequences
+
+The two disclosures now share one expanded-body chrome and one text presentation, so a later change to the 141px scrollport or the opaque bound applies to both. The cost is that a long system prompt scrolls inside 141px instead of 360px, and Markdown markup in the prompt stays visible as characters.
+
+## Testing
+
+`packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx` expands and collapses the row and pins the opaque `[data-context-text]` bytes, including Markdown markers that must not become a heading. `apps/web/tests/replay-round-trip.e2e.ts` still opens the assembled disclosure and reads the persona line from that body.
diff --git a/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.zh.md b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.zh.md
new file mode 100644
index 0000000000..6f2fbba9d3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-17-web-system-prompt-opaque-body.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: System prompt expands into the opaque context body
+
+Status: implemented
+
+[English](2026-08-17-web-system-prompt-opaque-body.md) | 中文
+
+## Problem
+
+Chat 的 `系统提示词` 行和上下文注入共用 `DisclosureRow` 外壳,其展开内容区需要呈现请求的 system 字段。如果把该字段渲染成 Markdown——标题、强调、列表——读者看到的将是模型从未收到的排版文档。上下文注入已经用 141px 代码块滚动区和保留模型所见字节与换行的 `
` 文本解决了同一件事,因此该行需要复用这一呈现,而不是再造一套。
+
+## Decision
+
+`SystemPromptRow` 展开后挂载与不透明上下文注入相同的内容区。它复用 `ContextInjectionRow.module.css` 的 Figma 10:2482 的 141px 滚动区,并把持久 `request/header` 的 system 字符串作为一块文本交给 `OpaqueBody`,因此展开后看到的是带真实换行的模型可见文本,以及相同的 20_000 字符显示上限。该行默认折叠,仍然没有流式路径。它不增加生产者标签、form 标记或 source 字段列表:system 字段是 header 上的一段拼接字符串,不是带 source 的 `user/message`。
+
+## Alternatives considered
+
+**在卡片式内容区里渲染结算后的 Markdown。** 外壳可以对齐,但 Markdown 会改写模型读到的内容。标题或加粗是另一份文档,不是请求里的字节。
+
+**把拼接后的 system 字符串拆成 snapshot 分段。** 持久 header 只保存组装后的文本。客户端臆造分段边界会把日志未命名的正文归到某个子系统,恢复或外来 header 也无法重建这些分段。
+
+**直接走 `ContextInjectionRow`。** 那一行面向带 source 的 user-role 消息:它标角色、显示生产者,并按 form 选内容区。system 字段是另一件持久事实,没有这些字段。
+
+## Consequences
+
+两处展开现在共用同一套内容区外壳和同一套文本展示,因此之后改 141px 滚动区或不透明显示上限会同时作用到两边。代价是较长的系统提示词在 141px 而不是 360px 内滚动,提示词里的 Markdown 标记会以字符形式可见。
+
+## Testing
+
+`packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx` 会展开并折叠该行,并钉住不透明 `[data-context-text]` 字节,包括不得变成标题的 Markdown 标记。`apps/web/tests/replay-round-trip.e2e.ts` 仍会打开组装后的展开行,并从该内容区读出 persona 行。
diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml
index 48800b1c08..68511a8efe 100644
--- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.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 .agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
-2026-07-12-simplify-session-log-representation.md: 3efb531d3c0822d7444d1270eac4da2617c12447
-2026-07-12-simplify-session-log-representation.zh.md: 8c05ac6512a8aa8e7fefc56ba410bc81dc3be277
+2026-07-12-simplify-session-log-representation.md: a0c86b66af78b4c94991d38656f03609297e2314
+2026-07-12-simplify-session-log-representation.zh.md: ceaf90236a47c141e3a4bb2cc78d415b3b9ac2ba
diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
index 3efb531d3c..a0c86b66af 100644
--- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
+++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.md
@@ -18,7 +18,7 @@ The implementation retains append and replacement `sourceEventSeqs`, the `tool/c
`SurfaceManager.nodes` is a `readonly number[]` of event sequences; the public `SurfaceNode` shape, node links, and seq-to-node map are removed. The internal replace-generation signal remains. The complete `foldSurface()` read used by session-query returns the same number-array representation plus replacement metadata without making the incremental manager retain history. Tool-pairing balance and compaction use event sequences and surface positions; the compact-owned per-cut balance cache does not depend on node links.
-Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
+Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a full snapshot with reason `series`. Ordinary append-only later Turns, further Steps, and retries in that model-message series inherit the latest snapshot. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts.
diff --git a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md
index 8c05ac6512..ceaf90236a 100644
--- a/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md
+++ b/.agents/notes/implemented/simplification/2026-07-12-simplify-session-log-representation.zh.md
@@ -18,7 +18,7 @@ Status: implemented
`SurfaceManager.nodes` 是由事件序号组成的 `readonly number[]`;公共 `SurfaceNode` 形状、node 链接和 seq-to-node map 均已移除。内部替换 generation 信号保留。session-query 使用的完整 `foldSurface()` 读取会返回相同的数字数组表示和替换元数据,而无需让增量 manager 保留历史。工具配对 balance 和压缩(compaction)使用事件序号与 surface 位置;由 compact 拥有的每个切点的 balance cache 不依赖 node 链接。
-请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。
+请求头只使用规范的完整快照。初始与恢复锚点即使没有变化也仍是完整快照;实例内变化会追加另一个完整 `request/header`,reason 为 `change`;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `series` 的完整快照。普通的仅追加后续 Turn、同一模型消息序列内的后续 Step 与重试沿用最新快照。delta 事件、codec 类型、diff/apply 辅助函数,以及仅供 codec 使用的 `fallback` reason 均已移除。请求重建选择最新快照。
`SESSION_FORMAT_VERSION` 仍固定为 `0`,因此 seed、追加和持久化加载验证会显式拒绝旧 v0 `request/header-delta` 事件,以及携带已删除 `fallback` reason 的完整快照。不存在兼容性 fold 或迁移。JSONL 与 SQLite 测试固定了这一失败即报错的边界;ACP(Agent Client Protocol)快照 harness 则把合法的会话中途变更表示为固定的完整请求头和完整可读提示词。
diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
index 3804ea77b3..390b157a76 100644
--- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
+++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
@@ -45,12 +45,13 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"GOAL ROUND ONE"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL ROUND ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"}]}}
@@ -58,9 +59,10 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":3,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
-{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[59,60],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[61,62],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":3,"step":1}}
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}
diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
index 71d23de033..b724c08ef5 100644
--- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
+++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
@@ -35,15 +35,16 @@
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"series"}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[37,38,39,40,41],"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}
{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}}
-{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[42],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[43],"surfaceOp":"append"}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: Finish the ACP goal wrap-up snapshot proof"},"role":"user","id":"{{sessionId}}"}]}}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
@@ -54,6 +55,6 @@
{"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}}
{"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":2}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts
index 8130e70308..e18af7c791 100644
--- a/apps/web/tests/chat-continuous-conversation.e2e.ts
+++ b/apps/web/tests/chat-continuous-conversation.e2e.ts
@@ -330,6 +330,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
event.type === 'turn/end' && event.data.reason.kind === 'completed'
))).toHaveLength(TURN_COUNT)
+ expect(sessionEvents.flatMap(event =>
+ event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
+ await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
+ timeout: 10_000,
+ }).toBe(1)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.filter(event => (
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md
index 0f4902439c..738a1b11a3 100644
--- a/apps/web/tests/expected/github-ready-review/conversation.expected.md
+++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md
@@ -20,6 +20,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
+- button "System prompt":
+ - img
+ - img
+ - text: System prompt
- button "Context injection webhook github webhook handled by review-pr-when-ready":
- img
- img
diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
index 0aa6eeb091..b06b3aae82 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
+- button "System prompt":
+ - img
+ - img
+ - text: System prompt
- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
- button "Copy":
- img
diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md
index 7084523979..c201520e8d 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
+- button "System prompt":
+ - img
+ - img
+ - text: System prompt
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md
index 96da765b9b..885143a0f6 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
+- button "System prompt":
+ - img
+ - img
+ - text: System prompt
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy":
- img
diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts
index 346593529b..54af9f2da9 100644
--- a/apps/web/tests/goal-multi-turn-actions.e2e.ts
+++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts
@@ -151,6 +151,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
.toEqual([1, 2])
expect(goalRounds(sessionEvents)).toEqual([1, 2])
+ expect(sessionEvents.flatMap(event =>
+ event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
+ await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
+ timeout: 15_000,
+ }).toBe(2)
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts
index 30644607f5..6325e202b2 100644
--- a/apps/web/tests/replay-round-trip.e2e.ts
+++ b/apps/web/tests/replay-round-trip.e2e.ts
@@ -150,6 +150,25 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
+ it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
+ const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
+ const body = page.locator('[data-system-prompt-body]')
+ await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
+ await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+ expect(await body.count()).toBe(0)
+
+ await disclosure.click()
+ await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
+ const opaque = body.locator('[data-context-text]')
+ await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1)
+ expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.')
+
+ await disclosure.click()
+ await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+ await expect.poll(() => body.count()).toBe(0)
+ })
+
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
// Interaction over the REAL wire-delivered transcript (the fixture-client
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
index 7207de464b..ebcb6163c7 100644
--- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -9,6 +9,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
+- button "System prompt":
+ - img
+ - img
+ - text: System prompt
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy":
- img
diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml
index 0bd71efcb6..7e3e47b3ac 100644
--- a/docs/agent-lifecycle.i18n.yaml
+++ b/docs/agent-lifecycle.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 docs/agent-lifecycle.md
-agent-lifecycle.md: 30509e17ce24ff2d078f86b6cc2a24b77ae3e4fa
-agent-lifecycle.zh.md: 693824913b2b9fcb627591a98804778a09e968a6
+agent-lifecycle.md: 9d1b66888e35d840c95ee9f2bd589dad3aac66f6
+agent-lifecycle.zh.md: f1648792fa15495f878ae2ec362bb760ccf2dc22
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 30509e17ce..9d1b66888e 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -75,7 +75,7 @@ The `assistant/message` event records every successful provider call, including
`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
-The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.
+The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.
SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.
diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md
index 693824913b..f1648792fa 100644
--- a/docs/agent-lifecycle.zh.md
+++ b/docs/agent-lifecycle.zh.md
@@ -77,7 +77,7 @@ sequenceDiagram
`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。
-以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。
+以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息与 `startsRequestSeries`,除非有意替换。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。
需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event`;`agent/*` 是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 82e57fd2f7..164230cbd6 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.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 docs/architecture.md
-architecture.md: add615c252948adab8db7dec7059f94b8f45e52c
-architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748
+architecture.md: c6e01b8c30486d292694cbc26836e83522e3e760
+architecture.zh.md: 21d60d0c962097ee6853bf7a3831a2c0b727e9c9
diff --git a/docs/architecture.md b/docs/architecture.md
index add615c252..c6e01b8c30 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -79,7 +79,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
- -> agent/pre-step reject | enter(messages)
+ -> agent/pre-step reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
step/start
append entered messages as user/message
@@ -96,7 +96,7 @@ turn/end
Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
-`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Each step reads the prompt sections and tool schemas that plugins registered.
+`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 48baf14d6a..21d60d0c96 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -83,7 +83,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
turn/start
claim next-step input plus one queued message
assemble prompt sections + tool schemas
- -> agent/pre-step reject | enter(messages)
+ -> agent/pre-step reject | enter(messages, startsRequestSeries?)
reject, or a first enter rewritten empty -> close the turn with no step
step/start
append entered messages as user/message
@@ -100,7 +100,7 @@ turn/end
输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
-`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。每个步骤读取插件注册的提示词片段和工具 schema。
+`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index a991af76a3..4b663c5b42 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: e87e48b8f3d8a97a65940eb438068876d095f188
-config-catalog.zh.md: a9519ea9131a14abb4a398010ff3014d0321b39d
+config-catalog.md: da0fc2a5d4158fccd9b452a354a55229a0bc8a2b
+config-catalog.zh.md: 33ffecd6a3fe0ce2df00ab6ad5bed1dbe14be93f
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index e87e48b8f3..da0fc2a5d4 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1789,7 +1789,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:64`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index a9519ea913..33ffecd6a3 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1791,7 +1791,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-来源:[`packages/session/session-persistence-jsonl/src/index.ts:64`](../packages/session/session-persistence-jsonl/src/index.ts)
+来源:[`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts)
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 0714cc3a54..6b5b64f531 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md
-event-producer-consumer.md: bb4a66e363782ec468439fafd714da0e0e6c1329
-event-producer-consumer.zh.md: 6d50d092a18721ea978216f70bcc4aa887dd59d1
+event-producer-consumer.md: ee50da63c883801dfbea0b81630637cd46ba09f7
+event-producer-consumer.zh.md: d2ebacec55d1f00af998a46e5a57fe17c29956f4
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index bb4a66e363..ee50da63c8 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:199`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:207`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:188`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:246`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:219`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:180`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 6d50d092a1..d2ebacec55 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -11,18 +11,18 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:23`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 30b1894dbe..04ad9a33d7 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.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 docs/persistence-catalog.md
-persistence-catalog.md: 893ffef71be98afe2356419dcb6ca0d871f26649
-persistence-catalog.zh.md: e34ce2b4b67746f9ce79f3d61add5e7f59e1aa22
+persistence-catalog.md: 12558eeadc009b498c9a178cfcc79116bf1b7c2b
+persistence-catalog.zh.md: f855d6969aa2dcade159ac8d6549e5f0350a7f0f
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 893ffef71b..12558eeadc 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -90,7 +90,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:396`](../packages/core/session/src/types.ts)
## Events
@@ -215,7 +215,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:32`](../packages/inter
Types: [StreamChunk](subsystems/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts)
@@ -237,7 +237,7 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/
Types: [TokenUsage](subsystems/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts)
### `command/*`
@@ -563,7 +563,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s
'request/context': RequestContext
```
-Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts)
@@ -574,10 +574,15 @@ Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
-'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+'request/header': {
+ header: EpochHeader
+ reason: RequestHeaderReason
+ /** A changed header also begins a distinct model-message series. */
+ startsSeries?: true
+}
```
-Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -652,7 +657,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
'session/end-seed': Record
```
-Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts)
@@ -712,7 +717,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:26`](../packages/se
'step/end': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts)
@@ -723,7 +728,7 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
### `subagent/*`
@@ -851,7 +856,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
Types: [CallId](subsystems/core.md)
-Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
@@ -926,7 +931,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
}
```
-Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
@@ -1006,7 +1011,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
Types: [TurnEndReason](subsystems/session.md)
-Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
@@ -1022,7 +1027,7 @@ Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/
'turn/start': { turn: number }
```
-Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts)
### `user/*`
@@ -1041,7 +1046,7 @@ Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/
'user/message': UserMessage
```
-Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
### `web/*`
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index e34ce2b4b6..f855d6969a 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -576,7 +576,12 @@ export type SessionEvent = {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
-'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+'request/header': {
+ header: EpochHeader
+ reason: RequestHeaderReason
+ /** A changed header also begins a distinct model-message series. */
+ startsSeries?: true
+}
```
来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml
index fb5dd11fbb..6825069021 100644
--- a/docs/subsystems/core.i18n.yaml
+++ b/docs/subsystems/core.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 docs/subsystems/core.md
-core.md: c53bb94fa5918c3a91ee9aedbb2416d0b101b240
-core.zh.md: 93ee45fb88cc100eb77673f2b70e86483c7ed29f
+core.md: d3564b6d50e0087be25f5dd1abc7b19507fd1c21
+core.zh.md: 0f7e49141e27b18ec8b75e944460d3ac04a88c0a
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index c53bb94fa5..d3564b6d50 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -224,7 +224,12 @@ It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complet
/** Whether and with which messages the loop enters a proposed step. */
type PreStepDecision =
| { kind: 'reject' }
- | { kind: 'enter'; messages: UserMessage[] }
+ | {
+ kind: 'enter'
+ messages: UserMessage[]
+ /** Start a distinct model-message series before this step's admitted messages. */
+ startsRequestSeries?: true
+ }
```
`agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal.
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index 93ee45fb88..0f7e49141e 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -232,7 +232,12 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag
/** Whether and with which messages the loop enters a proposed step. */
type PreStepDecision =
| { kind: 'reject' }
- | { kind: 'enter'; messages: UserMessage[] }
+ | {
+ kind: 'enter'
+ messages: UserMessage[]
+ /** Start a distinct model-message series before this step's admitted messages. */
+ startsRequestSeries?: true
+ }
```
`agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。
diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml
index 0bc2413aa3..f85e085da9 100644
--- a/docs/subsystems/persistence.i18n.yaml
+++ b/docs/subsystems/persistence.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 docs/subsystems/persistence.md
-persistence.md: 5046be0f2ff65faa7fa71f41d8141399d55bfa96
-persistence.zh.md: 71bbca1121e5b8d1e9441d857a0d0989c9946d51
+persistence.md: 098f5798e5313ca97e90e67dce1d67177f003ca7
+persistence.zh.md: d6b3baf7cdb7f1735008e0c1da9740e0b756baff
diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md
index 5046be0f2f..098f5798e5 100644
--- a/docs/subsystems/persistence.md
+++ b/docs/subsystems/persistence.md
@@ -51,8 +51,8 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
interface SessionHeader {
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
- * session is created. Persistence refuses newer versions and older versions
- * without a complete registered migration path.
+ * session is created. A persistence backend rejects any other version on load
+ * (no migration — see the constant).
*/
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
@@ -91,27 +91,7 @@ interface SessionHeader {
## Format refusal — logs a build cannot faithfully read
-A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it requires a complete registered adjacent-version migration path or names the missing step. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale lives in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md).
-
-## `SessionFormatMigration` — adjacent static format upgrades
-
-Each migration class declares one adjacent `from`/`to` pair and creates fresh state for one decode attempt. The decoder snapshots every header and event output as detached lossless JSON before the next migration receives it, preserves event sequence numbers, and calls optional EOF validation only after the complete event stream is consumed. The [package README](../../packages/session/session-persistence/README.md) owns the registration and version-bump procedure.
-
-```ts type-equiv
-/** Static identity and constructor for one adjacent-version migration. */
-interface SessionFormatMigration {
- /** Input Session format version. */
- readonly from: number
- /** Output Session format version; must equal `from + 1`. */
- readonly to: number
- /**
- * Create fresh state for one header decode and its optional complete event
- * stream. Instances are never shared across sessions or decode attempts.
- * @returns a single-use migration instance.
- */
- new(): SessionFormatMigrationInstance
-}
-```
+A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md).
## `CreateSessionOptions` — seeding and metadata
diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md
index 71bbca1121..d6b3baf7cd 100644
--- a/docs/subsystems/persistence.zh.md
+++ b/docs/subsystems/persistence.zh.md
@@ -51,8 +51,8 @@ interface SessionLocation {
interface SessionHeader {
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
- * session is created. Persistence refuses newer versions and older versions
- * without a complete registered migration path.
+ * session is created. A persistence backend rejects any other version on load
+ * (no migration — see the constant).
*/
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
@@ -91,27 +91,7 @@ interface SessionHeader {
## 格式拒绝:本构建无法可靠读取的日志
-后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时则要求一条完整注册的相邻版本迁移路径,否则会指出缺失步骤。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。
-
-## `SessionFormatMigration`:相邻静态格式升级
-
-每个迁移 class 声明一组相邻的 `from`/`to`,并为一次解码创建全新状态。decoder 会将每次 header 和事件输出快照为分离的无损 JSON,再交给下一项迁移,同时保留事件 seq;只有完整消费事件流后,才会调用可选的 EOF 验证。[包 README](../../packages/session/session-persistence/README.zh.md)负责说明注册与版本递增步骤。
-
-```ts type-equiv
-/** Static identity and constructor for one adjacent-version migration. */
-interface SessionFormatMigration {
- /** Input Session format version. */
- readonly from: number
- /** Output Session format version; must equal `from + 1`. */
- readonly to: number
- /**
- * Create fresh state for one header decode and its optional complete event
- * stream. Instances are never shared across sessions or decode attempts.
- * @returns a single-use migration instance.
- */
- new(): SessionFormatMigrationInstance
-}
-```
+后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于本格式版本的 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。
## `CreateSessionOptions`:seed 与元数据
diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml
index e3d112e468..732d6ee6c6 100644
--- a/docs/subsystems/session.i18n.yaml
+++ b/docs/subsystems/session.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 docs/subsystems/session.md
-session.md: 23b3f8535ac432c297595bdf621cad5cecf717d4
-session.zh.md: ad73efb2d1ec8a2a7df3463518f172103f107296
+session.md: dc0f823cbc529b64d1f19abb3a07ffd39f849904
+session.zh.md: 640d3ee279f2fde140a5a89ee614f4db21485171
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index 23b3f8535a..dc0f823cbc 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -94,7 +94,12 @@ interface SessionEventMap {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
- 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+ 'request/header': {
+ header: EpochHeader
+ reason: RequestHeaderReason
+ /** A changed header also begins a distinct model-message series. */
+ startsSeries?: true
+ }
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
@@ -132,7 +137,7 @@ interface SessionEventMap {
### The request header event: `request/header`
-The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
+The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
```ts type-equiv
/**
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index ad73efb2d1..640d3ee279 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -94,7 +94,12 @@ interface SessionEventMap {
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
*/
- 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
+ 'request/header': {
+ header: EpochHeader
+ reason: RequestHeaderReason
+ /** A changed header also begins a distinct model-message series. */
+ startsSeries?: true
+ }
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
@@ -132,7 +137,7 @@ interface SessionEventMap {
### 请求头事件:`request/header`
-请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
+请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
```ts type-equiv
/**
diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts
index c2b7b03e61..f96c66b464 100644
--- a/packages/api/session-controller/src/agent.ts
+++ b/packages/api/session-controller/src/agent.ts
@@ -291,12 +291,18 @@ export class ApiSessionAgentController {
const selection: InstalledSelection = {
get current(): AgentModelSelection {
if (picked !== undefined) return picked
- const logged = agent.session.requestHeader()?.config
- if (logged === undefined) return defaultModel.currentSelection()
+ const loggedHeader = agent.session.requestHeader()
+ if (loggedHeader === undefined) return defaultModel.currentSelection()
+ const logged = loggedHeader.config
return {
provider: logged.provider,
model: logged.model,
- ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
+ // An effort the adapter defaulted is not a conversation choice: restoring
+ // it as one would make an unchanged default read as a request change.
+ ...(logged.reasoningEffort === undefined
+ || loggedHeader.adapterDefaults?.reasoningEffort === true
+ ? {}
+ : { reasoningEffort: logged.reasoningEffort }),
}
},
set current(next: AgentModelSelection) {
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index b53bfdd9c7..7771bc112d 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -24,7 +24,7 @@ import {
PersistenceCoordinator,
SessionPersistenceRevision,
type PersistenceBackend,
- type StoredSessionSource,
+ type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import { ApiSessionList } from '../src/list.ts'
import {
@@ -359,29 +359,19 @@ describe('cold history recovery view', () => {
await ctx.plugin(SessionStore)
const sessionId = sid('session-interrupted')
const meta = header(sessionId, 1000)
- const revision = SessionPersistenceRevision('history-recovery-test:1')
- const stored: StoredSessionSource = {
+ const stored: StoredPrefix = {
meta,
- revision,
- readEvents: ({ fromSeq = 0 } = {}) => ({
- events: (async function* () {
- const events: SessionEvent[] = [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- ]
- for (const event of events.slice(fromSeq)) yield structuredClone(event)
- })(),
- completed: Promise.resolve({}),
- }),
+ events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
+ revision: SessionPersistenceRevision('history-recovery-test:1'),
}
const backend: PersistenceBackend = {
name: 'history-recovery-test',
- openStored: id => Promise.resolve(id === sessionId ? stored : undefined),
+ loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
readStoredRevision: id => Promise.resolve(
- id === sessionId ? revision : undefined,
+ id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined,
),
appendBatch: () => Promise.resolve(),
commitRepair: () => Promise.resolve(),
- replaceStored: () => Promise.resolve(),
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts
index 65b9f5f7e7..d203585459 100644
--- a/packages/api/session-controller/tests/session-models.host.spec.ts
+++ b/packages/api/session-controller/tests/session-models.host.spec.ts
@@ -12,13 +12,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
- GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
- LlmResolvedModelInfo, StreamChunk,
+ GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo,
+ LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
+import { ApiSessionAgentController } from '../src/agent.ts'
import { buildModelCatalog } from '../src/catalog.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
@@ -86,6 +87,7 @@ async function harness(logged?: {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
+ adapterDefaults?: LlmCallConfigAdapterDefaults
}): Promise<{
ctx: Context
agent: Agent
@@ -121,7 +123,11 @@ async function harness(logged?: {
]))
const session = ctx.sessions.create()
if (logged !== undefined) {
- session.append('request/header', { header: { config: logged }, reason: 'initial' })
+ const { adapterDefaults, ...config } = logged
+ session.append('request/header', {
+ header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } },
+ reason: 'initial',
+ })
}
const agent = {
id: session.id,
@@ -488,6 +494,23 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
+ it('does not reinterpret an adapter-owned reasoning default as an explicit Web selection', async () => {
+ const { ctx, agent } = await harness({
+ provider: 'deepseek-official',
+ model: 'deepseek-chat',
+ reasoningEffort: ReasoningEffortId('high'),
+ adapterDefaults: { reasoningEffort: true },
+ })
+ createSessionTestRemote(ctx, {
+ defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }),
+ cwd: '/tmp',
+ })
+
+ expect(new ApiSessionAgentController(ctx).selectionFor(agent).current)
+ .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
+ await ctx.fiber.dispose()
+ })
+
it('saves an accepted selection as the default and survives a storage failure', async () => {
const { ctx, sessionId } = await harness()
const saved: unknown[] = []
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 4859153e08..883760c733 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/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 packages/client/ui-chat/README.md
-README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288
-README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17
+README.md: ef9dc65de0d6b990fd0066c387518dc932bd4d2e
+README.zh.md: c4de06b18077485d7d65734b9bb38ff7745a4d67
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 5253cb95b0..ef9dc65de0 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`).
+## System prompt row
+
+Chat contributes a `System prompt` row for a non-empty initial or resumed request, an explicit series start, or an actual system-field change; same-series config-only or tool-only changes, tool steps, and retries do not duplicate it. Chat places the first header in a step at that request's message boundary — turn start for step one, step start thereafter — before the user-role messages sent with the request, matching the provider envelope's system-before-messages order; when the preceding header is outside a partial window, a non-initial header stays at its own Event and renders conservatively until prepend supplies that predecessor. The row stays collapsed by default and mounts the complete prompt in the same 141px code-block body as an opaque context injection — model-facing text with its real line breaks, not Markdown — only while expanded; it has no streaming path. Systemless headers produce no row.
+
## Model Experience
None, as this package renders logged conversation state in the browser and registers nothing model-facing.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 8cd2d0d581..c4de06b180 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -4,6 +4,10 @@
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。
+## 系统提示词行
+
+Chat 会为非空的初始或恢复请求、显式序列起点,或 system 字段真实变化贡献一行 `系统提示词`;同一序列内仅配置变化或仅工具变化、工具 step 和重试不会重复该行。Chat 会把一个 step 中的首条 header 放在该请求的消息边界——step one 使用 turn start,其余 step 使用 step start——位于该请求发送的 user-role 消息之前,与提供方信封「system 在 messages 之前」的顺序一致;部分窗口未包含前序 header 时,非 initial header 会保留在自身 Event 并保守渲染,直到 prepend 补入前序 header。该行默认折叠,仅在展开期间把完整提示词挂到与不透明上下文注入相同的 141px 代码块内容区——保留模型所见真实换行的模型可见文本,而非 Markdown;它没有流式路径。无系统提示词的 header 不生成行。
+
## 模型体验
无,因为本包在浏览器中渲染已记录的对话状态,不注册任何面向模型的内容。
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index a647ad5c18..118214fcb6 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -13,6 +13,7 @@ import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
+const MAX_PAGING_ANCHOR_PROBES = 64
/** Active column host when present; otherwise the view-local scroller. */
function scrollerOf(from: HTMLElement): HTMLElement {
@@ -26,7 +27,7 @@ interface PagingAnchor {
top: number
}
-/** Find an already-rendered settled row without interpolating a selector. */
+/** Find an already-rendered row without interpolating a selector. */
function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
for (const row of list.querySelectorAll('[data-chat-anchor-key]')) {
if (row.dataset.chatAnchorKey === key) return row
@@ -45,17 +46,24 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
const viewport = scrollport.getBoundingClientRect()
const composer = scrollport.querySelector('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
- // Scroll events are hot: hit-test a few points through the stretched flow
- // rows before considering the full mounted set. The fallback keeps jsdom
- // and pre-layout states deterministic; a virtualizer naturally bounds it.
+ // Scroll events are hot: walk down one hit-test line and stop at the first
+ // hit row with layout before considering the full mounted set. Starting at the
+ // viewport edge preserves the reader's leading row when a later row is
+ // inserted between already-visible messages. The fallback keeps jsdom and
+ // pre-layout states deterministic; a virtualizer naturally bounds it.
if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
const content = list.getBoundingClientRect()
const left = Math.max(viewport.left, content.left)
const right = Math.min(viewport.right, content.right)
const x = left + Math.max(0, right - left) / 2
const height = visibleBottom - viewport.top
- const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)]
- for (const offset of points) {
+ let probes = 0
+ for (
+ let offset = 1;
+ offset < height && probes < MAX_PAGING_ANCHOR_PROBES;
+ offset = offset === 1 ? 16 : offset + 16
+ ) {
+ probes++
for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
const row = element instanceof HTMLElement
? element.closest('[data-chat-anchor-key]')
diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
index e72bd594a2..32f88c135c 100644
--- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
+++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css
@@ -1,4 +1,5 @@
-/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */
+/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap.
+ SystemPromptRow reuses this sheet so both disclosures share one body. */
.root {
min-width: 0;
diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
index 8be42c0f42..37f08dc2f5 100644
--- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
+++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
-import { DisclosureRow, IconBrowseOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
+import { DisclosureRow, IconContextInjectionOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ContextMessageNode } from '../contract/snapshot.ts'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
@@ -39,7 +39,7 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
className={css.root}
icon={provenance.role === 'recall'
?
- : }
+ : }
chevronClassName={css.chevron}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
diff --git a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx
new file mode 100644
index 0000000000..26c43b1793
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx
@@ -0,0 +1,47 @@
+import { memo, useState } from 'react'
+import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
+import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { OpaqueBody } from './ContextBody.tsx'
+import css from './ContextInjectionRow.module.css'
+
+/** Props for one complete system prompt disclosure. */
+export interface SystemPromptRowProps {
+ /** Complete model-visible prompt text. */
+ text: string
+ /** The owning view's locale seat. */
+ t: ChatViewSlotProps['t']
+}
+
+/**
+ * Render one complete system prompt as a collapsed disclosure whose expanded
+ * body is the same opaque context chrome: 141px code-block scrollport and
+ * model-facing text with its real line breaks.
+ * @param props - Complete prompt text and the locale seat.
+ * @returns The system-prompt disclosure row.
+ */
+export function SystemPromptRow({ text, t }: SystemPromptRowProps) {
+ const [open, setOpen] = useState(false)
+ return (
+ }
+ chevronClassName={css.chevron}
+ title={t('message.systemPrompt')}
+ open={open}
+ expandable
+ expandOnRowClick
+ onToggle={() => { setOpen(value => !value) }}
+ >
+