Pārlūkot izejas kodu

Merge pull request #3483 from deepseek-harness/feat/system-prompt-in-history

feat(agent-loop): append a changed system prompt in history on capable routes
Tianyi Cui 1 mēnesi atpakaļ
vecāks
revīzija
3ef3bc17c5
100 mainītis faili ar 962 papildinājumiem un 598 dzēšanām
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
  3. 4 4
      .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
  5. 4 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
  6. 4 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml
  8. 99 0
      .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md
  9. 99 0
      .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md
  10. 6 0
      .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml
  11. 97 0
      .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
  12. 97 0
      .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md
  13. 2 2
      .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml
  14. 3 1
      .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md
  15. 3 1
      .agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md
  16. 2 2
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
  17. 3 3
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
  18. 3 3
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
  19. 0 6
      .agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml
  20. 0 78
      .agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md
  21. 0 78
      .agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.zh.md
  22. 0 6
      .agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml
  23. 0 74
      .agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md
  24. 0 74
      .agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.zh.md
  25. 8 7
      apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl
  26. 7 6
      apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl
  27. 26 25
      apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl
  28. 7 6
      apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl
  29. 9 8
      apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl
  30. 9 8
      apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl
  31. 12 11
      apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl
  32. 7 5
      apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl
  33. 8 4
      apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl
  34. 2 1
      apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl
  35. 13 0
      apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.replay.v3.jsonl
  36. 8 4
      apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl
  37. 4 3
      apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl
  38. 21 20
      apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
  39. 7 3
      apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl
  40. 7 3
      apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl
  41. 4 5
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  42. 12 7
      apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
  43. 11 4
      apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
  44. 16 8
      apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
  45. 13 6
      apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
  46. 19 7
      apps/web/tests/agent-preset-selection.e2e.ts
  47. 2 2
      apps/web/tests/approval-composer.e2e.ts
  48. 1 1
      apps/web/tests/background-job-list.e2e.ts
  49. 1 1
      apps/web/tests/bash-abort-row.e2e.ts
  50. 24 0
      apps/web/tests/chat-scroll-contract.e2e.ts
  51. 15 2
      apps/web/tests/chat-scroll-fixture.ts
  52. 14 1
      apps/web/tests/complex-history.perf.ts
  53. 1 1
      apps/web/tests/cordis-tool-round.e2e.ts
  54. 2 2
      apps/web/tests/details-session-lifecycle.e2e.ts
  55. 2 2
      apps/web/tests/feedback-command.e2e.ts
  56. 2 2
      apps/web/tests/feedback-release.e2e.ts
  57. 3 3
      apps/web/tests/file-upload-round.e2e.ts
  58. 2 2
      apps/web/tests/goal-multi-turn-actions.e2e.ts
  59. 2 2
      apps/web/tests/lifecycle-chrome.e2e.ts
  60. 2 2
      apps/web/tests/live-interactions.e2e.ts
  61. 2 2
      apps/web/tests/message-actions.e2e.ts
  62. 1 1
      apps/web/tests/message-feedback-layout.e2e.ts
  63. 2 2
      apps/web/tests/message-feedback-protocol.snapshot.ts
  64. 1 1
      apps/web/tests/message-feedback.e2e.ts
  65. 12 3
      apps/web/tests/minimal-preset.snapshot.ts
  66. 2 2
      apps/web/tests/navigation-panes.e2e.ts
  67. 6 6
      apps/web/tests/permission-policy-context.e2e.ts
  68. 2 2
      apps/web/tests/plan-control-row.e2e.ts
  69. 2 2
      apps/web/tests/plan-review.e2e.ts
  70. 2 2
      apps/web/tests/ptc-round.e2e.ts
  71. 2 2
      apps/web/tests/pwsh-terminal.e2e.ts
  72. 2 2
      apps/web/tests/question-composer.e2e.ts
  73. 1 1
      apps/web/tests/queue-actions.e2e.ts
  74. 1 1
      apps/web/tests/queue-image.e2e.ts
  75. 12 6
      apps/web/tests/replay-round-trip.e2e.ts
  76. 6 5
      apps/web/tests/scaffold.ts
  77. 2 2
      apps/web/tests/schedule-after.e2e.ts
  78. 3 3
      apps/web/tests/seeded-history.e2e.ts
  79. 2 2
      apps/web/tests/sidebar-scrollbar.e2e.ts
  80. 1 1
      apps/web/tests/skill-tool-row.e2e.ts
  81. 17 7
      apps/web/tests/stats-paged-history.e2e.ts
  82. 2 2
      apps/web/tests/steering.e2e.ts
  83. 1 1
      apps/web/tests/subagent-conversation.e2e.ts
  84. 1 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  85. 2 2
      apps/web/tests/turn-tail-actions.e2e.ts
  86. 2 2
      apps/web/tests/web-search-round.e2e.ts
  87. 2 2
      apps/web/tests/workflow-run.e2e.ts
  88. 1 1
      apps/web/tests/workspace-management.e2e.ts
  89. 1 1
      apps/web/tests/workspace-new-session-folding.e2e.ts
  90. 2 2
      benchmarks/agent-continuation/README.i18n.yaml
  91. 1 1
      benchmarks/agent-continuation/README.md
  92. 1 1
      benchmarks/agent-continuation/README.zh.md
  93. 39 0
      benchmarks/agent-continuation/synthetic-history.bench.ts
  94. 3 0
      benchmarks/agent-continuation/workload.ts
  95. 2 2
      benchmarks/long-session-browser/README.i18n.yaml
  96. 1 1
      benchmarks/long-session-browser/README.md
  97. 1 1
      benchmarks/long-session-browser/README.zh.md
  98. 5 2
      benchmarks/long-session-browser/synthetic-history.ts
  99. 70 2
      benchmarks/session-open/session-open.bench.ts
  100. 1 1
      benchmarks/session-open/synthetic-released-v0-session.ts

+ 2 - 2
.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: bca93a60bf07484d73f1faf50359b72a0d00b9a3
-2026-07-05-reconstructable-requests.zh.md: c9d2a4a5d05456df8b0bd065bade8a41dd7e4e84
+2026-07-05-reconstructable-requests.md: cad999f4ceb1ec470b2e4286777c028b8935c571
+2026-07-05-reconstructable-requests.zh.md: 863041cb5c41de1139f3d6a85a9214ab3be070af

+ 4 - 4
.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. 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.
+`EpochHeader` records the request's non-history state: call config and tool schemas, with empty values canonicalized to absence. The rendered system prompt is derived history — the `system/message` event at surface node 0, per the [surface-node Agent Note](2026-09-02-system-prompt-as-surface-node.md) — so a prompt change is a surface replacement rather than a header change. 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`, 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 freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. 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, assembles the system prompt and tools, projects the rendered prompt against the surviving `system/message` node, and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, commits a changed prompt as the `system/message` append or node-0 replacement, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. `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 (system message first) and that header with no `system` field, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. 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.
 
@@ -49,9 +49,9 @@ Like MiniCode, the conversation advances append-only and resets only when model-
 
 - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
 - Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction.
-- What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
+- What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt change (a `system/message` replacement of surface node 0), a real tool or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
 - `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, 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. Current v1 retains this single representation; the frozen v0-to-v1 edge explicitly refuses legacy delta events before current Session construction.
+- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. Current v2 retains this single representation; the frozen historical edges explicitly refuse legacy delta events before current Session construction.
 - 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.

+ 4 - 4
.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,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
+`EpochHeader` 记录请求的非历史状态:调用配置和工具 schema,空值规范化为缺失。渲染后的系统提示词是派生历史——surface 第 0 号节点上的 `system/message` 事件,见[surface 节点 Agent Note](2026-09-02-system-prompt-as-surface-node.zh.md)——因此提示词变更是 surface 替换而不是 header 变更。适配器提供的推理强度与 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` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
+每个拟议步骤先领取其 inbox 批次,组装系统提示词与工具,把渲染后的提示词与存活的 `system/message` 节点比对投影,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把变化的提示词作为 `system/message` 追加或第 0 号节点替换提交,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息(系统消息在先)与该不含 `system` 字段的 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
 
 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
 
@@ -49,9 +49,9 @@ Status: implemented
 
 - 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。
 - 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。
-- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。
+- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词变更(对 surface 第 0 号节点的 `system/message` 替换)、真正的工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。
 - `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 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前 v1 保留这一种表示;冻结的 v0-to-v1 迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。
+- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。当前 v2 保留这一种表示;冻结的历史迁移边会在构造当前 Session 前显式拒绝旧版 delta 事件。
 - 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: ee4e06b569ce449d015a6ceb5c710efc8b2b3a0e
-2026-08-31-released-session-format-migrations.zh.md: d5418655361831f7dd443ab2f424a6221e910a5d
+2026-08-31-released-session-format-migrations.md: dc651350330ddb9a78b31bbd07a1f52ac758adfa
+2026-08-31-released-session-format-migrations.zh.md: 3bbcca1b0ca6d764cce23b75ee02af68e7d080c6

+ 4 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -68,13 +68,15 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
 
 V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored in V2 must not become an active V3 upload watermark merely because the header changes. Other non-current marker generations retain their ignored meaning. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
 
-V2→V3 owns bounded PTC conversions and a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [V2→V3 library](../../../../packages/session/session-format-v2-to-v3/README.md) defines the exact transformed fields and admission rules. Session, message, and call ids, sequence references, ordering, and inherited cuts remain stable. Structural extensions must reassess opaque-event admission rather than inherit an unconditional preservation promise. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
+V2→V3 owns the [system-prompt structural conversion](2026-09-02-system-prompt-as-surface-node.md), a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. The edge preserves source event order and request meaning while inserting system-head events and remapping local sequence references and the inherited cut. Unknown events are not unconditionally safe to retain when cardinality changes; the edge refuses events it cannot transform safely. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
 
 Preset renames cover the creation header and every selection event because the latest selection controls resume while earlier selections control historical forks. Rewriting only the last selection loses that distinction. The released `code` id denotes the legacy built-in preset; migration is independent of the installed roster so the same bytes produce the same result on every host. Native V3 custom ids remain available without a global runtime alias.
 
 A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. V2→V3 validates and derives its cut from markers; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
 
-A shared `release/*` base collects independent child changes in the one unreleased V2→V3 edge. Released generations and older edges retain their semantics; review order does not allocate extra format versions. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore use isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.
+All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.
+
+The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.
 
 ### Physical codecs and packed runs
 

+ 4 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -68,13 +68,15 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
 
 V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记不能仅因 header 变化就成为有效的 V3 上传水位。其他非当前代际标记仍保持被忽略的含义。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
 
-V2→V3 负责限定范围的 PTC 转换,以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[V2→V3 库](../../../../packages/session/session-format-v2-to-v3/README.zh.md)定义精确转换字段与准入规则。Session、消息和调用标识、序列引用、顺序与继承截点保持稳定。结构性扩展必须重新评估不透明事件的准入,而非继承无条件保留的承诺。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
+V2→V3 负责[系统提示词结构转换](2026-09-02-system-prompt-as-surface-node.zh.md),以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。该迁移边在插入系统头节点事件、重映射本地序号引用与继承截点的同时,保留源事件顺序和请求含义。事件数量变化时,未知事件并非无条件可以安全保留;迁移边拒绝无法安全转换的事件。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
 
 预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。
 
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
 
-共享 `release/*` 基线把独立子变更汇入唯一且尚未发布的 V2→V3 迁移边。已发布代际与旧迁移边保持原有语义;评审顺序不分配额外格式版本。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。
+所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。
+
+[已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。
 
 ### Physical codec 与 packed run
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.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/architecture/2026-09-02-system-prompt-as-surface-node.md
+2026-09-02-system-prompt-as-surface-node.md: 784f06cc5f1e58ecb79ad4621466af94ce9646b3
+2026-09-02-system-prompt-as-surface-node.zh.md: 14116029ec485ebed16de7cd60f89730a15f1a19

+ 99 - 0
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md

@@ -0,0 +1,99 @@
+# Agent Note: The system prompt is surface node 0
+
+Status: implemented
+
+English | [中文](2026-09-02-system-prompt-as-surface-node.zh.md)
+
+## Problem
+
+A system prompt held outside the surface has a different durable representation from every other message the model reads. Conversation messages are surface events (`user/message`, `assistant/message`, `tool/result`) folded in seq order by `Session.deriveMessages()`; a prompt stored as a `system` field of the log-only `request/header` snapshot has to be prepended by each serializer as wire message 0. The [reconstructable-requests Agent Note](2026-07-05-reconstructable-requests.md) made both halves durable, but that layout leaves one model-visible fact with two homes: the surface owns the messages, the header owns the message in front of them.
+
+That split forces every reader of "what did the model see" to join two sources: the compaction summarizer copies the header prompt in front of the region's derived messages, `dsh-token-meter` estimates the system prompt from the header while pricing every other message from the surface, and the Web request-prompt card, the trajectory view, and the snapshot normalizer's `{{system}}` placeholder each read the header on their own. Change detection is split the same way: a `headerEquals` that compares `system` byte-for-byte beside `config` and `tools` makes a prompt change and a tool change indistinguishable in the log (`request/header` reason `change`) even though they are different operations on the conversation.
+
+The split also blocks the next step. A model that accepts a mid-conversation `system` message as a prompt replacement needs the harness to append a system-role message to history; with the prompt living in the header there is no surface representation to append, and the header would have to be frozen by special case. The [in-history replacement decision](../feature/2026-09-02-in-history-system-prompt-replacement.md) depends on this note.
+
+## Decision
+
+The system prompt lives on the surface. It is an ordinary surface event, `system/message`, and every prompt lifecycle operation is one of the two existing `SurfaceOp` variants applied to that event type. The wire request is unchanged: the surface fold yields the message list the serializers send, with the system message first.
+
+### The event
+
+`system/message` is a member of `SurfaceEventType` beside `user/message`, `assistant/message`, and `tool/result` (`packages/core/session/src/types.ts`). Its payload mirrors `tool/result`: `{ turn, step, message }`, where `message` is a `SystemMessage` with `role: 'system'`, one text block holding the rendered prompt, and source `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`. Empty `content` records "no system prompt": the node keeps its surface position and `deriveEventMessage` projects it to `null`, so it contributes no wire message. A non-empty node projects verbatim, so `deriveMessages()` returns the system message at its surface position and the DeepSeek serializers, which pass a `role: 'system'` history message through unchanged, emit it as wire message 0. `EpochHeader` is `{ config, adapterDefaults?, tools? }`; `canonicalHeader` and `headerEquals` in `packages/core/session/src/request-header.ts` compare config, adapter defaults, and tools only.
+
+### The operations
+
+| Situation | Surface operation |
+|---|---|
+| No `system/message` survives on the surface (including an empty rendered prompt) | append `system/message`; on the session's first step it is surface node 0, before the first `user/message` of the step |
+| A `system/message` survives and the rendered prompt differs from its text (including a prompt that becomes empty) | replace exactly that node: `surfaceOp: { op: 'replace', start: <seq of the node>, end: <same> }`, `sourceEventSeqs: [<seq of the node>]`; an empty prompt produces an empty-content node that projects to no message |
+| The rendered prompt equals the surviving node's text | no operation |
+
+When the initial rendered prompt is empty, the loop reserves an empty system head before the initial admitted user messages so a prompt that first becomes non-empty later still replaces node 0. Omitting that empty node would append the later prompt behind user history, where pi-ai converts it to a user message rather than its `systemPrompt`. Replacing node 0 is a head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances as it does for a compaction replacement. The loop's `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) therefore covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` means config or tools changed, and the unchanged header that follows a prompt replacement logs as `series`.
+
+`packages/core/session/src/surface.ts` enforces the head invariant in `assertSystemHeadRewrite`: a replacement whose range covers surface node 0 while node 0 is a `system/message` is rejected unless the replacing event is itself a `system/message` covering exactly that node. System nodes at later positions carry no such protection; a compaction range may shadow them.
+
+### Ownership in the loop
+
+`dsh-agent-loop` owns `SystemPromptProjection` beside `RuntimeContextProjection` in `packages/core/agent-loop/src/runtime-context.ts`. It reads the surviving `system/message` nodes from the current surface on every projection, so a compaction or replacement that ran earlier in the same step is already reflected. `project(rendered, { inHistory, startsSeries })` returns `{ message, intent }` — `intent` is `{ surfaceOp: 'append' }` when no system node survives or when the [in-history rule](../feature/2026-09-02-in-history-system-prompt-replacement.md) applies, otherwise a replacement of exactly the latest surviving system node — or `undefined` when the latest node already holds the rendered text.
+
+In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `renderPrompt(assembly)` and projects it after the `agent/pre-step` waterfall, so a compaction provider's replacement inside that waterfall is visible to the decision; `turn()` commits the `system/message` immediately after `step/start` and before the step's `user/message` events, so log order is wire order. `buildRequest` sets no `system` on the request: the request is `header.config`, `session.deriveMessages()` (system message first), and `header.tools`. The loop step order is: claim inbox → `systemPrompt.assemble()` → project runtime context → `agent/pre-step` waterfall → project system prompt → `step/start` → commit `system/message` (when changed) → commit `user/message`s → `agent/request` waterfall → `request/header` → `request/context` → stream. The `dsh-agent-loop/invariant` companion (`packages/core/agent-loop/src/invariant.ts`) asserts that a loop-built request has `system === undefined` and `messages` equal to `deriveMessages()`.
+
+`dsh-token-meter` anchors usage to the priced surface immediately before the successful `assistant/message`, not to `step/start`. The loop admits the system prompt and user messages after step start, and retry recovery can replace nodes before rebuilding the request. Capturing that current surface includes every admitted input once; the embedded provider output remains separately priced so durable assistant rewrites retain their signed delta. The open step stores only turn and step for lifecycle validation, not a second node snapshot.
+
+### Consumers
+
+| Consumer | Reads |
+|---|---|
+| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | `options.messages`, passing the `role: 'system'` history message through as wire message 0; `GenerateOptions.system` remains for direct one-shot callers such as title providers |
+| `dsh-llm-pi-ai` | a leading system history message maps to pi-ai's `systemPrompt` |
+| `compaction-basic` `buildSummarizationInput` | node 0's derived message prepended to the region in `SummarizationInput.messages`, with no separate `system` field; an empty-content head projects to no message while staying protected from compaction |
+| `compaction-basic` `selectCompactableRange` | anchors at the first non-system node; node 0 is never inside a compaction range |
+| `dsh-token-meter` | the system node is priced as a surface node under the `systemTokens` breakdown |
+| Web request-prompt card, trajectory request node, request inspection | the `system/message` node; a replaced node 0 is shown as a prompt change and an appended in-history node as a prompt update, each in a collapsed inspectable card, never a chat bubble |
+| Snapshot normalizer `{{system}}` placeholder, plan-mode tests | the system node's text |
+| TypeScript and Python SDK expected outputs | include the `system/message` event |
+| Human transcript projections | skip `system/message`; it is model history, not conversation |
+
+`RuntimeContextProjection` and `SystemPromptProjection` both hand the loop an uncommitted message that `turn()` commits. They differ in how they observe the surface and in their operation set: runtime context follows `session/event` for its owned user-role snapshots and appends only, while the system prompt scans the current surface for system nodes on each projection because its decision depends on how many survive, and it appends or replaces per the route.
+
+### V2-to-V3 structural conversion
+
+The [V2-to-V3 migration](../../../../packages/session/session-format-v2-to-v3/README.md) converts each V2 `request/header.system` into the protected `system/message` head and removes the retired header field. It inserts an empty head immediately after the first `step/start`, then replaces that head immediately before a header whose prompt differs. Inserted messages have deterministic IDs. The transform preserves source event order, reconstructed request meaning, and the exact IDs and content of every other message. A native V3 writer emits its normal prompt head; the migrated event layout is semantically equivalent, not byte-identical to a native recording.
+
+Inserted events shift local sequence positions. The transform remaps local sequence references, replacement ranges, and the inherited cut; historical delivery/version facts and captured references to other sessions retain their source values. These historical coordinates must not be relabeled as acknowledgements of the transformed V3 log.
+
+Strict migration refuses unknown events whose payloads cannot be safely transformed, unsupported surface history before the first step, and a prompt transition outside an open step. Such a V2 source can be valid while having no order-preserving conversion under the current core step invariant. Refusal leaves the original files unchanged; it does not reorder source events or relax the invariant to force a conversion. The V3 reader rejects the retired `header.system` field and validates system-message payloads and protected-head rewrites rather than relying on TypeScript field omission.
+
+The [released-format policy](2026-08-31-released-session-format-migrations.md) keeps V0, V1, and V2 generations byte-frozen and publishes only V3 successors. V3 is one unreleased target, not a new version per feature; it can evolve before release, so integration requires disposable homes. An existing V3 generation does not rerun V2-to-V3. Projection-cache version 4 is independent of the Session format and does not imply Session V4.
+
+[Canonical-envelope work](https://github.com/deepseek-ai/deepseek-harness/pull/3636) is separate and not integrated here. Its composition order is after this structural transform within the same V2-to-V3 edge, so it canonicalizes the transformed events rather than replacing the conversion.
+
+## Alternatives considered
+
+**Keep `header.system` and add `system/message` only for updates.** Two homes for one fact: every consumer above would read the header for message 0 and the surface for later messages, and the loop would need a special case that ignores `system` in `headerEquals` while a surface system node exists. Rejected because the point of the change is one representation.
+
+**A dedicated log-only `system-prompt/change` event that rewrites the header.** Preserves the header as the home of the prompt and records changes as their own event kind, but still cannot express a system message inside history, so the in-history proposal would need a second mechanism anyway. Rejected.
+
+**Synthesize the system message inside the adapter from consecutive headers.** The adapter is stateless per request and never sees the log; a wire history that depends on adapter state is not reconstructable from the surface fold. Rejected.
+
+**Express the prompt as a `user/message` snapshot like runtime context.** Reuses an existing event type but sends the wrong role, so a model that treats a system message as authoritative would not. Rejected.
+
+## Consequences
+
+- One representation: every reader of "what did the model see" folds the surface; no consumer joins the header to the message list. `EpochHeader` has no `system` field, so a reader that expects one fails at compile time.
+- A prompt change and a tool or config change are distinguishable in the log: the former is a `system/message` replacement of node 0 followed by a `series` header, the latter a `request/header` with reason `change`.
+- Compaction carries an invariant: node 0 is never compacted. The `dsh-session` surface manager enforces it in the replace operation itself, so a compaction provider other than `compaction-basic` cannot shadow the prompt by anchoring at `surfaceNodes[0]`. Later system nodes are unprotected by design.
+- `replaceGeneration` advances for a prompt replacement as well as for compaction; a reader that needs to distinguish them inspects the replacement event's type.
+- A mid-history system node has a surface representation, which is what the [in-history replacement decision](../feature/2026-09-02-in-history-system-prompt-replacement.md) builds on.
+- An initially empty prompt occupies the protected head without contributing a wire message; in replacement mode, a later non-empty prompt replaces it and remains the leading system message.
+- Recorded snapshot fixtures carry the `system/message` event instead of a header `system` field. The snapshot normalizer tokenizes that event's text to `{{system}}`, the prompt sidecar is harvested from the `system/message` sequence (one section per prompt version, declared as `header.promptChanges`), and `request/header` pins compare config and tools only.
+
+## Testing
+
+- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` pins zero post-call surface delta with provider usage through initial, growing, shrinking, and empty prompts, same-step retry replacement, request middleware, and fresh replay.
+- `packages/core/session/tests/surface.spec.ts` (`system/message surface node` block) pins the leading system-role projection, the empty-content `null` projection, `assertSystemHeadRewrite`'s acceptance and rejection paths, the unprotected later system nodes, and the rejection of a seeded `system/message` with a non-system role or non-plugin source.
+- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render (including empty), the later non-empty prompt at the derived head in replacement mode, the no-op on an unchanged prompt, the replacement of the latest surviving node on change, the tail append after a replacement shadowed a non-head system node, and the in-history append and re-baseline rules.
+- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` (`a system-prompt change replaces surface node 0 and starts a new series under the same header`) pins the `series` header that follows a prompt replacement.
+- `packages/core/agent-loop/tests/invariant.spec.ts` pins the companion's rejection of a loop request carrying a `system` field and its `messages` equality check against the boundary derivation.
+- `packages/llm/llm-deepseek/tests/serialize.spec.ts` (`serializes a leading system message byte-for-byte like the same prompt passed as options.system`) pins wire identity. `packages/llm/llm-pi-ai/tests/context.spec.ts` compares both system sources on text and image paths. `packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` pins the derived prefix, routed tools, absent separate `system` option, and protected non-empty or empty head through the region transaction and default summarizer.
+- The recorded snapshots under `snapshots/` pin the model-visible wire request of every shipped profile; a recorded session that renders a prompt carries the `system/message` event at surface node 0 in its `session.jsonl`, and a session with a mid-session prompt change carries the replacement of node 0 or, on an in-history route, the appended node.

+ 99 - 0
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md

@@ -0,0 +1,99 @@
+# Agent Note: 系统提示词是 surface 的第 0 号节点
+
+Status: implemented
+
+[English](2026-09-02-system-prompt-as-surface-node.md) | 中文
+
+## Problem
+
+放在 surface 之外的系统提示词,其持久化表示与模型读到的其他所有消息都不同。对话消息是 surface 事件(`user/message`、`assistant/message`、`tool/result`),由 `Session.deriveMessages()` 按 seq 顺序折叠;而存放在仅记日志的 `request/header` 快照 `system` 字段中的提示词,必须由每个序列化器前置为协议消息 0。[可重建请求 Agent Note](2026-07-05-reconstructable-requests.zh.md) 让两半都成为持久数据,但这种布局让一个模型可见的事实拥有两个归属:surface 拥有消息,header 拥有排在这些消息之前的那条消息。
+
+这种拆分迫使每个想知道「模型看到了什么」的读取方都要合并两个来源:压缩(compaction)摘要器把 header 中的提示词复制到区域派生消息之前,`dsh-token-meter` 从 header 估算系统提示词却从 surface 为其他每条消息计价,Web 请求提示词卡片、轨迹视图和快照归一化器的 `{{system}}` 占位符各自单独读取 header。变更检测同样被拆开:在 `config` 和 `tools` 旁边逐字节比较 `system` 的 `headerEquals`,让提示词变更与工具变更在日志中无法区分(`request/header` 的 reason 都是 `change`),尽管它们是对对话的两种不同操作。
+
+这种拆分还阻塞了下一步。一个把对话中途的 `system` 消息当作提示词替换来接受的模型,需要 harness 向历史追加一条 system 角色消息;当提示词住在 header 里时,没有可追加的 surface 表示,header 也只能靠特例被冻结。[历史内替换决定](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 依赖本 Agent Note。
+
+## Decision
+
+系统提示词住在 surface 上。它是一个普通的 surface 事件 `system/message`,提示词生命周期中的每个操作都是对该事件类型施加现有两种 `SurfaceOp` 变体之一。协议请求不变:surface 折叠产出的就是序列化器发送的消息列表,系统消息在最前面。
+
+### 事件
+
+`system/message` 是 `SurfaceEventType` 的成员,与 `user/message`、`assistant/message`、`tool/result` 并列(`packages/core/session/src/types.ts`)。它的载荷与 `tool/result` 对称:`{ turn, step, message }`,其中 `message` 是 `role: 'system'` 的 `SystemMessage`,一个文本块承载渲染后的提示词,source 为 `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`。空的 `content` 记录「没有系统提示词」:该节点保持其 surface 位置,`deriveEventMessage` 把它投影为 `null`,因此不贡献任何协议消息。非空节点逐字投影,因此 `deriveMessages()` 在其 surface 位置返回系统消息,而原样透传 `role: 'system'` 历史消息的 DeepSeek 序列化器把它作为协议消息 0 发出。`EpochHeader` 是 `{ config, adapterDefaults?, tools? }`;`packages/core/session/src/request-header.ts` 中的 `canonicalHeader` 与 `headerEquals` 只比较 config、适配器默认值和工具。
+
+### 操作
+
+| 情形 | surface 操作 |
+|---|---|
+| surface 上没有存活的 `system/message`(包括渲染后的提示词为空时) | 追加 `system/message`;在会话的首个步骤中它是 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 |
+| 有存活的 `system/message` 且渲染后的提示词与其文本不同(包括提示词变为空) | 恰好替换该节点:`surfaceOp: { op: 'replace', start: <该节点的 seq>, end: <同一值> }`,`sourceEventSeqs: [<该节点的 seq>]`;空提示词产生一个投影为无消息的空内容节点 |
+| 渲染后的提示词与存活节点的文本相同 | 无操作 |
+
+当初始渲染的提示词为空时,循环在初始接纳的用户消息之前预留空系统头部,使稍后首次变为非空的提示词仍替换第 0 号节点。省略该空节点会让后来的提示词追加在用户历史之后,pi-ai 会将其转换为用户消息,而不是 `systemPrompt`。替换第 0 号节点是头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进。因此循环的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 表示 config 或 tools 变更,提示词替换之后跟随的未变 header 记为 `series`。
+
+`packages/core/session/src/surface.ts` 在 `assertSystemHeadRewrite` 中强制头部不变量:当第 0 号节点是 `system/message` 时,范围覆盖第 0 号节点的替换会被拒绝,除非替换事件本身是恰好覆盖该节点的 `system/message`。位于更后位置的系统节点没有此类保护;压缩范围可以遮蔽它们。
+
+### 循环中的归属
+
+`dsh-agent-loop` 在 `packages/core/agent-loop/src/runtime-context.ts` 中与 `RuntimeContextProjection` 并列拥有 `SystemPromptProjection`。它在每次投影时从当前 surface 读取存活的 `system/message` 节点,因此同一步骤中更早运行的压缩或替换已经反映在内。`project(rendered, { inHistory, startsSeries })` 返回 `{ message, intent }`——没有系统节点存活或[历史内规则](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)适用时 `intent` 为 `{ surfaceOp: 'append' }`,否则是对最新存活系统节点的精确替换——最新节点已持有渲染文本时返回 `undefined`。
+
+在 `packages/core/agent-loop/src/agent.ts` 中,`preStep` 用 `renderPrompt(assembly)` 渲染提示词,并在 `agent/pre-step` waterfall 之后投影它,因此压缩提供者在该 waterfall 内做出的替换对决定可见;`turn()` 紧接在 `step/start` 之后、该步骤的 `user/message` 事件之前提交 `system/message`,因此日志顺序即协议顺序。`buildRequest` 不在请求上设置 `system`:请求由 `header.config`、`session.deriveMessages()`(系统消息在先)和 `header.tools` 构成。循环步骤顺序为:领取收件箱 → `systemPrompt.assemble()` → 投影运行时上下文 → `agent/pre-step` waterfall → 投影系统提示词 → `step/start` → 提交 `system/message`(有变化时) → 提交各条 `user/message` → `agent/request` waterfall → `request/header` → `request/context` → 流式请求。`dsh-agent-loop/invariant` 伴随组件(`packages/core/agent-loop/src/invariant.ts`)断言循环构建的请求满足 `system === undefined` 且 `messages` 等于 `deriveMessages()`。
+
+`dsh-token-meter` 把用量锚定到成功的 `assistant/message` 之前的已计价 surface,而不是 `step/start`。循环在步骤开始之后接纳系统提示词与用户消息,重试恢复还可能在重建请求之前替换节点。捕获当前 surface 会让每个已接纳输入恰好计入一次;内嵌的提供方输出仍单独计价,因此持久 assistant 改写保留其带符号增量。开放步骤只保存 turn 与 step 以验证生命周期,不保存第二份节点快照。
+
+### 消费方
+
+| 消费方 | 读取内容 |
+|---|---|
+| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | `options.messages`,把 `role: 'system'` 的历史消息作为协议消息 0 透传;`GenerateOptions.system` 为标题提供方等直接单次调用方保留 |
+| `dsh-llm-pi-ai` | 开头的 system 历史消息映射为 pi-ai 的 `systemPrompt` |
+| `compaction-basic` 的 `buildSummarizationInput` | 第 0 号节点的派生消息前置于 `SummarizationInput.messages` 中的区域消息,无单独的 `system` 字段;空内容头节点不投影为消息,但仍受保护而不能被压缩 |
+| `compaction-basic` 的 `selectCompactableRange` | 锚定在首个非系统节点;第 0 号节点永不落入压缩范围 |
+| `dsh-token-meter` | 系统节点作为 surface 节点计价,归入 `systemTokens` 明细 |
+| Web 请求提示词卡片、轨迹请求节点、请求检视 | `system/message` 节点;被替换的第 0 号节点显示为提示词变更,追加的历史内节点显示为提示词更新,各自以折叠可检视的卡片呈现,永不作为聊天气泡 |
+| 快照归一化器的 `{{system}}` 占位符、plan-mode 测试 | 系统节点的文本 |
+| TypeScript 与 Python SDK 预期输出 | 包含 `system/message` 事件 |
+| 人类 transcript(文本记录)投影 | 跳过 `system/message`;它是模型历史,不是对话 |
+
+`RuntimeContextProjection` 与 `SystemPromptProjection` 都把一条未提交的消息交给循环由 `turn()` 提交。两者在观察 surface 的方式与操作集上不同:运行时上下文跟随 `session/event` 观察自己拥有的 user 角色快照且只做追加,而系统提示词在每次投影时扫描当前 surface 上的系统节点,因为它的决定取决于有多少节点存活,并按路由追加或替换。
+
+### V2-to-V3 结构转换
+
+[V2-to-V3 迁移](../../../../packages/session/session-format-v2-to-v3/README.zh.md)把每个 V2 `request/header.system` 转为受保护的 `system/message` 头节点,并删除已退役的 header 字段。它紧接首个 `step/start` 插入空头节点,再在提示词不同的 header 之前立即替换该节点。插入消息的 ID 是确定性的。转换保留源事件顺序、重建请求的含义,以及所有其他消息的精确 ID 和内容。原生 V3 writer 正常写出提示词头节点;迁移事件布局与原生记录语义等价,但并非逐字节相同。
+
+插入事件会移动本地序列位置。转换重映射本地序号引用、替换范围与继承截点;历史投递/版本事实,以及捕获的其他会话引用保留源值。这些历史坐标不得被重新标记为对转换后 V3 日志的确认。
+
+严格迁移拒绝无法安全转换载荷的未知事件、首个步骤前不受支持的 surface 历史,以及开放步骤之外的提示词转换。这样的 V2 源可以有效,但在当前核心步骤不变量下没有保持顺序的转换方式。拒绝不会改动原始文件;它不会重排源事件,也不会放宽不变量来强行转换。V3 读取器拒绝已退役的 `header.system` 字段,并校验系统消息载荷与受保护头节点的重写,而非依赖 TypeScript 省略字段。
+
+[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保持 V0、V1、V2 代际字节冻结,并且只发布 V3 后继代际。V3 是一个尚未发布的目标,而不是每个功能一个新版本;它在发布前可以演化,因此集成必须使用可丢弃的 home。已有 V3 代际不会重跑 V2-to-V3。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。
+
+[规范信封工作](https://github.com/deepseek-ai/deepseek-harness/pull/3636)独立开展,此处尚未集成。它在同一 V2-to-V3 迁移边中的组合顺序位于该结构转换之后,因此规范化的是转换后的事件,而不是替代该转换。
+
+## Alternatives considered
+
+**保留 `header.system`,只为更新添加 `system/message`。** 一个事实两个归属:上述每个消费方都要从 header 读消息 0、从 surface 读后续消息,循环还需要一个在 surface 存在系统节点时让 `headerEquals` 忽略 `system` 的特例。被否决,因为本次变更的目的就是单一表示。
+
+**用专门的仅记日志事件 `system-prompt/change` 重写 header。** 保留 header 作为提示词归属,并把变更记录为独立事件种类,但仍无法表达历史内部的系统消息,历史内替换提案还是需要第二套机制。被否决。
+
+**在适配器内根据相邻 header 合成系统消息。** 适配器逐请求无状态且从不接触日志;依赖适配器状态的协议历史无法从 surface 折叠重建。被否决。
+
+**像运行时上下文那样用 `user/message` 快照表达提示词。** 复用了现有事件类型,却发送了错误的角色,因此把系统消息视为权威的模型不会这样对待它。被否决。
+
+## Consequences
+
+- 单一表示:每个想知道「模型看到了什么」的读取方都折叠 surface;没有消费方需要把 header 与消息列表合并。`EpochHeader` 没有 `system` 字段,因此期望该字段的读取方在编译期失败。
+- 提示词变更与工具或 config 变更在日志中可以区分:前者是对第 0 号节点的 `system/message` 替换加随后的 `series` header,后者是 reason 为 `change` 的 `request/header`。
+- 压缩带有一条不变量:第 0 号节点永不被压缩。`dsh-session` 的 surface 管理器在替换操作本身中强制它,因此除 `compaction-basic` 以外的压缩提供方无法通过锚定在 `surfaceNodes[0]` 来遮蔽提示词。更后位置的系统节点按设计不受保护。
+- `replaceGeneration` 在提示词替换时和压缩时一样推进;需要区分两者的读取方检查替换事件的类型。
+- 历史中途的系统节点拥有 surface 表示,这正是[历史内替换决定](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md)所依赖的基础。
+- 初始空提示词占据受保护的头部,但不贡献协议消息;在替换模式下,后来的非空提示词替换它,并保持为开头的系统消息。
+- 录制的快照 fixture 携带 `system/message` 事件而非 header 的 `system` 字段。快照归一化器把该事件的文本标记化为 `{{system}}`,提示词伴随文件从 `system/message` 序列采集(每个提示词版本一节,以 `header.promptChanges` 声明),`request/header` 的 pin 只比较 config 与 tools。
+
+## Testing
+
+- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` 钉住提供方用量下调用后的表面增量为零,覆盖初始、增长、缩短与空提示词、同一步骤中的重试替换、请求中间件和全新回放。
+- `packages/core/session/tests/surface.spec.ts`(`system/message surface node` 块)钉住开头 system 角色的投影、空内容的 `null` 投影、`assertSystemHeadRewrite` 的接受与拒绝路径、更后位置系统节点不受保护,以及对 seed 中非 system 角色或非插件 source 的 `system/message` 的拒绝。
+- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加(包括空提示词)、替换模式下后来非空提示词位于派生历史头部、提示词未变时的无操作、变更时对最新存活节点的替换、替换遮蔽了非头部系统节点之后的尾部追加,以及历史内追加与重新基线规则。
+- `packages/core/agent-loop/tests/request-reconstruction.spec.ts`(`a system-prompt change replaces surface node 0 and starts a new series under the same header`)钉住提示词替换之后跟随的 `series` header。
+- `packages/core/agent-loop/tests/invariant.spec.ts` 钉住伴随组件对携带 `system` 字段的循环请求的拒绝,以及其 `messages` 与边界派生结果的相等性检查。
+- `packages/llm/llm-deepseek/tests/serialize.spec.ts`(`serializes a leading system message byte-for-byte like the same prompt passed as options.system`)钉住协议一致性。 `packages/llm/llm-pi-ai/tests/context.spec.ts` 在文本与图片路径上比较两种系统提示词来源。`packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` 通过区域事务与默认摘要器钉住派生前缀、已路由工具、不携带单独 `system` 选项,以及非空或空头节点的保护。
+- `snapshots/` 下的录制快照钉住每个随发 profile 的模型可见协议请求;渲染了提示词的录制会话在其 `session.jsonl` 中于 surface 第 0 号节点携带 `system/message` 事件,会话中途发生提示词变更的会话则携带对第 0 号节点的替换,或在历史内路由上携带追加的节点。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.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-09-02-in-history-system-prompt-replacement.md
+2026-09-02-in-history-system-prompt-replacement.md: b34a0d2b3591c1b62aba16d79963940be787f373
+2026-09-02-in-history-system-prompt-replacement.zh.md: 296932a977ae852c4ef32de1c23b2c5d9e4a1bbf

+ 97 - 0
.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md

@@ -0,0 +1,97 @@
+# Agent Note: In-history system prompt replacement for cache-stable prompt changes
+
+Status: implemented
+
+English | [中文](2026-09-02-in-history-system-prompt-replacement.zh.md)
+
+## Problem
+
+Every system prompt change costs the whole provider prefix cache. The loop renders the prompt on every step; when the bytes differ — a plan-mode section entering or leaving, a skill or tool guidance section registering, an agent-scoped persona shadow, a changed `{{model}}` variable — the request's message 0 changes and the DeepSeek context cache misses from the first token. Long agentic sessions pay this repeatedly, and the [runtime-context snapshot design](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) exists precisely because moving a changing fact out of the prompt was the only way to keep the prefix stable.
+
+A DeepSeek model, recorded here as a model fact supplied for this work, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix, so a tool-set change still invalidates the cache. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm.
+
+The harness has the representation for this because the [system prompt is surface node 0](../architecture/2026-09-02-system-prompt-as-surface-node.md): a prompt change is an operation on `system/message` surface nodes, and the choice between "replace the latest system node" and "append a new node" is a per-route decision.
+
+## Decision
+
+For a model route that declares the capability, the loop appends a new `system/message` surface node instead of replacing the latest system node when the rendered prompt changes and the prefix would otherwise survive. Everything else in the [surface-node decision](../architecture/2026-09-02-system-prompt-as-surface-node.md) is unchanged: the event type, the projection owner, the serializers, and the node 0 head protection.
+
+### Capability
+
+`dsh-llm` defines `SystemPromptUpdate = 'in-history'` and carries it as an optional sibling field, `systemPromptUpdate`, on `LlmResolvedModelInfo` and `PreparedLlmCall`; `normalizeModelInfo` rejects any other value with an `LlmError` whose code is `INVALID_MODEL_INFO`. The DeepSeek adapter's catalog model (`DeepSeekCatalogModel.systemPromptUpdate`, validated by zod at load) and the replay provider's `ReplayModelConfig.systemPromptUpdate` declare it per model; absence means the model needs message 0 rewritten. No default catalog entry declares it; a deployment enables it through the `models` list in `cordis.yml`, and every `dsh-llm-pi-ai` route keeps the replace behaviour.
+
+The loop records the mode in the session: `RequestContext.systemPromptUpdate` joins provider, model, and capacity as a `request/context` field, logged whenever any of them differs from the latest snapshot. Admission reads `PreparedLlmCall.systemPromptUpdate` from the actual call prepared after `agent/request`; the preceding snapshot is not an admission input. First requests, resumed sessions, route changes, and same-route capability changes therefore use the capability of the bound adapter that will serve the call.
+
+### The decision rule
+
+`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it reserves the head even for an empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. An empty rendering clears every active system node, regardless of route or series state. For a non-empty rendering on an incapable route or at a new request series, consolidation applies even when the effective text is unchanged. Otherwise matching effective text emits nothing. The operations are:
+
+| Route capability | Prefix state | Operation |
+|---|---|---|
+| none | non-empty rendering, any prefix state | log an empty replacement for each non-empty later system node, then rewrite the first system node with the rendering if needed |
+| `in-history` | the current request series continues | append a new `system/message` before the step's `user/message` events; the append alone needs no `request/header` |
+| `in-history` | non-empty rendering, a new series starts | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed, even when the latest effective text is unchanged |
+| any | the rendered prompt is empty | log empty replacements for non-empty later system nodes, then empty the head if needed; no prompt version remains in derived messages |
+
+`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt.
+
+The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary.
+
+An empty head with no active later system node represents no prompt. Dormant empty tails do not supply effective text, so repeated clearing and resume cannot resurrect an older prompt. Restoring non-empty text uses the same admission rule: a continuing capable route may append it; an incapable route or a new series refills the head. Clearing uses ordinary per-node replacements, not a surface delete or an initial empty-head creation.
+
+### Presentation and accounting
+
+Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`.
+
+`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
+
+Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence.
+
+Chat and Trajectory interpret the effective prompt through the pure `uiConversation.inspectSystemPrompt` operation. Each target keeps immutable prefix states for system events and positional replacements, with only surviving system nodes and a map of surviving replacement sequences to inherited surface positions. Each replacement copies that map and removes shadowed entries; historical prefix maps remain immutable. Ordinary appends and streaming updates require no prompt fold. Surface order, rather than event order or provenance citations, determines which nodes survive: a compaction can restore an older prompt without another system event, and a head rewrite can have a greater sequence than a later active prompt. Empty nodes remain addressable but do not override a nonempty prompt. An endpoint older than the earliest relevant loaded event has unknown order unless its replacement position is indexed. The interpreter withholds all subsequent prompt text after such an endpoint until prepend replay resolves the missing prefix; numeric event order cannot establish surface order. Historical cards keep their own prefix state rather than reading the final surface.
+
+### Compaction
+
+`compaction-basic` is unchanged. `selectCompactableRange` still anchors at the first non-system node, so node 0 is never shadowed and later in-history nodes can be; `buildSummarizationInput` prepends the derived head to `messages`, followed by every shadowed node's derived message in surface order, so a mid-region system node is replayed in place and the summarization call remains a genuine prefix of the conversation.
+
+## Alternatives considered
+
+**Send only the changed sections as a delta.** The model treats the latest system message as the complete prompt, so a delta would silently drop every unchanged section. Rejected on the model contract.
+
+**Enable in-history mode by plugin config instead of a model capability.** A deployment flag could pair a non-capable model with appended system messages, which such a model would read as ordinary history at best. The capability belongs to the route that honours it; the adapter catalog already carries per-model capacities. Rejected.
+
+**Always append, never re-baseline.** One rule, but node 0 would stay stale for the life of the session and every request after compaction would carry the stale head plus the replacement. Re-baselining at a series start costs nothing extra because the cache is already lost there. Rejected.
+
+**Re-baseline on every resume.** Accepts one cache miss per process restart for a simpler resume path. The cache persists across restarts for hours to days, and the log already carries what resume needs. Rejected.
+
+**Place the system message after the step's user messages.** Both positions sit after the cached prefix, but the model then reads the instructions after the input it must apply them to; system-before-user matches the leading position's ordering. Rejected.
+
+**Project the prompt before the `agent/pre-step` waterfall.** The projection would not see a compaction performed inside the waterfall, so a just-appended node could be shadowed in the same step and the request would carry node 0's stale prompt as the only system message. Projecting after the waterfall keeps the rule a pure function of the surface the request is built from. Rejected.
+
+**Use the preceding request context for admission.** It describes the previous call, not the adapter bound after request middleware. It can select the wrong prompt representation on the first call, after resume, or after a route or capability change. Resolving before prompt and user commits also keeps cancellation from admitting unsent content. Rejected.
+
+**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching the tools case. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected.
+
+**Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected.
+
+**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification.
+
+## Consequences
+
+- A prompt change on a capable route keeps the provider prefix cache; the appended node costs its own tokens on every request in the series until compaction shadows it. A deployment whose prompt changes on most steps is better served by moving that fact into runtime context.
+- The request head is not the only place a system prompt can live: readers of "what did the model see" fold the surface and take the latest system node, and the breakdown's system figure follows the same rule.
+- A `request/context` snapshot records the prepared route and declared mode; it describes admission rather than deciding it. Incapable-route consolidation is logged per system node, preserving intervening user, assistant, and tool history.
+- The model contract is recorded as supplied. If a released model narrows it — for example honouring only the latest system message within a bounded window — the rule needs a re-baseline trigger beyond series starts.
+- A proxy that rewrites or reorders system messages breaks the replacement semantics silently; the real-API e2e's cache-hit assertion is the detector.
+
+## Testing
+
+Lifecycle verification requires no event for an unchanged prompt and an appended changed prompt after resume on a capable route. Both TypeScript and Python SDK expected outputs must include the typed appended `system/message` event, as required by the [SDK snapshot policy](../../../../docs/testing.md). The [TypeScript SDK notifications](../../../../snapshots/sdk/system-prompt-in-history/notifications.expected.jsonl) and [Python SDK prompt history](../../../../scripts/snapshots/python-sdk-single-exe/minimal-in-history/prompt-history.json) record the appended prompt event and retained prompt versions.
+
+- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` covers capable-to-incapable routing with changed or unchanged text, incapable-to-capable routing, resumed-route admission, cancellation in request middleware or preparation, and a concurrent selection change while the prepared route stays bound. Retry-compaction cases shadow the latest prompt with or without an earlier surviving update and verify reuse of the admitted assembly, one user admission, and no extra series header on an unchanged retry. Clear cases on capable and incapable routes remove three active prompt versions, keep repeated requests and seeded resume empty without extra prompt events, and restore only the new text; log reconstruction and the pi converter retain no old instructions. Focused coverage of `src/agent.ts` and `src/runtime-context.ts` reaches 100% for statements, branches, functions, and lines.
+- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on a continuing series, the re-baseline at a series start with or without surviving later nodes and with changed or unchanged effective text, the empty-prompt clearing of all active versions, and the replace-only behaviour without the capability.
+- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline under a `change` header that starts a series.
+- `packages/llm/llm/tests/service.spec.ts`, `packages/llm/llm-deepseek/tests/adapter.spec.ts`, and `packages/test-support/llm-replay/tests/llm-replay.spec.ts` pin the declared mode on resolved model info and the load-time rejection of any other value.
+- `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins newest/middle prompt removal, exact heuristic totals, surface ordering after head rewrites, extra provenance citations, dormant empties and fallback clears, immutable transitions, compact retained checkpoints, late registration, replay, and version invalidation.
+- `packages/client/ui-conversation`, `ui-chat`, and `ui-trajectory` client specs pin the update card, the same-step header dedupe, the absent system change after an update, and the synthetic trajectory header.
+- The keyless authored snapshot `snapshots/session/system-prompt-in-history/` declares the capability on the replay route, changes the prompt after the first tool call through a fixture section, and pins the appended `system/message`, the untouched node 0, the single `request/header`, and the `request/context` mode.
+- `packages/llm/llm-deepseek/tests/adapter.e2e.ts` runs a two-step prompt change against the model named by `DEEPSEEK_IN_HISTORY_MODEL`, asserts that the reply follows the appended prompt, and asserts that the appended request reads more cached tokens than the same conversation with a rewritten leading prompt; it skips when the variable is unset.

+ 97 - 0
.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md

@@ -0,0 +1,97 @@
+# Agent Note: 历史内系统提示词替换,实现缓存稳定的提示词变更
+
+Status: implemented
+
+[English](2026-09-02-in-history-system-prompt-replacement.md) | 中文
+
+## Problem
+
+每一次系统提示词变更都要付出整个提供方前缀缓存的代价。循环在每个步骤渲染提示词;一旦字节不同——plan 模式片段进入或退出、某个 skill 或工具指引片段完成注册、agent 作用域的 persona 遮蔽、`{{model}}` 变量改变——请求的消息 0 随之改变,DeepSeek 上下文缓存从第一个 token 起失效。长时间的 agent 会话反复为此付费,而[运行时上下文快照设计](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)之所以存在,正是因为把会变化的事实移出提示词是保持前缀稳定的唯一办法。
+
+一个 DeepSeek 模型——在此按为本项工作提供的模型事实记录——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀,因此工具集变更仍会使缓存失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。
+
+因为[系统提示词是 surface 第 0 号节点](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md),harness 拥有实现这一点的表示:提示词变更是对 `system/message` surface 节点的操作,而「替换最新的系统节点」与「追加新节点」之间的选择是逐路由的决定。
+
+## Decision
+
+对于声明了该能力的模型路由,当渲染后的提示词变化且前缀本可存活时,循环追加一个新的 `system/message` surface 节点而不是替换最新的系统节点。[surface 节点决策](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md)中的其他一切不变:事件类型、投影的拥有者、序列化器,以及第 0 号节点的头部保护。
+
+### 能力
+
+`dsh-llm` 定义 `SystemPromptUpdate = 'in-history'`,并把它作为可选的并列字段 `systemPromptUpdate` 放在 `LlmResolvedModelInfo` 与 `PreparedLlmCall` 上;`normalizeModelInfo` 用代码为 `INVALID_MODEL_INFO` 的 `LlmError` 拒绝任何其他值。DeepSeek 适配器的目录模型(`DeepSeekCatalogModel.systemPromptUpdate`,加载时由 zod 校验)与回放提供者的 `ReplayModelConfig.systemPromptUpdate` 逐模型声明它;缺省表示该模型需要重写消息 0。没有默认目录条目声明它;部署方通过 `cordis.yml` 的 `models` 列表启用,所有 `dsh-llm-pi-ai` 路由保持替换行为。
+
+循环把该模式记录进会话:`RequestContext.systemPromptUpdate` 与 provider、model、容量并列成为 `request/context` 的字段,其中任一项与最新快照不同时就记录一次。准入读取 `agent/request` 之后实际准备调用的 `PreparedLlmCall.systemPromptUpdate`;先前快照不是准入输入。因此首次请求、恢复的会话、路由变更以及同一路由的能力变更,都使用将服务该调用的绑定适配器的能力。
+
+### 决策规则
+
+`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,即使渲染文本为空也预留头节点。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。无论路由或序列状态如何,空渲染文本都会清除每个生效的系统节点。不具备能力的路由或新请求序列面对非空渲染文本时,即使有效文本未变也执行归并。除此之外,有效文本相同时不产生事件。具体操作如下:
+
+| 路由能力 | 前缀状态 | 操作 |
+|---|---|---|
+| 无 | 非空渲染文本,任意前缀状态 | 为每个非空的后续系统节点记录空内容替换,随后按需用渲染文本重写首个系统节点 |
+| `in-history` | 当前请求序列延续 | 在该步骤的 `user/message` 事件之前追加新的 `system/message`;仅追加本身不需要记录 `request/header` |
+| `in-history` | 非空渲染文本,新序列开始 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点,即使最新有效文本未变也执行 |
+| 任意 | 渲染后的提示词为空 | 为非空的后续系统节点记录空内容替换,再按需清空头节点;派生消息中不保留任何提示词版本 |
+
+`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、可见工具 schema 集合发生了变化。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。
+
+首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。
+
+空头节点且没有生效的后续系统节点表示没有提示词。未生效的空尾节点不提供有效文本,因此重复清除与恢复会话都不会使旧提示词重新生效。重新提供非空文本使用同一准入规则:延续中的具备能力路由可以追加它;不具备能力的路由或新序列则重新填充头节点。清除使用普通的逐节点替换,而非 surface 删除或初次创建空头节点。
+
+### 呈现与记账
+
+Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。
+
+`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
+
+Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。
+
+Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释有效提示词。每个 target 为系统事件与位置替换保留不可变的前缀状态,其中只包含存活系统节点,以及将存活替换序号映射到继承 surface 位置的映射表。每次替换复制该表并删除被遮蔽的条目;历史前缀映射表保持不可变。普通追加与流式更新无需折叠提示词。节点是否存活由 surface 顺序决定,而不是事件顺序或来源引用:压缩可以在没有另一个系统事件的情况下恢复更早的提示词,头部重写的序号也可能大于更后位置的有效提示词。空节点仍可被定位,但不会覆盖非空提示词。早于最早已加载相关事件的端点,其顺序未知,除非已有替换位置索引。遇到这样的端点后,解释器会暂停公开之后的所有提示词文本,直到向前补页回放解析缺失的前缀;事件序号顺序不能确定 surface 顺序。历史卡片保留自己的前缀状态,而不是读取最终 surface。
+
+### 压缩
+
+`compaction-basic` 不变。`selectCompactableRange` 仍锚定在第一个非系统节点,因此第 0 号节点永不被遮蔽,更后的历史内节点则可能被遮蔽;`buildSummarizationInput` 将派生的头节点前置到 `messages`,再按 surface 顺序加入每个被遮蔽节点的派生消息,因此区域中途的系统节点在原位被回放,摘要调用仍是对话的真实前缀。
+
+## Alternatives considered
+
+**只发送变化的片段作为增量。** 模型把最新的系统消息当作完整提示词,因此增量会静默丢掉每个未变化的片段。基于模型约定被否决。
+
+**用插件配置而不是模型能力启用历史内模式。** 部署标志可能把不具备能力的模型与追加的系统消息配对,这样的模型最多把它们当作普通历史。该能力属于兑现它的路由;适配器目录已经承载逐模型的容量信息。被否决。
+
+**永远追加,从不重新基线化。** 规则单一,但第 0 号节点会在会话整个生命周期内保持过时,压缩之后的每个请求都要携带过时的头部加替换消息。在序列开始处重新基线化不花额外代价,因为缓存在那里已经丢失。被否决。
+
+**每次恢复都重新基线化。** 为更简单的恢复路径接受每次进程重启一次缓存未命中。缓存跨重启持续数小时到数天,而日志已经承载恢复所需的一切。被否决。
+
+**把系统消息放在该步骤的用户消息之后。** 两个位置都在已缓存前缀之后,但模型会在读到必须应用指令的输入之后才读到指令;system 在 user 之前与最前位置的顺序一致。被否决。
+
+**在 `agent/pre-step` waterfall 之前投影提示词。** 投影将看不到在该 waterfall 内执行的压缩,刚追加的节点可能在同一步骤内被遮蔽,请求就会把第 0 号节点的过时提示词作为唯一的系统消息携带。在 waterfall 之后投影让规则保持为构建请求所用 surface 的纯函数。被否决。
+
+**用先前的请求上下文决定准入。** 它描述上一次调用,而非请求中间件之后绑定的适配器。在首次调用、恢复之后、路由或能力变更之后,它可能选错提示词表示。在提交提示词与用户消息之前解析,还能防止取消时接纳未发送的内容。被否决。
+
+**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与 tools 的情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。
+
+**仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。
+
+**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。
+
+## Consequences
+
+- 具备能力的路由上的提示词变更保住提供方前缀缓存;追加的节点在该序列的每个请求上付出自身的 token 开销,直到压缩遮蔽它。提示词在多数步骤都变化的部署,更适合把那个事实移入运行时上下文。
+- 请求头部不是系统提示词唯一可能的位置:「模型看到了什么」的读者折叠 surface 并取最新的系统节点,明细的系统数字遵循同一规则。
+- `request/context` 快照记录已准备的路由与声明模式;它描述准入结果,而不决定准入。不具备能力的路由逐系统节点记录归并,保留其间的用户、assistant 与工具历史。
+- 模型约定按所提供的内容记录。若发布的模型收窄了约定——例如只在有界窗口内兑现最新的系统消息——规则需要序列开始之外的重新基线化触发条件。
+- 重写或重排系统消息的代理会静默破坏替换语义;真实 API e2e 的缓存命中断言是探测器。
+
+## Testing
+
+生命周期验证要求:提示词未变更时不产生事件,具备能力的路由在恢复后追加变更后的提示词。TypeScript 与 Python SDK 的期望输出都必须包含带类型的追加 `system/message` 事件,遵循 [SDK 快照策略](../../../../docs/testing.zh.md)。[TypeScript SDK 通知](../../../../snapshots/sdk/system-prompt-in-history/notifications.expected.jsonl)与 [Python SDK 提示词历史](../../../../scripts/snapshots/python-sdk-single-exe/minimal-in-history/prompt-history.json)记录了追加的提示词事件与保留的提示词版本。
+
+- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` 覆盖文本变化或未变时从具备能力切换到不具备能力的路由、反向路由切换、恢复时的路由准入、请求中间件或准备阶段取消,以及已准备路由保持绑定时并发选择发生变化。重试压缩用例覆盖遮蔽最新提示词后有或没有更早更新存活的情况,并验证复用已接纳的组装结果、用户消息仅接纳一次,以及未变的后续重试不会多记序列 header。具备和不具备能力路由的清除用例会移除三个生效提示词版本,验证重复请求与带 seed 的恢复保持为空且不多记提示词事件,并仅恢复新文本;日志重建与 pi 转换器都不保留旧指令。`src/agent.ts` 与 `src/runtime-context.ts` 的聚焦覆盖率在语句、分支、函数和行四项均达到 100%。
+- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住序列延续时的追加、序列开始时无论是否存在后续存活节点、有效文本是否变化都执行的重新基线化、空提示词对所有生效版本的清除,以及不具备能力时只做替换的行为。
+- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。
+- `packages/llm/llm/tests/service.spec.ts`、`packages/llm/llm-deepseek/tests/adapter.spec.ts` 与 `packages/test-support/llm-replay/tests/llm-replay.spec.ts` 钉住已解析模型信息上声明的模式,以及加载时对任何其他值的拒绝。
+- `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住最新与中间提示词移除、精确启发式总量、头部改写后的 surface 顺序、额外来源引用、休眠空节点与回退清空、不可变转换、紧凑保留检查点、延迟注册、重放和版本失效。
+- `packages/client/ui-conversation`、`ui-chat` 与 `ui-trajectory` 的客户端测试钉住更新卡片、同一步骤 header 的去重、更新之后不存在系统变更,以及合成的轨迹 header。
+- 无密钥的手写快照 `snapshots/session/system-prompt-in-history/` 在回放路由上声明该能力,通过 fixture 片段在第一次工具调用之后改变提示词,钉住追加的 `system/message`、未被触及的第 0 号节点、唯一一条 `request/header` 以及 `request/context` 中的模式。
+- `packages/llm/llm-deepseek/tests/adapter.e2e.ts` 针对 `DEEPSEEK_IN_HISTORY_MODEL` 指定的模型运行两个步骤并夹带一次提示词变更,断言回复遵循追加的提示词,并断言追加后的请求比同一对话在重写最前提示词时读取更多的缓存 token;该变量未设置时跳过。

+ 2 - 2
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.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/testing/2026-08-24-session-log-snapshot-corpus.md
-2026-08-24-session-log-snapshot-corpus.md: bd8941a40bf1c5f0dac15205c5ab0c7d4a3179aa
-2026-08-24-session-log-snapshot-corpus.zh.md: d27a3b90e13c73fd46794992d84e6393f0d030ce
+2026-08-24-session-log-snapshot-corpus.md: ebcd9709f9cd17ad288d787a13ca66efee5fcc42
+2026-08-24-session-log-snapshot-corpus.zh.md: 8d0614e150c927b491784b51a166e752a7718dae

+ 3 - 1
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md

@@ -16,7 +16,7 @@ Reserve the top-level `snapshots/` tree and `*.snapshot.ts` suffix for scenarios
 
 This decision supersedes the ACP-specific placement and controller ownership in the [record-once/replay-deterministic decision](2026-06-19-acp-snapshot-tests.md), while that note remains authoritative for session-log replay, exceptional overrides, normalization, and ACP transcript comparison.
 
-The recorded session remains the primary input and expected output. Human-originated messages drive the selected public interface, recorded assistant chunks drive deterministic model replay, and the normalized persisted result must equal the fixture. Parent and child sessions share one typed redaction map. Committed fixtures contain relationship-preserving identity tokens and replace request system prompts and tool schemas with tokens; each distinct header class retains one explicit sidecar owner.
+The recorded session remains the primary input and, for current-generation scenarios, the expected output. Human-originated messages drive the selected public interface, recorded assistant chunks drive deterministic model replay, and the normalized persisted result must equal the fixture. Parent and child sessions share one typed redaction map. Committed fixtures contain relationship-preserving identity tokens and replace request system prompts and tool schemas with tokens; each distinct header class retains one explicit sidecar owner.
 
 Fixture decoding and comparison depend only on the selected JSONL content; filenames identify inventory roles but are not parser inputs. The same strict static catalog validates replay, seed, record, refresh, and normalized comparison paths.
 
@@ -30,6 +30,8 @@ Every existing ACP scenario receives a behavior-preserving destination. Ordinary
 
 Workspace inputs remain scenario-local. A mutating scenario compares a complete expected final workspace that record and refresh never rewrite, so a model or tool self-report cannot satisfy the test. Existing intentional session reuse remains an explicit acyclic owner reference; the corpus adds no workspace inheritance or general fixture-merging mechanism.
 
+Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no canonical V3 sibling that could displace them. Separate `writer.expected.jsonl` and `writer.<ordinal>.expected.jsonl` files pin exact normalized native V3 parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations.
+
 ## Alternatives considered
 
 **Keep ACP as the universal driver.** This preserves the existing harness but continues coupling backend coverage to a low-priority protocol and cannot prove the supported headless, SDK, and Web launch paths.

+ 3 - 1
.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 本决策取代[一次录制/确定性回放决策](2026-06-19-acp-snapshot-tests.zh.md)中 ACP 专属的放置位置与控制器所有权;后者继续负责会话日志回放、例外 override、规范化和 ACP transcript 比较。
 
-录制会话仍是主要输入和预期输出。来自用户的消息驱动所选公开接口,录制的 assistant chunk 驱动确定性模型回放,规范化后的持久化结果必须等于 fixture。父会话和子会话共享同一类型化脱敏映射。提交的 fixture 使用保留关系的身份 token,并将请求 system prompt 和工具 schema 替换为 token;每个不同 header 类仍保留一个显式 sidecar 所有者。
+录制会话仍是主要输入,并在当前代际场景中同时作为预期输出。来自用户的消息驱动所选公开接口,录制的 assistant chunk 驱动确定性模型回放,规范化后的持久化结果必须等于 fixture。父会话和子会话共享同一类型化脱敏映射。提交的 fixture 使用保留关系的身份 token,并将请求 system prompt 和工具 schema 替换为 token;每个不同 header 类仍保留一个显式 sidecar 所有者。
 
 Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 inventory role,但不是 parser 输入。replay、seed、record、refresh 与规范化比较路径都使用同一个严格静态 catalog 校验。
 
@@ -30,6 +30,8 @@ Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的
 
 Workspace 输入继续归各场景本地所有。变更文件的场景比较完整的预期最终 workspace,record 与 refresh 绝不改写该预期,因此模型或工具的自报结果无法满足测试。现有的有意会话复用继续使用显式、无环的所有者引用;语料不增加 workspace 继承或通用 fixture 合并机制。
 
+当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的规范 V3 同角色文件。单独的 `writer.expected.jsonl` 与 `writer.<ordinal>.expected.jsonl` 文件固定精确的规范化原生 V3 父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。
+
 ## Alternatives considered
 
 **继续将 ACP 作为通用驱动器。** 这会保留现有 harness,但继续把后端覆盖耦合到低优先级协议,也无法证明受支持的 headless、SDK 和 Web 启动路径。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.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/testing/2026-09-04-session-open-performance-gate.md
-2026-09-04-session-open-performance-gate.md: 2937a2aec1dbddb31fde82d2617d69852a611d90
-2026-09-04-session-open-performance-gate.zh.md: b6608ca79d07c3e9fb00d62801038ecefbdf944c
+2026-09-04-session-open-performance-gate.md: d5447f93666a5cb27af2073a4a66f99d0c39f6ee
+2026-09-04-session-open-performance-gate.zh.md: 657f77fb76cf84afc36301d22d9a7798c559075b

+ 3 - 3
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md

@@ -16,9 +16,9 @@ Linux pull requests run a required `node 24 / benchmarks` job that executes `pnp
 
 Required performance gates live under top-level `benchmarks/`, grouped by measured user path rather than package ownership. Host files use `*.bench.ts`, Client-face files use `*.bench.client.ts`, and scenario-specific workers and fixtures stay beside their benchmark without a benchmark suffix. Package-local `.perf.ts` files remain non-gating diagnostics; `scripts/` owns orchestration rather than benchmark cases.
 
-The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. The fixture constructs the immutable released-v0 physical rows directly instead of depending on a current-runtime historical encoder; compression and every measured read or migration entry point still use production code. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions.
+The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. Each synthetic turn starts its step before appending user surface input, allowing the V2-to-V3 migration to reserve a protected system head without reordering history. The fixture constructs released-v0 physical rows directly instead of depending on a current-runtime historical encoder; compression and every measured read or migration entry point still use production code. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions.
 
-Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and includes migration; read-only consumers do not publish a successor, while writable Agent resume does. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published V2 successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches.
+Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and includes migration; read-only consumers do not publish a successor, while writable Agent resume does. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published current-generation successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches.
 
 Each access-kind and endpoint sample runs in a fresh compiled Node child process. Module imports, Host service initialization, and fixture preparation finish before measurement; the measured process performs no extra parse warm-up. Normal-heap mode runs five independent samples, reports every sample plus minimum, median, and maximum, and enforces access-specific fixed budgets against the median. Another child runs the same path under a fixed 128 MB old-space limit and checks only that it completes; extra GC caused by the constrained heap does not enter the normal timing baseline.
 
@@ -35,7 +35,7 @@ The phase profile invokes each layer's production entry point explicitly and doe
 
 Normal-heap mode performs a fixed pair of explicit garbage collections after Host initialization and before the cold Session is touched, then records starting memory. It stops operation timing before performing the same garbage-collection sequence while the scenario's intended long-lived objects remain explicitly reachable, then records ending memory. The Agent-resume endpoint retains the Agent, Session, complete events, and normal service caches; its `heapUsed` delta is the primary resident-Session memory budget. Every scenario also reports `external`, `arrayBuffers`, post-GC RSS, and `process.resourceUsage().maxRSS`; the 128 MB mode prevents transient allocation peaks from being hidden by endpoint collection. Explicit garbage-collection time is excluded from operation timing.
 
-The performance gate does not duplicate semantic assertions owned by functional tests; it requires only that the target call completes and reaches its measured endpoint. The Client-fold benchmark continues to use the real `ConversationNodeAssembler` and every Chat Definition, and requires both the large window's absolute time and its scaling relative to the small window to remain below fixed budgets.
+A small untimed fixture prerequisite verifies current migration, message preservation, immutable V0 bytes, and successor reopen. Worker failures retain the first and last ten stderr lines, or fatal heap diagnostics, so setup rejection remains distinguishable from a budget breach. The timed performance cases do not duplicate semantic assertions owned by functional tests; it requires only that the target call completes and reaches its measured endpoint. The Client-fold benchmark continues to use the real `ConversationNodeAssembler` and every Chat Definition, and requires both the large window's absolute time and its scaling relative to the small window to remain below fixed budgets.
 
 Budgets are calibrated per measured endpoint. Two repeated Node 24.19 x64 CI runs differ by at most 5.2% in their medians; their CPU-heavy wall times are 1.95–2.06× the Node 24.18 arm64 reference run. Except for current-generation `open`, source constants record expected reference-machine durations; `ciTimeBudget()` multiplies them by the measured 2× CI time scale and 1.25× variance headroom. Current-generation `open` uses a directly measured standard-runner expectation of 50 ms with only the 1.25× headroom, rounded up to a 63 ms budget. The retained-heap and Client-fold scaling budgets use only the 1.25× headroom because neither is a wall-clock duration. The 128 MB completion check remains an independent transient-allocation limit. The resulting first-open time limits, constrained-heap checks, and Client-fold limits all reject the known regressions. Pre-stack commit `0d7ea53743e273930a31e9e2b6ca682f21dd4ca5` is the fixed calibration and review reference; CI does not check out or execute the historical repository. Budgets are reviewed source constants and have no environment-variable override.
 

+ 3 - 3
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md

@@ -16,9 +16,9 @@ Linux pull request 运行必需的 `node 24 / benchmarks` job,执行 `pnpm run
 
 必需性能 gate 位于顶层 `benchmarks/`,按被测用户路径而非 package 归属组织。Host 文件使用 `*.bench.ts`,Client 面文件使用 `*.bench.client.ts`,场景专属 worker 与 fixture 留在对应 benchmark 旁且不带 benchmark 后缀。包内 `.perf.ts` 文件仍是非门禁诊断;`scripts/` 负责编排而不承载 benchmark case。
 
-Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。fixture 直接构造不可变的 released-v0 physical rows,不依赖当前 runtime 的历史 encoder;压缩以及所有被测读取和 migration 入口仍使用生产代码。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。
+Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。每个合成 turn 先开始 step,再追加 user surface 输入,使 V2-to-V3 migration 能预留受保护的 system head 而不重排历史。fixture 直接构造 released-v0 physical rows,不依赖当前 runtime 的历史 encoder;压缩以及所有被测读取和 migration 入口仍使用生产代码。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。
 
-每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,包含 migration;只读消费者不发布后继文件,可写 Agent resume 才会发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的 V2 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。
+每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,包含 migration;只读消费者不发布后继文件,可写 Agent resume 才会发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的当前 generation 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。
 
 每个 access kind 与 endpoint 的样本都在全新、已编译的 Node 子进程中运行。模块加载、Host 服务初始化和 fixture 准备在测量开始前完成;测量进程不执行额外的预热解析。正常堆模式运行五个独立样本,报告全部样本及最小值、中位数和最大值,并以中位数执行各访问状态独立的固定预算。另一个子进程使用固定 128 MB old-space 上限运行同一路径,只判断能否完成;低堆限制引起的额外 GC 不进入正常时间基线。
 
@@ -35,7 +35,7 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮
 
 正常堆模式在 Host 初始化完成且 Session 尚未访问时执行固定的两轮显式 GC,记录起点内存;操作计时结束后,在该场景要求的长期对象仍明确可达时再次执行同样的 GC,再记录终点内存。Agent resume 场景在终点保留 Agent、Session、完整 events 与正常服务 cache,它的 `heapUsed` 增量是常驻 Session 内存预算的主指标。每个场景同时报告 `external`、`arrayBuffers`、GC 后 RSS 和 `process.resourceUsage().maxRSS`;128 MB 模式继续防止瞬时分配峰值被终点 GC 隐藏。显式 GC 时间不计入操作时间。
 
-性能 gate 不重复功能测试的内容断言,只要求目标调用完成并到达对应的可观察终点。Client fold benchmark 继续使用真实 `ConversationNodeAssembler` 与全部 Chat Definition,要求大窗口的绝对时间和相对小窗口的缩放比均低于固定预算。
+一个不计时的小型 fixture 前置用例验证当前 migration、消息保留、V0 字节不变及后继再次打开。Worker 失败时保留 stderr 首尾各十行或致命堆错误,使准备阶段拒绝与预算超限可区分。计时性能用例不重复功能测试的内容断言,只要求目标调用完成并到达对应的可观察终点。Client fold benchmark 继续使用真实 `ConversationNodeAssembler` 与全部 Chat Definition,要求大窗口的绝对时间和相对小窗口的缩放比均低于固定预算。
 
 预算按各测量终点分别校准。两次 Node 24.19 x64 CI 运行的中位数最大相差 5.2%;其 CPU 密集型壁钟时间是 Node 24.18 arm64 参考运行的 1.95–2.06 倍。除当前 generation `open` 外,源码常量记录参考机器上的预期耗时;`ciTimeBudget()` 将其乘以实测的 2 倍 CI 时间系数和 1.25 倍波动余量。当前 generation `open` 使用标准运行器直接测得的 50 ms 预期值,仅乘以 1.25 倍余量,向上取整得到 63 ms 预算。GC 后增量堆与 Client fold 缩放预算不属于壁钟时间,因此只使用 1.25 倍余量。128 MB 完成性检查仍是独立的瞬时分配限制。由此得到的 first-open 时间上限、受限堆检查与 Client fold 上限都会拒绝已知退化。栈前参考提交固定为 `0d7ea53743e273930a31e9e2b6ca682f21dd4ca5`,只用于校准和评审预算;CI 不 checkout 或执行历史仓库。预算是源码中的受评审常量,不由环境变量覆盖。
 

+ 0 - 6
.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml

@@ -1,6 +0,0 @@
-# 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/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md
-2026-09-02-system-prompt-as-surface-node.md: 56577a7199235e95f4a7c6500140c8a8841d48dc
-2026-09-02-system-prompt-as-surface-node.zh.md: da864fd300c93cae0210e758562b823c0a61679a

+ 0 - 78
.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.md

@@ -1,78 +0,0 @@
-# Agent Note: The system prompt is surface node 0
-
-Status: proposed
-
-English | [中文](2026-09-02-system-prompt-as-surface-node.zh.md)
-
-## Problem
-
-The system prompt has a different durable representation from every other message the model reads. Conversation messages are surface events (`user/message`, `assistant/message`, `tool/result`) folded in seq order by `Session.deriveMessages()`; the system prompt is the `system` field of the log-only `request/header` snapshot, and each DeepSeek serializer prepends it as wire message 0 (`serializeRequest`, `serializeRequestWithImages`). The [reconstructable-requests Agent Note](../../implemented/architecture/2026-07-05-reconstructable-requests.md) made both halves durable, but it left one model-visible fact with two homes: the surface owns the messages, the header owns the message in front of them.
-
-That split forces every reader of "what did the model see" to join two sources. The compaction summarizer (`buildSummarizationInput`) copies `header.system` in front of the region's derived messages; `dsh-token-meter` estimates the system prompt from the header while pricing every other message from the surface; the Web request-prompt card, the trajectory view, and the snapshot normalizer's `{{system}}` placeholder each read the header on their own. The loop's change detection is also split: `headerEquals` compares `system` byte-for-byte beside `config` and `tools`, so a prompt change and a tool change are indistinguishable in the log (`request/header` reason `change`) even though they are different operations on the conversation.
-
-The split also blocks the next step. A model that accepts a mid-conversation `system` message as a prompt replacement needs the harness to append a system-role message to history; with the prompt living in the header there is no surface representation to append, and the header would have to be frozen by special case. The [in-history replacement proposal](../feature/2026-09-02-in-history-system-prompt-replacement.md) depends on this note.
-
-## Proposal
-
-Move the system prompt onto the surface. It becomes an ordinary surface event, `system/message`, and every prompt lifecycle operation is one of the two existing `SurfaceOp` variants applied to that event type. The wire request does not change: the surface fold yields the same message list the serializers already build today, with the system message first.
-
-### The event
-
-`system/message` joins `SurfaceEventType` beside `user/message`, `assistant/message`, and `tool/result`. Its payload mirrors `tool/result`: `{ turn, step, message }`, where `message` is a `Message` with `role: 'system'`, exactly one text block holding the rendered prompt, and source `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`. `deriveEventMessage` projects it verbatim, so `deriveMessages()` returns the system message at its surface position and both DeepSeek serializers, which already pass a `role: 'system'` history message through unchanged, emit it as wire message 0. `EpochHeader.system` is removed; the header keeps `config`, `adapterDefaults`, and `tools`.
-
-### The operations
-
-| Situation | Surface operation |
-|---|---|
-| First request of a session with a non-empty rendered prompt | append `system/message` as surface node 0, before the first `user/message` of the step |
-| Rendered prompt differs from the prompt at node 0 | replace node 0: `surfaceOp: { op: 'replace', start: <seq of node 0>, end: <same> }`, `sourceEventSeqs: [<seq of node 0>]` |
-| Rendered prompt is empty on the first request | no system node; a later non-empty prompt appends node 0 when the surface has no system node yet |
-
-Replacing node 0 is today's head rewrite expressed on the surface: the provider prefix changes from the first token, the log records the shadowed node through `sourceEventSeqs`, and `replaceGeneration` advances exactly as it does for a compaction replacement, so the loop's existing `startsSeries` detection (`requestSurfaceGeneration !== surfaceGeneration`) covers the prompt change without a `system` comparison in `headerEquals`. `request/header` keeps reasons `initial`, `resume`, `change`, and `series`; `change` now means config or tools changed.
-
-### Ownership in the loop
-
-`dsh-agent-loop` owns a `SystemPromptProjection` beside `RuntimeContextProjection` in `runtime-context.ts`. It restores the current system node from the log (the latest surviving `system/message` on the surface), follows `session/event` for new system nodes and for replacements whose `sourceEventSeqs` shadow the retained one, and returns the uncommitted append or replace intent when the rendered prompt differs. `turn()` commits that intent immediately before the step's `user/message` events, so the log order is the wire order. `step()` no longer passes `system` to `buildRequest`; the request is `header.config` plus `deriveMessages()` plus `header.tools`. The `dsh-agent-loop/invariant` companion keeps comparing the rebuilt request against the frozen one, now with the system message inside `messages`. `docs/architecture.md` records the new loop step order: claim, assemble, project system prompt, project runtime context, pre-step, commit system node, commit user messages, build request.
-
-### Consumers retargeted
-
-| Consumer | Today | After |
-|---|---|---|
-| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | prepend `options.system` | serialize `options.messages` only; `GenerateOptions.system` remains for direct one-shot callers such as the summarizer and title providers |
-| `compaction-basic` `buildSummarizationInput` | `header.system` + region messages | node 0's derived message + region messages, still a genuine prefix of the routed request |
-| `compaction-basic` `selectCompactableRange` | head-anchored at `surfaceNodes[0]` | anchored at the first non-system node; node 0 is never inside a compaction range |
-| `dsh-token-meter` system estimate | `header.system` length | the system node is priced like every other surface node; the context breakdown labels it by its source plugin |
-| Web request-prompt card, trajectory request-header node, request inspection | read `header.system` | read the `system/message` node; the card keeps its collapsed inspectable presentation and is never a chat bubble |
-| Snapshot normalizer `{{system}}` placeholder, plan-mode tests asserting `header.system` | header | the system node's text |
-| TypeScript and Python SDK expected outputs | no system event | include the `system/message` event |
-| Human transcript projections (`isAppendSurfaceEvent` readers) | no system events | skip `system/message`; it is model history, not conversation |
-
-`RuntimeContextProjection` and `SystemPromptProjection` are symmetric: both watch owned surface nodes and their shadowing through `sourceEventSeqs`, and both hand the loop an uncommitted message that `turn()` commits. The difference is the role and the operation set — runtime context appends user-role snapshots only, the system prompt appends once and then replaces.
-
-## Alternatives considered
-
-**Keep `header.system` and add `system/message` only for updates.** Two homes for one fact: every consumer above would read the header for message 0 and the surface for later messages, and the loop would need a special case that ignores `system` in `headerEquals` while a surface system node exists. Rejected because the point of the change is one representation.
-
-**A dedicated log-only `system-prompt/change` event that rewrites the header.** Preserves the header as the home of the prompt and records changes as their own event kind, but still cannot express a system message inside history, so the in-history proposal would need a second mechanism anyway. Rejected.
-
-**Synthesize the system message inside the adapter from consecutive headers.** The adapter is stateless per request and never sees the log; a wire history that depends on adapter state is not reconstructable from the surface fold. Rejected.
-
-**Express the prompt as a `user/message` snapshot like runtime context.** Reuses an existing event type but sends the wrong role, so a model that treats a system message as authoritative would not. Rejected.
-
-## Acceptance criteria
-
-- `SurfaceEventType` contains `system/message`; `deriveEventMessage` projects it; `Session.append('system/message', …)` requires a `SurfaceIntent` like the other surface events.
-- `EpochHeader` has no `system` field; `headerEquals` compares `config`, `adapterDefaults`, and `tools` only.
-- A first request with a non-empty rendered prompt appends `system/message` as surface node 0 before the step's first `user/message`; a changed prompt replaces node 0 with `sourceEventSeqs` naming the shadowed node; an unchanged prompt appends nothing.
-- The DeepSeek wire request for every loop step is byte-identical to today's for the same session history: system first, then the folded conversation.
-- Compaction never selects node 0; the summarizer's replayed prefix starts with node 0's derived message.
-- `dsh-token-meter`, the Web request-prompt card, trajectory and inspection views, the snapshot normalizer, plan-mode tests, and both SDK expected outputs read the system node; the `dsh-agent-loop/invariant` companion rebuilds requests with the system message inside `messages`.
-- Keyless recorded snapshots that exercise a mid-session prompt change (plan mode entering and leaving) show a replaced node 0 instead of a `request/header` `change`.
-- `docs/architecture.md`, the `dsh-agent-loop`, `dsh-session`, `dsh-system-prompt`, `dsh-compaction-basic`, and `dsh-token-meter` READMEs, and the reconstructable-requests Agent Note describe the surface node as the home of the system prompt.
-
-## Risks
-
-- Every reader of `header.system` moves in one change; a missed reader fails at compile time because the field is gone, which is the intended failure mode.
-- Compaction region selection gains an invariant (node 0 is never compacted). A compaction provider other than `compaction-basic` that anchors at `surfaceNodes[0]` would shadow the prompt; the `dsh-session` surface manager rejects a replacement whose range covers surface node 0 while node 0 is a `system/message` unless the replacing event is itself a `system/message` covering exactly that node, so the invariant is enforced where the operation happens, not only in the shipped provider. System nodes at later positions carry no such protection: a compaction range may shadow them.
-- Replacing node 0 advances `replaceGeneration`, which today means "compaction happened" to some readers; those readers switch to inspecting the replacement event's type.
-- Recorded snapshot fixtures whose logs contain `header.system` are re-recorded; the fixtures, not the normalizer, change.

+ 0 - 78
.agents/notes/proposed/architecture/2026-09-02-system-prompt-as-surface-node.zh.md

@@ -1,78 +0,0 @@
-# Agent Note: 系统提示词是 surface 的第 0 号节点
-
-Status: proposed
-
-[English](2026-09-02-system-prompt-as-surface-node.md) | 中文
-
-## Problem
-
-系统提示词的持久化表示与模型读到的其他所有消息都不同。对话消息是 surface 事件(`user/message`、`assistant/message`、`tool/result`),由 `Session.deriveMessages()` 按 seq 顺序折叠;系统提示词则是仅记日志的 `request/header` 快照中的 `system` 字段,每个 DeepSeek 序列化器把它前置为协议消息 0(`serializeRequest`、`serializeRequestWithImages`)。[可重建请求 Agent Note](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md) 让两半都成为持久数据,却让一个模型可见的事实拥有两个归属:surface 拥有消息,header 拥有排在这些消息之前的那条消息。
-
-这种拆分迫使每个想知道「模型看到了什么」的读取方都要合并两个来源。压缩摘要器(`buildSummarizationInput`)把 `header.system` 复制到区域派生消息之前;`dsh-token-meter` 从 header 估算系统提示词,却从 surface 为其他每条消息计价;Web 请求提示词卡片、轨迹视图和快照归一化器的 `{{system}}` 占位符各自单独读取 header。循环的变更检测同样被拆开:`headerEquals` 在 `config` 和 `tools` 旁边逐字节比较 `system`,因此提示词变更与工具变更在日志中无法区分(`request/header` 的 reason 都是 `change`),尽管它们是对对话的两种不同操作。
-
-这种拆分还阻塞了下一步。一个把对话中途的 `system` 消息当作提示词替换来接受的模型,需要 harness 向历史追加一条 system 角色消息;当提示词住在 header 里时,没有可追加的 surface 表示,header 也只能靠特例被冻结。[历史内替换提案](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 依赖本 Agent Note。
-
-## Proposal
-
-把系统提示词搬到 surface 上。它成为一个普通的 surface 事件 `system/message`,提示词生命周期中的每个操作都是对该事件类型施加现有两种 `SurfaceOp` 变体之一。协议请求不变:surface 折叠产出的消息列表与序列化器今天构建的完全相同,系统消息在最前面。
-
-### 事件
-
-`system/message` 加入 `SurfaceEventType`,与 `user/message`、`assistant/message`、`tool/result` 并列。它的载荷与 `tool/result` 对称:`{ turn, step, message }`,其中 `message` 是 `role: 'system'` 的 `Message`,恰好一个文本块承载渲染后的提示词,source 为 `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`。`deriveEventMessage` 逐字投影它,因此 `deriveMessages()` 在其 surface 位置返回系统消息,而两个 DeepSeek 序列化器本已原样透传 `role: 'system'` 的历史消息,会把它作为协议消息 0 发出。`EpochHeader.system` 被移除;header 保留 `config`、`adapterDefaults` 和 `tools`。
-
-### 操作
-
-| 情形 | surface 操作 |
-|---|---|
-| 会话首个请求且渲染后的提示词非空 | 追加 `system/message` 作为 surface 第 0 号节点,位于该步骤首条 `user/message` 之前 |
-| 渲染后的提示词与第 0 号节点不同 | 替换第 0 号节点:`surfaceOp: { op: 'replace', start: <第 0 号节点的 seq>, end: <同一值> }`,`sourceEventSeqs: [<第 0 号节点的 seq>]` |
-| 首个请求时渲染后的提示词为空 | 没有系统节点;之后出现非空提示词且 surface 尚无系统节点时,追加为第 0 号节点 |
-
-替换第 0 号节点就是今天的头部重写在 surface 上的表达:提供方前缀从第一个 token 起改变,日志通过 `sourceEventSeqs` 记录被遮蔽的节点,`replaceGeneration` 与压缩替换时一样推进,因此循环现有的 `startsSeries` 检测(`requestSurfaceGeneration !== surfaceGeneration`)无需在 `headerEquals` 中比较 `system` 即可覆盖提示词变更。`request/header` 保留 `initial`、`resume`、`change`、`series` 四种 reason;`change` 现在表示 config 或 tools 变更。
-
-### 循环中的归属
-
-`dsh-agent-loop` 在 `runtime-context.ts` 中与 `RuntimeContextProjection` 并列拥有一个 `SystemPromptProjection`。它从日志恢复当前系统节点(surface 上最新存活的 `system/message`),跟随 `session/event` 观察新的系统节点以及 `sourceEventSeqs` 遮蔽了所保留节点的替换,并在渲染后的提示词不同时返回未提交的追加或替换意图。`turn()` 紧接在该步骤的 `user/message` 事件之前提交该意图,因此日志顺序即协议顺序。`step()` 不再向 `buildRequest` 传递 `system`;请求由 `header.config`、`deriveMessages()` 和 `header.tools` 构成。`dsh-agent-loop/invariant` 伴随组件继续把重建的请求与冻结的请求比较,只是系统消息现在位于 `messages` 内。`docs/architecture.md` 记录新的循环步骤顺序:领取、装配、投影系统提示词、投影运行时上下文、pre-step、提交系统节点、提交用户消息、构建请求。
-
-### 消费方迁移
-
-| 消费方 | 现状 | 变更后 |
-|---|---|---|
-| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | 前置 `options.system` | 只序列化 `options.messages`;`GenerateOptions.system` 为摘要器、标题提供方等直接单次调用方保留 |
-| `compaction-basic` 的 `buildSummarizationInput` | `header.system` + 区域消息 | 第 0 号节点的派生消息 + 区域消息,仍是已路由请求的真实前缀 |
-| `compaction-basic` 的 `selectCompactableRange` | 锚定在头部 `surfaceNodes[0]` | 锚定在首个非系统节点;第 0 号节点永不落入压缩范围 |
-| `dsh-token-meter` 的系统提示词估算 | `header.system` 长度 | 系统节点与其他每个 surface 节点一样计价;上下文明细按其 source 插件标注 |
-| Web 请求提示词卡片、轨迹请求 header 节点、请求检视 | 读取 `header.system` | 读取 `system/message` 节点;卡片保持折叠可检视的呈现,永不作为聊天气泡 |
-| 快照归一化器的 `{{system}}` 占位符、断言 `header.system` 的 plan-mode 测试 | header | 系统节点的文本 |
-| TypeScript 与 Python SDK 期望输出 | 没有系统事件 | 包含 `system/message` 事件 |
-| 人类转录投影(`isAppendSurfaceEvent` 的读取方) | 没有系统事件 | 跳过 `system/message`;它是模型历史,不是对话 |
-
-`RuntimeContextProjection` 与 `SystemPromptProjection` 是对称的:两者都通过 `sourceEventSeqs` 观察自己拥有的 surface 节点及其被遮蔽的情况,都把一条未提交的消息交给循环由 `turn()` 提交。区别在于角色与操作集——运行时上下文只追加 user 角色快照,系统提示词追加一次之后只做替换。
-
-## Alternatives considered
-
-**保留 `header.system`,只为更新添加 `system/message`。** 一个事实两个归属:上述每个消费方都要从 header 读消息 0、从 surface 读后续消息,循环还需要一个在 surface 存在系统节点时让 `headerEquals` 忽略 `system` 的特例。被否决,因为本次变更的目的就是单一表示。
-
-**用专门的仅记日志事件 `system-prompt/change` 重写 header。** 保留 header 作为提示词归属,并把变更记录为独立事件种类,但仍无法表达历史内部的系统消息,历史内替换提案还是需要第二套机制。被否决。
-
-**在适配器内根据相邻 header 合成系统消息。** 适配器逐请求无状态且从不接触日志;依赖适配器状态的协议历史无法从 surface 折叠重建。被否决。
-
-**像运行时上下文那样用 `user/message` 快照表达提示词。** 复用了现有事件类型,却发送了错误的角色,因此把系统消息视为权威的模型不会这样对待它。被否决。
-
-## Acceptance criteria
-
-- `SurfaceEventType` 包含 `system/message`;`deriveEventMessage` 投影它;`Session.append('system/message', …)` 与其他 surface 事件一样要求 `SurfaceIntent`。
-- `EpochHeader` 没有 `system` 字段;`headerEquals` 只比较 `config`、`adapterDefaults` 和 `tools`。
-- 渲染后的提示词非空的首个请求在该步骤首条 `user/message` 之前追加 `system/message` 作为 surface 第 0 号节点;提示词变更时以指明被遮蔽节点的 `sourceEventSeqs` 替换第 0 号节点;提示词不变时不追加任何内容。
-- 对同一会话历史,每个循环步骤的 DeepSeek 协议请求与今天逐字节一致:系统消息在先,随后是折叠后的对话。
-- 压缩永不选中第 0 号节点;摘要器回放的前缀以第 0 号节点的派生消息开头。
-- `dsh-token-meter`、Web 请求提示词卡片、轨迹与检视视图、快照归一化器、plan-mode 测试以及两个 SDK 的期望输出都读取系统节点;`dsh-agent-loop/invariant` 伴随组件重建请求时系统消息位于 `messages` 内。
-- 演练会话中途提示词变更(进入与退出 plan 模式)的无密钥录制快照显示被替换的第 0 号节点,而不是 `request/header` 的 `change`。
-- `docs/architecture.md`、`dsh-agent-loop`、`dsh-session`、`dsh-system-prompt`、`dsh-compaction-basic`、`dsh-token-meter` 的 README 以及可重建请求 Agent Note 都把 surface 节点描述为系统提示词的归属。
-
-## Risks
-
-- `header.system` 的每个读取方在一次变更中迁移;遗漏的读取方因字段消失而在编译期失败,这正是预期的失败方式。
-- 压缩范围选择新增一条不变量(第 0 号节点永不被压缩)。除 `compaction-basic` 以外、锚定在 `surfaceNodes[0]` 的压缩提供方会遮蔽提示词;`dsh-session` 的 surface 管理器拒绝在第 0 号节点是 `system/message` 时覆盖第 0 号节点的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,因此不变量在操作发生处被强制,而不只在随发的提供方中。位于更后位置的系统节点没有此类保护:压缩范围可以遮蔽它们。
-- 替换第 0 号节点会推进 `replaceGeneration`,今天有些读取方把它理解为「发生了压缩」;这些读取方改为检查替换事件的类型。
-- 日志中包含 `header.system` 的录制快照 fixture 需要重新录制;改变的是 fixture,而不是归一化器。

+ 0 - 6
.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml

@@ -1,6 +0,0 @@
-# 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/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md
-2026-09-02-in-history-system-prompt-replacement.md: 229e92500936ec8742f341c4dc18184eb9a2fe0c
-2026-09-02-in-history-system-prompt-replacement.zh.md: f776f25a43f936024564488c1c53e9728fa19827

+ 0 - 74
.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.md

@@ -1,74 +0,0 @@
-# Agent Note: In-history system prompt replacement for cache-stable prompt changes
-
-Status: proposed
-
-English | [中文](2026-09-02-in-history-system-prompt-replacement.zh.md)
-
-## Problem
-
-Every system prompt change costs the whole provider prefix cache. The loop renders the prompt on every step; when the bytes differ — a plan-mode section entering or leaving, a skill or tool guidance section registering, an agent-scoped persona shadow, a changed `{{model}}` variable — the request's message 0 changes and the DeepSeek context cache misses from the first token. Long agentic sessions pay this repeatedly, and the [runtime-context snapshot design](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) exists precisely because moving a changing fact out of the prompt was the only way to keep the prefix stable.
-
-A DeepSeek model, provided as an unpublished model fact for this proposal, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix, so a tool-set change still invalidates the cache. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm.
-
-The harness has the representation for this only after the [system prompt is surface node 0](../architecture/2026-09-02-system-prompt-as-surface-node.md): a prompt change is then an operation on `system/message` surface nodes, and the choice between "replace node 0" and "append a new node" is a per-model decision.
-
-## Proposal
-
-For a model route that declares the capability, the loop appends a new `system/message` surface node instead of replacing node 0 when the rendered prompt changes and the prefix would otherwise survive. Everything else in the [surface-node design](../architecture/2026-09-02-system-prompt-as-surface-node.md) is unchanged: the event type, the projection owner, the serializers, and the presentation.
-
-### Capability
-
-The DeepSeek adapter's catalog model gains a validated optional field, `systemPromptUpdate`, with the single accepted value `'in-history'`; absence means the model needs message 0 rewritten. The adapter surfaces it on `LlmResolvedModelInfo` and `prepareCall()` returns it beside `context.contextWindow`, so the loop reads it from the same registration-bound metadata it already consumes. No default catalog entry declares it until the model is released; a deployment enables it through the `models` list in `cordis.yml`. Models without the field — including every current default entry and every `dsh-llm-pi-ai` route — keep the replace-node-0 behaviour exactly.
-
-### The decision rule
-
-`SystemPromptProjection` tracks the **effective prompt**: the text of the latest surviving `system/message` on the surface (node 0 when no later system node exists). When the rendered prompt differs from the effective prompt:
-
-| Route capability | Prefix state | Operation |
-|---|---|---|
-| none | any | replace node 0 |
-| `in-history` | the current request series continues (no compaction since the last request, no tools or config change) | append a new `system/message` before the step's `user/message` events |
-| `in-history` | a new series starts (compaction replaced the surface, or `request/header` records a `change` for tools or config) and no mid-history system node survives | replace node 0 with the current prompt |
-| `in-history` | a new series starts but a mid-history system node survives | append a new `system/message`; node 0 stays as it is |
-
-The third row exists because a series start already costs the cache; folding the prompt back into node 0 keeps the history short. The fourth row exists because the surface has no delete operation: replacing node 0 while a later system node survives would leave the model reading the later, stale node as authoritative, so the loop appends instead. In-history mode never rewrites node 0 while any later system node survives.
-
-Resume follows the mid-session rule. A new loop instance restores the effective prompt from the log and, when the freshly rendered prompt differs, appends — the provider cache may still be warm across a process boundary, and the `resume` header is not a series start.
-
-### Presentation and accounting
-
-A mid-history `system/message` uses the same collapsed request-prompt inspection card as node 0, labelled as a prompt update at its position in the request; it is never a chat bubble, transcript projections skip it, and SDK projections expose it as a typed event. `dsh-token-meter` prices it like any other surface node, so the per-step context breakdown shows the accumulated cost of retained prompt versions until compaction shadows them. `cacheReadTokens` on the following `assistant/message` usage is the observable effect: for a capable route the value covers the prefix through the last cached message; for a non-capable route it drops to the shared-prefix detection floor.
-
-### Verification plan
-
-- Unit tests in `dsh-agent-loop` for the projection: append on a mid-series change, replace on a series start without surviving mid-history nodes, append on a series start with one, append on resume, no operation when unchanged, and replace-only behaviour for a route without the capability.
-- Unit tests in `dsh-llm-deepseek` for catalog validation (`systemPromptUpdate` accepts `'in-history'` only) and for `prepareCall()` surfacing the field.
-- A keyless recorded snapshot under `snapshots/` whose composition declares the capability on the mock route and toggles plan mode mid-session, pinning the appended `system/message` and the untouched node 0; TypeScript and Python SDK expected outputs include the appended event.
-- A real-API e2e that runs two steps with a prompt change against a capable route and asserts that the second request's `cacheReadTokens` is at least the first request's prompt token count. It resolves its route from the standard credential and base-URL mechanism and self-skips when no capable route is configured.
-
-## Alternatives considered
-
-**Send only the changed sections as a delta.** The model treats the latest system message as the complete prompt, so a delta would silently drop every unchanged section. Rejected on the model contract.
-
-**Enable in-history mode by plugin config instead of a model capability.** A deployment flag could pair a non-capable model with appended system messages, which such a model would read as ordinary history at best. The capability belongs to the route that honours it; the adapter catalog already carries per-model capacities. Rejected.
-
-**Always append, never re-baseline.** One rule, but node 0 would stay stale for the life of the session and every request after compaction would carry the stale head plus the replacement. Re-baselining at a series start costs nothing extra because the cache is already lost there. Rejected.
-
-**Re-baseline on every resume.** Accepts one cache miss per process restart for a simpler resume path. The cache persists across restarts for hours to days, and the log already carries what resume needs. Rejected.
-
-**Place the system message after the step's user messages.** Both positions sit after the cached prefix, but the model then reads the instructions after the input it must apply them to; system-before-user matches the leading position's ordering. Rejected.
-
-## Acceptance criteria
-
-- `DeepSeekCatalogModel.systemPromptUpdate` is validated at load, exposed through `LlmResolvedModelInfo`, and returned by `prepareCall()`; a misspelt value fails at load.
-- On a capable route a mid-series prompt change appends `system/message` before the step's `user/message` events and node 0 is unchanged; on a non-capable route the same change replaces node 0.
-- On a capable route a series start with no surviving mid-history system node replaces node 0; with a surviving one it appends.
-- A resumed loop instance whose rendered prompt differs appends on a capable route.
-- The recorded snapshot and both SDK expected outputs pin the appended event; the e2e asserts the cache-hit inequality when a capable route is configured and skips otherwise.
-- The `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-system-prompt` READMEs document the capability, the decision rule, and the KV Cache effect; `docs/config-catalog.md` lists the field.
-
-## Risks
-
-- The model contract is unpublished; the note records it as provided. If the released model narrows it (for example, honouring only the latest system message within a bounded window), the decision rule needs a re-baseline trigger beyond series starts.
-- Retained prompt versions accumulate in history until compaction shadows them. Each version costs its tokens on every request in the series; a deployment whose prompt changes on most steps would be better served by moving that fact into runtime context.
-- A proxy that rewrites or reorders system messages breaks the replacement semantics silently; the e2e's cache-hit assertion is the detector.

+ 0 - 74
.agents/notes/proposed/feature/2026-09-02-in-history-system-prompt-replacement.zh.md

@@ -1,74 +0,0 @@
-# Agent Note: 历史内系统提示词替换,实现缓存稳定的提示词变更
-
-Status: proposed
-
-[English](2026-09-02-in-history-system-prompt-replacement.md) | 中文
-
-## Problem
-
-每一次系统提示词变更都要付出整个提供方前缀缓存的代价。循环在每个步骤渲染提示词;一旦字节不同——plan 模式片段进入或退出、某个 skill 或工具指引片段完成注册、agent 作用域的 persona 遮蔽、`{{model}}` 变量改变——请求的消息 0 随之改变,DeepSeek 上下文缓存从第一个 token 起失效。长时间的 agent 会话反复为此付费,而[运行时上下文快照设计](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)之所以存在,正是因为把会变化的事实移出提示词是保持前缀稳定的唯一办法。
-
-一个 DeepSeek 模型——作为本提案所依据的未公开模型事实——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀,因此工具集变更仍会使缓存失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。
-
-只有在[系统提示词成为 surface 第 0 号节点](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md)之后,harness 才拥有实现这一点的表示:提示词变更随之成为对 `system/message` surface 节点的操作,而「替换第 0 号节点」与「追加新节点」之间的选择是逐模型的决定。
-
-## Proposal
-
-对于声明了该能力的模型路由,当渲染后的提示词变化且前缀本可存活时,循环追加一个新的 `system/message` surface 节点而不是替换第 0 号节点。[surface 节点设计](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md)中的其他一切不变:事件类型、投影的拥有者、序列化器和呈现。
-
-### 能力
-
-DeepSeek 适配器的目录模型新增一个经校验的可选字段 `systemPromptUpdate`,唯一接受的值是 `'in-history'`;缺省表示该模型需要重写消息 0。适配器把它暴露在 `LlmResolvedModelInfo` 上,`prepareCall()` 在 `context.contextWindow` 旁边返回它,因此循环从它已经消费的同一份注册绑定元数据中读取。在该模型发布之前,没有默认目录条目声明它;部署方通过 `cordis.yml` 的 `models` 列表启用。没有该字段的模型——包括当前所有默认条目和所有 `dsh-llm-pi-ai` 路由——完全保持替换第 0 号节点的行为。
-
-### 决策规则
-
-`SystemPromptProjection` 跟踪**有效提示词**:surface 上最新存活的 `system/message` 的文本(不存在更后的系统节点时即第 0 号节点)。当渲染后的提示词与有效提示词不同时:
-
-| 路由能力 | 前缀状态 | 操作 |
-|---|---|---|
-| 无 | 任意 | 替换第 0 号节点 |
-| `in-history` | 当前请求序列延续(上次请求以来没有压缩,tools 或 config 没有变更) | 在该步骤的 `user/message` 事件之前追加新的 `system/message` |
-| `in-history` | 新序列开始(压缩替换了 surface,或 `request/header` 记录了 tools 或 config 的 `change`)且没有历史中途的系统节点存活 | 用当前提示词替换第 0 号节点 |
-| `in-history` | 新序列开始但有历史中途的系统节点存活 | 追加新的 `system/message`;第 0 号节点保持原样 |
-
-第三行存在,是因为序列开始已经付出了缓存代价;把提示词折回第 0 号节点能让历史保持简短。第四行存在,是因为 surface 没有删除操作:在更后的系统节点仍存活时替换第 0 号节点,会让模型把更后、已过时的节点当作权威,所以循环改为追加。历史内模式在任何更后的系统节点存活期间永不重写第 0 号节点。
-
-恢复遵循会话中途的规则。新的循环实例从日志恢复有效提示词,当新渲染的提示词不同时执行追加——提供方缓存在进程边界之后可能仍是热的,且 `resume` header 不是序列开始。
-
-### 呈现与记账
-
-历史中途的 `system/message` 使用与第 0 号节点相同的折叠请求提示词检视卡片,在请求中的对应位置标注为提示词更新;它永不作为聊天气泡,转录投影跳过它,SDK 投影把它暴露为带类型的事件。`dsh-token-meter` 像对待其他任何 surface 节点一样为它计价,因此逐步骤的上下文明细会显示被保留的各个提示词版本累计的开销,直到压缩遮蔽它们。随后 `assistant/message` 用量上的 `cacheReadTokens` 是可观察的效果:对具备能力的路由,该值覆盖到最后一条已缓存消息为止的前缀;对不具备能力的路由,它回落到公共前缀检测的下限。
-
-### 验证计划
-
-- `dsh-agent-loop` 中针对投影的单元测试:序列中途变更时追加、没有存活的历史中途节点时在序列开始处替换、有存活节点时在序列开始处追加、恢复时追加、未变更时无操作,以及不具备能力的路由只做替换。
-- `dsh-llm-deepseek` 中针对目录校验(`systemPromptUpdate` 只接受 `'in-history'`)和 `prepareCall()` 暴露该字段的单元测试。
-- `snapshots/` 下的一个无密钥录制快照,其组合在 mock 路由上声明该能力并在会话中途切换 plan 模式,钉住追加的 `system/message` 与未被触及的第 0 号节点;TypeScript 与 Python SDK 的期望输出包含追加的事件。
-- 一个真实 API 的 e2e:针对具备能力的路由运行两个步骤并夹带一次提示词变更,断言第二个请求的 `cacheReadTokens` 不小于第一个请求的提示词 token 数。它通过标准的凭据与 base-URL 机制解析路由,未配置具备能力的路由时自动跳过。
-
-## Alternatives considered
-
-**只发送变化的片段作为增量。** 模型把最新的系统消息当作完整提示词,因此增量会静默丢掉每个未变化的片段。基于模型约定被否决。
-
-**用插件配置而不是模型能力启用历史内模式。** 部署标志可能把不具备能力的模型与追加的系统消息配对,这样的模型最多把它们当作普通历史。该能力属于兑现它的路由;适配器目录已经承载逐模型的容量信息。被否决。
-
-**永远追加,从不重新基线化。** 规则单一,但第 0 号节点会在会话整个生命周期内保持过时,压缩之后的每个请求都要携带过时的头部加替换消息。在序列开始处重新基线化不花额外代价,因为缓存在那里已经丢失。被否决。
-
-**每次恢复都重新基线化。** 为更简单的恢复路径接受每次进程重启一次缓存未命中。缓存跨重启持续数小时到数天,而日志已经承载恢复所需的一切。被否决。
-
-**把系统消息放在该步骤的用户消息之后。** 两个位置都在已缓存前缀之后,但模型会在读到必须应用指令的输入之后才读到指令;system 在 user 之前与最前位置的顺序一致。被否决。
-
-## Acceptance criteria
-
-- `DeepSeekCatalogModel.systemPromptUpdate` 在加载时校验、通过 `LlmResolvedModelInfo` 暴露、由 `prepareCall()` 返回;拼错的值在加载时失败。
-- 在具备能力的路由上,序列中途的提示词变更在该步骤的 `user/message` 事件之前追加 `system/message`,第 0 号节点不变;在不具备能力的路由上,同样的变更替换第 0 号节点。
-- 在具备能力的路由上,没有存活的历史中途系统节点的序列开始替换第 0 号节点;有存活节点时追加。
-- 渲染后的提示词不同的已恢复循环实例在具备能力的路由上追加。
-- 录制快照与两个 SDK 的期望输出钉住追加的事件;配置了具备能力的路由时 e2e 断言缓存命中不等式,否则跳过。
-- `dsh-llm-deepseek`、`dsh-agent-loop`、`dsh-system-prompt` 的 README 记录该能力、决策规则和 KV Cache 效果;`docs/config-catalog.md` 列出该字段。
-
-## Risks
-
-- 模型约定尚未公开;本 Agent Note 按所提供的内容记录。若发布的模型收窄了约定(例如只在有界窗口内兑现最新的系统消息),决策规则需要序列开始之外的重新基线化触发条件。
-- 被保留的提示词版本在历史中累积,直到压缩遮蔽它们。每个版本在该序列的每个请求上都要付出其 token 开销;提示词在多数步骤都变化的部署,更适合把那个事实移入运行时上下文。
-- 重写或重排系统消息的代理会静默破坏替换语义;e2e 的缓存命中断言是探测器。

+ 8 - 7
apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl

@@ -1,4 +1,4 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"permission/preset","data":{"preset":"danger-full-access"}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
 {"type":"approval/policy","data":{"policy":"never"}}
@@ -6,20 +6,21 @@
 {"type":"turn/start","data":{"turn":1}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[7],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[8],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"max_goal_rounds\":2}"}}
 {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}
-{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-round-driver snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"step/start","data":{"turn":1,"step":3}}
 {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -30,7 +31,7 @@
 {"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":"<goal_round>\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</goal_round>"}],"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":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}}
 {"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},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL ROUND ONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL ROUND ONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -39,7 +40,7 @@
 {"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":"<goal_round>\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</goal_round>"}],"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":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}}
 {"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,"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["partial"]}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":3,"step":1}}
 {"type":"turn/end","data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}

+ 7 - 6
apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl

@@ -1,4 +1,4 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"permission/preset","data":{"preset":"danger-full-access"}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
 {"type":"approval/policy","data":{"policy":"never"}}
@@ -6,15 +6,16 @@
 {"type":"turn/start","data":{"turn":1}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[7],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[8],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}
 {"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -25,11 +26,11 @@
 {"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":"<goal_round>\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</goal_round>"}],"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":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}}
 {"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},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_complete","name":"update_goal","args":["{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"]},{"type":"chunk","time":0,"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":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"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":[28],"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":[29],"surfaceOp":"append"}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<goal_complete>\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</goal_complete>"}],"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":[]}}

+ 26 - 25
apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl

@@ -2,30 +2,31 @@
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[7],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_probe","name":"update_goal","args":["{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":15,"time":0,"data":{"title":"Goal tool snapshot","messageSeqs":[7],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":21,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":26,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":4}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":4}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_probe","name":"update_goal","args":["{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Goal tool snapshot","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":22,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":27,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":4}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":32,"time":0,"data":{"turn":1,"step":4}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":33,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
 {"type":"result","sessionId":"{{sessionId}}","output":"GOAL READY","usage":{"inputTokens":100,"outputTokens":20}}

+ 7 - 6
apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl

@@ -1,4 +1,4 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"permission/preset","data":{"preset":"danger-full-access"}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
 {"type":"approval/policy","data":{"policy":"never"}}
@@ -6,18 +6,19 @@
 {"type":"turn/start","data":{"turn":1}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}}
-{"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}
+{"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[8],"source":{"kind":"fallback"}}}
+{"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Inspecting the task before the tool call."},{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Inspecting the task before the tool call."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Inspecting the task before the tool call."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"cli-smoke-call","name":"bash","args":["{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
-{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"tools":"{{tools}}"},"reason":"change"}}
 {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 9 - 8
apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl

@@ -2,13 +2,14 @@
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"say pong","messageSeqs":[7],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":13,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}
 {"type":"result","sessionId":"{{sessionId}}","output":""}

+ 9 - 8
apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl

@@ -2,13 +2,14 @@
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"say pong","messageSeqs":[7],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":13,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"say pong\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), or export DEEPSEEK_API_KEY in the launching environment","code":"MISSING_CREDENTIAL"}}}}}
 {"type":"result","sessionId":"{{sessionId}}","output":""}

+ 12 - 11
apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl

@@ -2,16 +2,17 @@
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[7],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"retry the transient provider failure\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":13,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":14,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":15,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RETRY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"retry the transient provider failure\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/attempt","seq":14,"time":0,"data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":15,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":16,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RETRY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":19,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
 {"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}}

+ 7 - 5
apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl

@@ -1,10 +1,11 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"turn/start","data":{"turn":1}}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"assistant/message","data":{"turn":1,"step":1,"stream":[],"message":{"role":"assistant","content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-6","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[5]}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"interrupted"}}}
 {"type":"session/end-seed","data":{}}
@@ -15,11 +16,12 @@
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","start":2,"end":2}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["I will verify the external state before deciding whether to retry the side-effecting operation."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}

+ 8 - 4
apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl

@@ -1,6 +1,9 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"turn/start","data":{"turn":1}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"session/end-seed","data":{}}
 {"type":"permission/preset","data":{"preset":"workspace-write"}}
@@ -10,14 +13,15 @@
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","start":2,"end":2}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}}
-{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[17],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"step/start","data":{"turn":2,"step":2}}
 {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The stored subagent is unreadable. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 2 - 1
apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl


+ 13 - 0
apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.replay.v3.jsonl

@@ -0,0 +1,13 @@
+{"type":"session","version":3,"id":"subagent-inheritance-child","createdAt":2,"isSeeded":false,"delegationDepth":1}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"child-system-head"}},"surfaceOp":"append"}
+{"type":"user/message","data":{"role":"user","content":[{"type":"text","text":"delegated task"}],"source":{"kind":"user"},"id":"child-task"},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"child-write-message"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"child-write","name":"write","args":["{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
+{"type":"tool/call","data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"child-write-result"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[5],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"step/start","data":{"turn":1,"step":2}}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"child-denied-message"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CHILD_DENIED [sandbox: file access denied under read-only mode]"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":2}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 8 - 4
apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl

@@ -1,8 +1,11 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"turn/start","data":{"turn":1}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"sandbox/mode","data":{"mode":"read-only"}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"}},"reason":"initial"}}
+{"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"session/end-seed","data":{}}
 {"type":"permission/preset","data":{"preset":"read-only"}}
@@ -11,14 +14,15 @@
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","start":2,"end":2}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"resume"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
-{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"step/start","data":{"turn":2,"step":2}}
 {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}

+ 4 - 3
apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl

@@ -1,4 +1,4 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
 {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
 {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}}
 {"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
@@ -6,11 +6,12 @@
 {"type":"turn/start","data":{"turn":1}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[7],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 21 - 20
apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl

@@ -2,25 +2,26 @@
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Start one continuable background subagen","messageSeqs":[7],"source":{"kind":"fallback"}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
 {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":15,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[7],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":18,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":23,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":24,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":25,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":27,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable background subagen","messageSeqs":[8],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title-llm-request","seq":13,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":19,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":20,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":23,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":25,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
 {"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}}

+ 7 - 3
apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl

@@ -1,7 +1,10 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"turn/start","data":{"turn":1}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"session/end-seed","data":{}}
 {"type":"permission/preset","data":{"preset":"workspace-write"}}
@@ -11,12 +14,13 @@
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","start":2,"end":2}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}

+ 7 - 3
apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl

@@ -1,7 +1,10 @@
-{"type":"session","version":2,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
+{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
 {"type":"turn/start","data":{"turn":1}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"session/end-seed","data":{}}
 {"type":"permission/preset","data":{"preset":"workspace-write"}}
@@ -11,12 +14,13 @@
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
 {"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","start":2,"end":2}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}}
-{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}}
 {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}

+ 4 - 5
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -8,10 +8,9 @@ import {
   normalizeSessionSnapshot,
   normalizeSessionSnapshots,
   normalizeStdout,
-  scrubRequestHeaders,
+  scrubModelRequestBulk,
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
-import { prepareSessionEventNotificationsForComparison } from '@deepseek-ai/dsh-llm-replay'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 import {
   decompressZstdFrame,
@@ -75,9 +74,9 @@ async function expectSessionSnapshot(
   expect(parseJsonl(normalizedActual ?? '')).toEqual(parseJsonl(normalizedExpected ?? ''))
 }
 
-/** Compare current headless session-event wrappers with a committed v1 stream. */
+/** Compare the complete current-writer headless notification sequence. */
 async function expectHeadlessStream(normalized: string, expectedPath: string): Promise<void> {
-  const expected = prepareSessionEventNotificationsForComparison(await readFile(expectedPath, 'utf8'))
+  const expected = await readFile(expectedPath, 'utf8')
   expect(parseJsonl(normalized)).toEqual(parseJsonl(expected))
 }
 
@@ -150,7 +149,7 @@ function normalizeHeadlessStream(rawStdout: string, cwd: string): string {
     }
     return record.event as JsonObject
   })
-  const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog(
+  const normalizedEvents = parseJsonl(scrubModelRequestBulk(normalizeSessionLog(
     `${events.map(event => JSON.stringify(event)).join('\n')}\n`,
     context,
   )))

+ 12 - 7
apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts

@@ -9,7 +9,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
-import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm'
+import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm'
 import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts'
@@ -49,14 +49,19 @@ async function seedInterruptedSession(root: string, cwd: string): Promise<string
   }
   const events: SessionEvent[] = [
     { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } },
-    { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({
+    { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
+    {
+      type: 'system/message', seq: SessionSeq(2), time: 12,
+      data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+      surfaceOp: 'append',
+    },
+    { type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({
       content: [{ type: 'text', text: 'Perform one side-effecting remote mutation.' }], source: { kind: 'user' },
     }), surfaceOp: 'append' },
-    { type: 'step/start', seq: SessionSeq(2), time: 12, data: { turn: 1, step: 1 } },
     {
       type: 'assistant/message',
-      seq: SessionSeq(3),
-      time: 13,
+      seq: SessionSeq(4),
+      time: 14,
       data: {
         turn: 1,
         step: 1,
@@ -74,8 +79,8 @@ async function seedInterruptedSession(root: string, cwd: string): Promise<string
     },
     {
       type: 'tool/call',
-      seq: SessionSeq(4),
-      time: 14,
+      seq: SessionSeq(5),
+      time: 15,
       data: {
         turn: 1,
         step: 1,

+ 11 - 4
apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts

@@ -15,7 +15,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import { describe, expect, it } from 'vitest'
@@ -31,7 +31,7 @@ const childId = SessionId('subagent-diagnostic-child')
 const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
 const task = 'Call list_agents once and report what it shows.'
 
-/** Compare one current normalized Session with its generation-aware committed fixture. */
+/** Compare current normalized Session records without historical migration. */
 async function expectSession(actual: string, expectedPath: string): Promise<void> {
   const expected = await readFile(expectedPath, 'utf8')
   const parse = (content: string): Record<string, unknown>[] => content.split('\n')
@@ -59,8 +59,15 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise<void>
   }
   const parentEvents: SessionEvent[] = [
     { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } },
-    { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
-    { type: 'turn/end', seq: SessionSeq(2), time: 12, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
+    {
+      type: 'system/message', seq: SessionSeq(2), time: 12,
+      data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+      surfaceOp: 'append',
+    },
+    { type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
+    { type: 'step/end', seq: SessionSeq(4), time: 14, data: { turn: 1, step: 1 } },
+    { type: 'turn/end', seq: SessionSeq(5), time: 15, data: { turn: 1, reason: { kind: 'completed' } } },
   ]
   const childMeta: SessionHeader = {
     version: SESSION_FORMAT_VERSION,

+ 16 - 8
apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts

@@ -14,14 +14,15 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
-import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import { createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import { describe, expect, it } from 'vitest'
 
 const fixtureDir = fileURLToPath(new URL('./expected/subagent-inheritance', import.meta.url))
 const replayOverride = join(fixtureDir, 'replay.override.json')
-const childReplay = join(fixtureDir, 'child.replay.jsonl')
+// Native replay preserves the V0 script's model outputs without migrating its pre-step user event.
+const childReplay = join(fixtureDir, 'child.replay.v3.jsonl')
 const parentExpected = join(fixtureDir, 'parent.expected.jsonl')
 const childExpected = join(fixtureDir, 'child.expected.jsonl')
 const configPath = fileURLToPath(new URL('../subagent-inheritance-snapshot.patch.yml', import.meta.url))
@@ -31,7 +32,7 @@ const sessionId = SessionId('subagent-inheritance-parent')
 const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
 const task = 'Delegate the write probe to a subagent.'
 
-/** Compare one current normalized Session with its generation-aware committed fixture. */
+/** Compare current normalized Session records without historical migration. */
 async function expectSession(actual: string, expectedPath: string): Promise<void> {
   const expected = await readFile(expectedPath, 'utf8')
   const parse = (content: string): Record<string, unknown>[] => content.split('\n')
@@ -55,12 +56,18 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
   }
   const events: SessionEvent[] = [
     { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } },
-    { type: 'user/message', seq: SessionSeq(1), time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
-    { type: 'sandbox/mode', seq: SessionSeq(2), time: 12, data: { mode: 'read-only' } },
+    { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
+    {
+      type: 'system/message', seq: SessionSeq(2), time: 12,
+      data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+      surfaceOp: 'append',
+    },
+    { type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
+    { type: 'sandbox/mode', seq: SessionSeq(4), time: 14, data: { mode: 'read-only' } },
     {
       type: 'request/header',
-      seq: SessionSeq(3),
-      time: 13,
+      seq: SessionSeq(5),
+      time: 15,
       data: {
         header: {
           config: {
@@ -72,7 +79,8 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
         reason: 'initial',
       },
     },
-    { type: 'turn/end', seq: SessionSeq(4), time: 14, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'step/end', seq: SessionSeq(6), time: 16, data: { turn: 1, step: 1 } },
+    { type: 'turn/end', seq: SessionSeq(7), time: 17, data: { turn: 1, reason: { kind: 'completed' } } },
   ]
   try {
     const handle = await ctx.sessionPersistence.create(meta)

+ 13 - 6
apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts

@@ -15,7 +15,7 @@ import {
   type NormalizeContext,
 } from '@deepseek-ai/dsh-session-snapshot'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import {
   SESSION_FORMAT_VERSION,
   SessionId,
@@ -87,17 +87,23 @@ async function seedVisibleBaseline(
   })
   const events: SessionEvent[] = [
     { type: 'turn/start', seq: SessionSeq(0), time: 10, data: { turn: 1 } },
+    { type: 'step/start', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1 } },
+    {
+      type: 'system/message', seq: SessionSeq(2), time: 12,
+      data: { turn: 1, step: 1, message: createMessage({ role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } }) },
+      surfaceOp: 'append',
+    },
     {
       type: 'user/message',
-      seq: SessionSeq(1),
-      time: 11,
+      seq: SessionSeq(3),
+      time: 13,
       data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }),
       surfaceOp: 'append',
     },
     {
       type: 'user/message',
-      seq: SessionSeq(2),
-      time: 12,
+      seq: SessionSeq(4),
+      time: 14,
       data: createUserMessage({
         content: [{ type: 'text', text: baseline.text }],
         source: {
@@ -115,7 +121,8 @@ async function seedVisibleBaseline(
       }),
       surfaceOp: 'append',
     },
-    { type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'step/end', seq: SessionSeq(5), time: 15, data: { turn: 1, step: 1 } },
+    { type: 'turn/end', seq: SessionSeq(6), time: 16, data: { turn: 1, reason: { kind: 'completed' } } },
   ]
   try {
     const handle = await ctx.sessionPersistence.create(meta)

+ 19 - 7
apps/web/tests/agent-preset-selection.e2e.ts

@@ -19,6 +19,7 @@ import {
   SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId,
 } from '@deepseek-ai/dsh-session'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import {
   captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
   webSnapshotMode, type WebScaffold,
@@ -81,7 +82,8 @@ async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
 /**
  * A settled one-turn session with no model content: this lane asserts chrome
  * around a conversation, not a conversation, and a recorded turn would tie
- * the golden to a provider's wording for no gain.
+ * the golden to a provider's wording for no gain. Its empty system head
+ * belongs to the first step, before the user message.
  * @returns a tokenized session log ending on a closed turn.
  */
 function seedLog(): string {
@@ -89,9 +91,18 @@ function seedLog(): string {
   const at = (index: number, event: Record<string, unknown>): string =>
     JSON.stringify({ ...event, seq: index, time: time + index })
   return [
-    JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }),
+    JSON.stringify({
+      type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
+      createdAt: time, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0,
+    }),
     at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }),
-    at(1, {
+    at(1, { type: 'step/start', data: { turn: 1, step: 1 } }),
+    at(2, {
+      type: 'system/message',
+      data: { turn: 1, step: 1, message: createSystemMessage('', '@deepseek-ai/dsh-system-prompt') },
+      surfaceOp: 'append',
+    }),
+    at(3, {
       type: 'user/message',
       data: {
         id: '00000000-0000-4000-9000-000000000001',
@@ -101,8 +112,9 @@ function seedLog(): string {
       },
       surfaceOp: 'append',
     }),
-    at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }),
-    at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
+    at(4, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [3], source: { kind: 'fallback' } } }),
+    at(5, { type: 'step/end', data: { turn: 1, step: 1 } }),
+    at(6, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
   ].join('\n')
 }
 
@@ -138,10 +150,10 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
       type: 'user/message',
       seq: 1,
       time: createdAt + 1,
-      data: {
+      data: createUserMessage({
         content: [{ type: 'text', text: 'Check the session-header action order.' }],
         source: { kind: 'user' },
-      },
+      }),
       surfaceOp: 'append',
     },
     {

+ 2 - 2
apps/web/tests/approval-composer.e2e.ts

@@ -17,7 +17,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/approval-composer', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 // The golden covers the stable waiting panel; direct assertions cover its answer.
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const MODE = webSnapshotMode()
@@ -142,6 +142,6 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
   }, 300_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md', 'workspace.expected'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md', 'workspace.expected'])
   })
 })

+ 1 - 1
apps/web/tests/background-job-list.e2e.ts

@@ -16,7 +16,7 @@ import {
 } from './scaffold.ts'
 import { newEnglishPage, saveFailureShot } from './support.ts'
 
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/background-job-list', import.meta.url))
 const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')

+ 1 - 1
apps/web/tests/bash-abort-row.e2e.ts

@@ -13,7 +13,7 @@ import {
 } from './scaffold.ts'
 import { newEnglishPage, saveFailureShot } from './support.ts'
 
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/acp/cancel-tool-calls/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/acp/cancel-tool-calls/session.v3.jsonl', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/bash-abort-row', import.meta.url))
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const MODE = webSnapshotMode()

+ 24 - 0
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -16,6 +16,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
 import {
   launchWebScaffold,
+  parseSeedFixture,
   seedSession,
   watchConsole,
   webSnapshotMode,
@@ -471,6 +472,29 @@ function assertClean(world: ScrollWorld): void {
   expect(world.tripwire.warnings).toEqual([])
 }
 
+it('generates a native V3 scroll seed with a protected system head and intact references', () => {
+  const { header, events } = parseSeedFixture(HISTORY_FIXTURE.log)
+  expect(header.version).toBe(3)
+  expect(events.slice(0, 5).map(event => event.type)).toEqual([
+    'turn/start', 'step/start', 'system/message', 'user/message', 'session/title',
+  ])
+  expect(events.filter(event => event.type === 'system/message')).toHaveLength(1)
+  const firstUser = events.find(event => event.type === 'user/message')!
+  const title = events.find(event => event.type === 'session/title')!
+  expect(title.data.messageSeqs).toEqual([firstUser.seq])
+  const calls = events.filter(event => event.type === 'tool/call')
+  const results = events.filter(event => event.type === 'tool/result')
+  expect(calls).toHaveLength(22)
+  expect(results).toHaveLength(calls.length)
+  for (const result of results) {
+    const call = calls.find(event => event.data.callId === result.data.message.source.callId)!
+    expect(result.sourceEventSeqs).toEqual([call.seq])
+    expect(call.seq).toBeLessThan(result.seq)
+  }
+  expect(events.filter(event => event.type === 'turn/end')).toHaveLength(HISTORY_FIXTURE.turns)
+  expect(events.at(-1)?.type).toBe('turn/end')
+})
+
 let browser: Browser
 
 describe('web e2e: long Chat scroll contract', () => {

+ 15 - 2
apps/web/tests/chat-scroll-fixture.ts

@@ -5,6 +5,7 @@
 import {
   ToolCallId,
   createAssistantMessage,
+  createSystemMessage,
   createToolResultMessage,
   createUserMessage,
 } from '@deepseek-ai/dsh-llm'
@@ -64,11 +65,21 @@ function markerHelpers(prefix: string): ChatScrollMarkers {
   }
 }
 
+function appendSystemPrompt(session: Session, turn: number, step: number): void {
+  session.append('system/message', {
+    turn,
+    step,
+    message: createSystemMessage(
+      'Synthetic chat-scroll system prompt.',
+      '@deepseek-ai/dsh-system-prompt',
+    ),
+  }, { surfaceOp: 'append' })
+}
+
 function appendRequestHeader(session: Session, turn: number, step: number): void {
   session.append('request/header', {
     header: {
       config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
-      system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
     },
     reason: turn === 1 && step === 1 ? 'initial' : 'change',
   })
@@ -188,6 +199,9 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat
     session.append('turn/start', {
       turn,
     })
+    session.append('step/start', { turn, step: 1 })
+    // Native V3 installs the protected system head before any user surface.
+    if (turn === 1) appendSystemPrompt(session, turn, 1)
     const user = session.append('user/message', createUserMessage({
       content: text(
         `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
@@ -203,7 +217,6 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat
       })
     }
 
-    session.append('step/start', { turn, step: 1 })
     appendRequestHeader(session, turn, 1)
     if (turn % TOOL_INTERVAL === 0) {
       appendToolStep(session, markers, turn)

+ 14 - 1
apps/web/tests/complex-history.perf.ts

@@ -13,6 +13,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import {
   ToolCallId,
   createAssistantMessage,
+  createSystemMessage,
   createToolResultMessage,
   createUserMessage,
   expandAssistantStream,
@@ -196,11 +197,21 @@ function appendTitle(session: Session, title: string, messageSeq: SessionSeq): v
   })
 }
 
+function appendSystemPrompt(session: Session, turn: number, step: number): void {
+  session.append('system/message', {
+    turn,
+    step,
+    message: createSystemMessage(
+      'Synthetic performance system prompt.',
+      '@deepseek-ai/dsh-system-prompt',
+    ),
+  }, { surfaceOp: 'append' })
+}
+
 function appendRequestHeader(session: Session, turn: number, step: number): void {
   session.append('request/header', {
     header: {
       config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
-      system: `Synthetic performance system prompt for turn ${String(turn)}, step ${String(step)}.`,
     },
     reason: turn === 1 && step === 1 ? 'initial' : 'change',
   })
@@ -334,6 +345,7 @@ function smallSidebarFixture(): string {
   }), { surfaceOp: 'append' })
   appendTitle(session, 'Synthetic sidebar session', user.seq)
   session.append('step/start', { turn: 1, step: 1 })
+  appendSystemPrompt(session, 1, 1)
   appendRequestHeader(session, 1, 1)
   appendToolStep(session, 1, 1, 2)
   session.append('step/end', { turn: 1, step: 1 })
@@ -360,6 +372,7 @@ function longHistoryFixture(): string {
     if (turn === 1) appendTitle(session, LONG_SESSION_TITLE, user.seq)
 
     session.append('step/start', { turn, step: 1 })
+    if (turn === 1) appendSystemPrompt(session, turn, 1)
     appendRequestHeader(session, turn, 1)
     if (turn % TOOL_TURN_INTERVAL === 0) {
       appendToolStep(session, turn, 1, TOOLS_PER_TOOL_TURN)

+ 1 - 1
apps/web/tests/cordis-tool-round.e2e.ts

@@ -20,7 +20,7 @@ import {
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const CORDIS_TOOLS = ['cordis_inspect_self', 'cordis_define', 'cordis_run', 'cordis_stop'] as const

+ 2 - 2
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -16,8 +16,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/details-session-lifecycle', import.meta.url))
 const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v2.jsonl', import.meta.url))
-const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
+const SEED_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
 const MODE = webSnapshotMode()
 

+ 2 - 2
apps/web/tests/feedback-command.e2e.ts

@@ -18,7 +18,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
 const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
 const MODE = webSnapshotMode()
@@ -96,7 +96,7 @@ describe('web e2e: /feedback command acknowledgement', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'ack.expected.md', 'ack-expanded.expected.md',
+      'session.v3.jsonl', 'ack.expected.md', 'ack-expanded.expected.md',
     ])
   })
 })

+ 2 - 2
apps/web/tests/feedback-release.e2e.ts

@@ -19,7 +19,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url))
 // Both routes borrow the same settled turn; this manifest references its owner.
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.v3.jsonl', import.meta.url))
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
 const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
 const RELEASE_EXPECTED = join(SNAPSHOT_DIR, 'feedback-release.expected.json')
@@ -110,7 +110,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
           { id: 'feedback-mock', name: 'Feedback mock', contextWindow: 128_000 },
         ] },
       ],
-      // The replayed session.v2.jsonl belongs to the feedback-command scenario;
+      // The replayed session.v3.jsonl belongs to the feedback-command scenario;
       // comparing (or refreshing) the persisted session here would rewrite
       // that shared source with this lane's feedback events. Persistence and
       // collector assertions belong to this lane.

+ 3 - 3
apps/web/tests/file-upload-round.e2e.ts

@@ -6,7 +6,7 @@
 // content-addressed store makes the saved path identical across record and
 // replay once the workspace cwd is tokenized, so the recorded read arguments
 // replay verbatim against a freshly re-uploaded object.
-// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless
+// Record: DSH_SNAPSHOT=record rewrites session.v3.jsonl, then a keyless
 // DSH_SNAPSHOT=refresh regenerates ui.expected.md.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
@@ -21,7 +21,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url))
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
 const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
 const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
@@ -277,7 +277,7 @@ describe('web e2e: generic file upload through the real assembly', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
+      'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
     ])
   })
 })

+ 2 - 2
apps/web/tests/goal-multi-turn-actions.e2e.ts

@@ -17,7 +17,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-turn-actions', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
@@ -178,7 +178,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
 
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'replay.override.json', 'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+      'replay.override.json', 'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })
 })

+ 2 - 2
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -27,7 +27,7 @@ import {
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
 const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
@@ -413,7 +413,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'replay.override.json', 'command-menu.expected.md',
+      'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md',
       'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md',
       'reloaded.expected.md', 'reloaded-expanded.expected.md',
     ])

+ 2 - 2
apps/web/tests/live-interactions.e2e.ts

@@ -30,7 +30,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/live-interactions', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 // One golden pins the empty mid-turn loading state, one pins the sendable draft
 // state, and the other four capture what remains after cancel, after a
 // non-retryable failure, after retry recovery, and after retry exhaustion.
@@ -325,7 +325,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
+      'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
       'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
       'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md',
     ])

+ 2 - 2
apps/web/tests/message-actions.e2e.ts

@@ -19,7 +19,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
 // Borrowed read-only: this scenario needs any settled user+assistant pair, not
 // a new recording (workspace-management / sidebar-scrollbar pattern).
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md')
 const MODE = webSnapshotMode()
@@ -184,7 +184,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await expect.poll(
       () => page.getByRole('button', { name: 'System prompt', exact: true }).count(),
       { timeout: 10_000 },
-    ).toBe(1)
+    ).toBe(2)
 
     // Focus-reveal the footers (hover:hover keeps them opacity-hidden until
     // hover/focus-within). Branch renders only under assistant answers — user

+ 1 - 1
apps/web/tests/message-feedback-layout.e2e.ts

@@ -41,7 +41,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedb
 const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
 const MODE = webSnapshotMode()
 /** Borrowed read-only: this scenario needs any settled assistant message to rate. */
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const SEED_ID = 'message-feedback-layout-e2e'
 /** Viewport widths from full-screen desktop down to a narrow window. */
 const WIDTHS = [1680, 1280, 1024, 900, 700, 600]

+ 2 - 2
apps/web/tests/message-feedback-protocol.snapshot.ts

@@ -12,7 +12,7 @@ import {
 } from './scaffold.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol', import.meta.url))
-const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
 const SESSION_ID = 'message-feedback-protocol'
 const MESSAGE_ID = fixtureIdentity('message', 2)
@@ -112,6 +112,6 @@ describe('message feedback Host Remote protocol', () => {
 
     expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
     await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
-    await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.v2.jsonl'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.v3.jsonl'])
   })
 })

+ 1 - 1
apps/web/tests/message-feedback.e2e.ts

@@ -15,7 +15,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 
 // Borrowed read-only: this scenario needs any settled assistant message to
 // address, not a new recording (message-actions / sidebar-scrollbar pattern).
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'message-feedback-web-e2e'
 const NOTE = 'Read both files before answering.'

+ 12 - 3
apps/web/tests/minimal-preset.snapshot.ts

@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { AgentHandle } from '@deepseek-ai/dsh-agent'
 import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import {
@@ -21,11 +22,17 @@ import {
 import { newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/minimal-preset', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const MODE = webSnapshotMode()
 const PROMPT = "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop."
 
+/** Rendered text of the system prompt surface node, or undefined when the surface carries none. */
+function systemPromptText(session: Session): string | undefined {
+  const message = session.deriveMessages().find(candidate => candidate.role === 'system')
+  return message?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
+}
+
 describe('minimal agent preset', () => {
   let scaffold: WebScaffold
   let agentHandle: AgentHandle
@@ -72,6 +79,8 @@ describe('minimal agent preset', () => {
   it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
     const requestHeader = agentHandle.agent.session.requestHeader()
     if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
+    const systemPrompt = systemPromptText(agentHandle.agent.session)
+    if (systemPrompt === undefined) throw new Error('the minimal agent issued no system prompt')
     expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message'
       && event.data.source.kind === 'plugin'
       && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
@@ -115,7 +124,7 @@ describe('minimal agent preset', () => {
       .trimEnd()
 
     expect({
-      prompt: requestHeader.system,
+      prompt: systemPrompt,
       tools: requestHeader.tools?.map(tool => tool.name),
       goalCommand: scaffold.ctx.commands.find(agentHandle.agent, 'goal') !== undefined,
       bash: text(bash),
@@ -180,7 +189,7 @@ describe('minimal agent preset', () => {
 
   it('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl',
+      'session.v3.jsonl',
       'system-prompt.expected.md',
       'tool-schemas.expected.json',
       'ui.expected.md',

+ 2 - 2
apps/web/tests/navigation-panes.e2e.ts

@@ -22,7 +22,7 @@ import {
 import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
-const SEED = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
@@ -499,7 +499,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
+      'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
       'terminal-card.expected.md',
     ])
   })

+ 6 - 6
apps/web/tests/permission-policy-context.e2e.ts

@@ -18,7 +18,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context', import.meta.url))
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.v3.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
 
 const PROMPTS = [
@@ -30,10 +30,10 @@ const PROMPTS = [
 
 const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const
 
-function requestSystems(events: readonly SessionEvent[]): string[] {
+function systemPrompts(events: readonly SessionEvent[]): string[] {
   return events.flatMap((event) => {
-    if (event.type !== 'request/header') return []
-    return typeof event.data.header.system === 'string' ? [event.data.header.system] : []
+    if (event.type !== 'system/message') return []
+    return [event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')]
   })
 }
 
@@ -124,7 +124,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
   }, 240_000)
 
   it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => {
-    const systems = requestSystems(sessionEvents)
+    const systems = systemPrompts(sessionEvents)
     expect(systems).toHaveLength(1)
     expect(systems[0]).not.toContain('Current DSH file policy:')
     expect(systems[0]).not.toContain('Approval policy:')
@@ -168,6 +168,6 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
   it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'workspace.expected'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'workspace.expected'])
   })
 })

+ 2 - 2
apps/web/tests/plan-control-row.e2e.ts

@@ -37,7 +37,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/plan-narrow-viewport', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const MODE = webSnapshotMode()
 
@@ -143,6 +143,6 @@ describe('web e2e: plan chip click area at the narrow viewport', () => {
   }, 200_000)
 
   it('keeps the snapshot inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'layout.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'layout.expected.md'])
   })
 })

+ 2 - 2
apps/web/tests/plan-review.e2e.ts

@@ -22,7 +22,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/plan-review', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 // The waiting golden owns the decision card; the approved golden owns the
 // transcript the approval leaves behind — the state the card cannot see.
 const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
@@ -125,7 +125,7 @@ describe('web e2e: plan review takeover round trip', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'review.expected.md', 'sidebar.expected.md',
+      'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md',
       'approved.expected.md', 'approved-expanded.expected.md',
     ])
   })

+ 2 - 2
apps/web/tests/ptc-round.e2e.ts

@@ -1,5 +1,5 @@
 // PTC mode browser round trip with nested sub-calls and details selection.
-// Record: DSH_SNAPSHOT=record writes session.v2.jsonl, then a keyless
+// Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless
 // DSH_SNAPSHOT=refresh regenerates ui.expected.md.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
@@ -13,7 +13,7 @@ import {
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 

+ 2 - 2
apps/web/tests/pwsh-terminal.e2e.ts

@@ -25,7 +25,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/pwsh-terminal', import.meta.url))
-const SEED = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url))
 const PROMPT = 'Run a PowerShell command that fails, then stop.'
@@ -101,6 +101,6 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
   }, 60_000)
 
   it('guards the lane fixture inventory', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'terminal-card.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'terminal-card.expected.md'])
   })
 })

+ 2 - 2
apps/web/tests/question-composer.e2e.ts

@@ -24,7 +24,7 @@ import {
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
 const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
@@ -410,7 +410,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', ()
 
   it('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl',
+      'session.v3.jsonl',
       'ui.expected.md',
       'sidebar.expected.md',
       'composed.expected.md',

+ 1 - 1
apps/web/tests/queue-actions.e2e.ts

@@ -19,7 +19,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queue-actions', import.meta.url))
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
 const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
 const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
 const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')

+ 1 - 1
apps/web/tests/queue-image.e2e.ts

@@ -21,7 +21,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queued-image', import.meta.url))
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
 const PNG = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
 const QUEUED_EXPECTED = join(SNAPSHOT_DIR, 'queued.expected.md')
 const DELIVERED_EXPECTED = join(SNAPSHOT_DIR, 'delivered.expected.md')

+ 12 - 6
apps/web/tests/replay-round-trip.e2e.ts

@@ -6,7 +6,7 @@
 // record cannot hang on a live model answering differently); assertion steps
 // run in replay/refresh only. Settled states only — streaming fidelity is
 // asserted from the durable embedded Assistant stream, not transient DOM.
-// Record: DSH_SNAPSHOT=record writes session.v2.jsonl, then a keyless
+// Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless
 // DSH_SNAPSHOT=refresh regenerates ui.expected.md.
 import { readFile } from 'node:fs/promises'
 import { join } from 'node:path'
@@ -15,7 +15,7 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import {
   assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
   compareOrRefreshGolden, fixtureUserPrompts,
@@ -26,7 +26,7 @@ import {
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url))
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url))
 const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url))
 const UI_EXPANDED_EXPECTED = fileURLToPath(
@@ -40,6 +40,12 @@ const MODE = webSnapshotMode()
 // drift apart.
 const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.'
 
+/** Rendered text of the system prompt surface node, or undefined when the surface carries none. */
+function systemPromptText(session: Session): string | undefined {
+  const message = session.deriveMessages().find(candidate => candidate.role === 'system')
+  return message?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
+}
+
 describe('web e2e: fresh round trip through the real assembly', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -101,11 +107,11 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     }
   }, 200_000)
 
-  it('ends the request header with the source checkout, Web surface, and session cwd', async () => {
+  it('ends the system prompt with the source checkout, Web surface, and session cwd', async () => {
     if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
     const agent = scaffold.ctx.agents.get(settledSessionId)
     if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
-    const system = agent.session.requestHeader()?.system
+    const system = systemPromptText(agent.session)
     if (system === undefined) throw new Error('the settled Web request has no system prompt')
     const paragraphs = system.split('\n\n')
     expect(paragraphs.slice(0, 2)).toEqual([
@@ -223,7 +229,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl',
+      'session.v3.jsonl',
       'submission-echo.expected.md',
       'system-prompt.expected.md',
       'tool-schemas.expected.json',

+ 6 - 5
apps/web/tests/scaffold.ts

@@ -46,7 +46,7 @@ import {
   redactSessionSnapshotIds,
   normalizeSessionSnapshots,
   parseSessionFixtureName,
-  scrubRequestHeaders,
+  scrubModelRequestBulk,
   scrubSessionSnapshot,
   sessionFixtureFiles,
   sessionFixtureName,
@@ -1057,9 +1057,10 @@ async function assertReplaySession(
 }
 
 /**
- * Record-mode fixture write-back: harvest the live session, scrub request
- * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, redact opaque
- * identities with typed relationship-preserving tokens, and write the fixture.
+ * Record-mode fixture write-back: harvest the live session, scrub the
+ * system-prompt text to {{system}} and header tool schemas to {{tools}},
+ * tokenize the run-local cwd, redact opaque identities with typed
+ * relationship-preserving tokens, and write the fixture.
  * A manifest-retained historical generation makes the write-back a no-op.
  * @param scaffold - the record-mode scaffold.
  * @param sessionId - the driven session.
@@ -1532,7 +1533,7 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P
   }
   for (const entry of artifacts.filter(name => name.endsWith('.jsonl'))) {
     const content = await readFile(join(dir, entry), 'utf8')
-    expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content)
+    expect(scrubModelRequestBulk(content), `${dir}/${entry} carries prompt text or tool-schema bulk`).toBe(content)
     expect(redactSessionSnapshotIds([content]), `${dir}/${entry} carries unredacted identities`).toEqual([content])
   }
 }

+ 2 - 2
apps/web/tests/schedule-after.e2e.ts

@@ -58,7 +58,7 @@ const EVERY_REPLY = 'Reminders: Check primary metrics; Check secondary metrics.'
 const EVERY_INTERVAL_SECONDS = 60 * 60
 const EVERY_FIXTURE_AGE_MS = 90 * 60 * 1_000
 const CATALOG_SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/schedule-catalog', import.meta.url))
-const CATALOG_FIXTURE = join(CATALOG_SNAPSHOT_DIR, 'session.v2.jsonl')
+const CATALOG_FIXTURE = join(CATALOG_SNAPSHOT_DIR, 'session.v3.jsonl')
 const CATALOG_EXPECTED = join(CATALOG_SNAPSHOT_DIR, 'catalog.expected.md')
 const BASE_PATCH = fileURLToPath(new URL('../../../packages/bundle/base/cordis.patch.yml', import.meta.url))
 const WEB_PATCH = fileURLToPath(new URL('../../../packages/bundle/web-app/cordis.patch.yml', import.meta.url))
@@ -816,7 +816,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
     }).toBe(0)
     await assertFixtureInventory(CATALOG_SNAPSHOT_DIR, [
       'catalog.expected.md',
-      'session.v2.jsonl',
+      'session.v3.jsonl',
       'system-prompt.expected.md',
       'tool-schemas.expected.json',
     ])

+ 3 - 3
apps/web/tests/seeded-history.e2e.ts

@@ -9,7 +9,7 @@
 // `/feedback` pins its expandable correlation ids. The seed is a recorded
 // fixture under the same record discipline as every other: DSH_SNAPSHOT=record drives the turn
 // live through the composer (real read tool against seeded workspace files)
-// and harvests session.v2.jsonl; replay/refresh seed it cold and only render.
+// and harvests session.v3.jsonl; replay/refresh seed it cold and only render.
 import { readFile, writeFile, mkdir } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -30,7 +30,7 @@ import {
 import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url))
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url))
 const UI_EXPANDED_EXPECTED = fileURLToPath(
   new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url),
@@ -564,7 +564,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md',
-      'session.v2.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+      'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })
 })

+ 2 - 2
apps/web/tests/sidebar-scrollbar.e2e.ts

@@ -15,7 +15,7 @@ import {
 } from './scaffold.ts'
 import { newEnglishPage, saveFailureShot } from './support.ts'
 
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-scrollbar', import.meta.url))
 /** Geometry and resolved style are absent from ARIA snapshots, so this scenario records them directly. */
 const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
@@ -412,7 +412,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
   }, 60_000)
 
   it('commits exactly the fixtures it reads', async () => {
-    // The scenario borrows seeded-history's session.v2.jsonl rather than committing a
+    // The scenario borrows seeded-history's session.v3.jsonl rather than committing a
     // second copy, so this directory holds the golden alone.
     await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
   })

+ 1 - 1
apps/web/tests/skill-tool-row.e2e.ts

@@ -12,7 +12,7 @@ import {
 } from './scaffold.ts'
 import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v3.jsonl', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row/ui.expected.md', import.meta.url))
 const MODE = webSnapshotMode()

+ 17 - 7
apps/web/tests/stats-paged-history.e2e.ts

@@ -1,5 +1,5 @@
 // Web e2e scenario: full-session stats over paged history. A deterministic
-// 28-turn log (56 surface messages — more than one 50-message history page)
+// 28-turn log (56 chat messages — more than one 50-message history page)
 // seeded cold through the REAL persistence API must render whole-log turn/step
 // counts from the sessionStats projection on first open, and loading the
 // older page must NOT change them. This pins the bug the projection fixed:
@@ -9,6 +9,8 @@
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
+import { createSystemMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -21,20 +23,21 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/stats-paged-history/ui.exp
 const MODE = webSnapshotMode()
 const SEED_ID = 'stats-paged-history-web-e2e'
 
-/** Turn count: 2 surface messages per turn, so 28 turns overflow one 50-message page. */
+/** Turn count: 2 chat messages per turn, so 28 turns overflow one 50-message page. */
 const TURNS = 28
 const FULL_COUNTS = `${TURNS} turns · ${TURNS} steps`
 
 /**
  * Generate the seed: TURNS closed single-step turns of one short user prompt
- * and one short assistant reply each. Times are fixed so the fixture is
- * byte-deterministic; message ids are synthetic uuids (aria normalizes them).
+ * and one short assistant reply each. Fixed times pin displayed dates;
+ * the empty system head precedes every user message in the current format.
  * @param turns - closed turns to generate.
  * @returns session.jsonl text for {@link seedSession}.
  */
 function buildSeed(turns: number): string {
   const lines = [JSON.stringify({
-    type: 'session', version: 0, id: '{{sessionId}}', createdAt: 1784974100000, cwd: '{{cwd}}/workspace',
+    type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
+    createdAt: 1784974100000, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0,
   })]
   let seq = 0
   let time = 1784974100000
@@ -43,6 +46,14 @@ function buildSeed(turns: number): string {
   }
   for (let turn = 1; turn <= turns; turn++) {
     at({ type: 'turn/start', data: { turn } })
+    at({ type: 'step/start', data: { turn, step: 1 } })
+    if (turn === 1) {
+      at({
+        type: 'system/message',
+        data: { turn, step: 1, message: createSystemMessage('', '@deepseek-ai/dsh-system-prompt') },
+        surfaceOp: 'append',
+      })
+    }
     at({
       type: 'user/message',
       data: {
@@ -53,10 +64,10 @@ function buildSeed(turns: number): string {
       },
       surfaceOp: 'append',
     })
-    at({ type: 'step/start', data: { turn, step: 1 } })
     at({
       type: 'assistant/message',
       data: {
+        stream: [],
         turn,
         step: 1,
         message: {
@@ -66,7 +77,6 @@ function buildSeed(turns: number): string {
           source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' },
         },
       },
-      sourceEventSeqs: [],
       surfaceOp: 'append',
     })
     at({ type: 'step/end', data: { turn, step: 1 } })

+ 2 - 2
apps/web/tests/steering.e2e.ts

@@ -19,7 +19,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 // Two goldens pin the transient Host projection and its durable handoff: the
 // mid-turn state renders accepted steering from the Session control queue while the
 // question blocks admission, then the settled state renders the same message
@@ -186,7 +186,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v2.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md',
+      'session.v3.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md',
     ])
   })
 })

+ 1 - 1
apps/web/tests/subagent-conversation.e2e.ts

@@ -19,7 +19,7 @@ import {
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
-const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url))
+const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
 const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath(
   new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url),

+ 1 - 1
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -27,7 +27,7 @@ import {
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
-const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url))
+const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/subagent-interrupt', import.meta.url))
 const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')

+ 2 - 2
apps/web/tests/turn-tail-actions.e2e.ts

@@ -23,7 +23,7 @@ import {
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
-const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 // Three goldens for the same message: parked mid-turn, aborted, and completed.
 const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
@@ -291,7 +291,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await assertFixtureInventory(
       SNAPSHOT_DIR,
       [
-        'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.v2.jsonl',
+        'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.v3.jsonl',
         'settled.expected.md', 'usage-expanded.expected.md',
       ],
     )

+ 2 - 2
apps/web/tests/web-search-round.e2e.ts

@@ -19,7 +19,7 @@ import {
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url))
-const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.v2.jsonl', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/web-search-round/ui.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const QUERIES = ['DeepSeek Harness snapshot search', 'DeepSeek Harness multi-query search'] as const
@@ -323,6 +323,6 @@ describe('web e2e: shipped default web search', () => {
   it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md'])
   })
 })

+ 2 - 2
apps/web/tests/workflow-run.e2e.ts

@@ -22,8 +22,8 @@ const MODE = webSnapshotMode()
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workflow-run', import.meta.url))
 const UI_LIVE_EXPECTED = join(SNAPSHOT_DIR, 'ui-live.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
-const PARENT_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.v2.jsonl')
-const CHILD_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.1.v2.jsonl')
+const PARENT_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.v3.jsonl')
+const CHILD_FIXTURE = join(REPO_ROOT, 'snapshots/session/workflow-run/session.1.v3.jsonl')
 const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.'
 
 describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => {

+ 1 - 1
apps/web/tests/workspace-management.e2e.ts

@@ -28,7 +28,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workspace-management', import.meta.url))
 // The seed is another scenario's committed fixture, reused read-only: this
 // spec needs any one cold session row, not new recorded content.
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
 const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
 const SEED_ID = 'workspace-management-web-e2e'

+ 1 - 1
apps/web/tests/workspace-new-session-folding.e2e.ts

@@ -20,7 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 
 const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-folding', import.meta.url))
 const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md')
-const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v2.jsonl', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v3.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
 const EXISTING_SESSION_COUNT = 6
 

+ 2 - 2
benchmarks/agent-continuation/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 benchmarks/agent-continuation/README.md
-README.md: 5f76b67805a906ea13321ab22eb671d41bfc3190
-README.zh.md: 54d9e1a0e950d14ce5a01568bc9e1cdb61de597a
+README.md: 75f804fc18deb90f1db40093cd52e5df10886b46
+README.zh.md: 047c4988beaebe96e0bd561674e5314139608c41

+ 1 - 1
benchmarks/agent-continuation/README.md

@@ -24,7 +24,7 @@ The test reports all five fresh-process samples and enforces reviewed median bud
 
 ## Measurements
 
-[workload.ts](workload.ts) owns synthetic dimensions. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md) owns timing endpoints, calibration evidence, memory interpretation, and exclusions. The model adapter does not perform provider serialization or network calls; integrated cases run synthetic tool bodies through the real tool-execution pipeline, while the SDK profile variant performs real file reads.
+[workload.ts](workload.ts) owns synthetic dimensions. Its current-generation history reserves an empty system head in the first step before user input, so resumed prompts replace that head without moving historical messages. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md) owns timing endpoints, calibration evidence, memory interpretation, and exclusions. The model adapter does not perform provider serialization or network calls; integrated cases run synthetic tool bodies through the real tool-execution pipeline, while the SDK profile variant performs real file reads.
 
 ## Dev Note
 

+ 1 - 1
benchmarks/agent-continuation/README.zh.md

@@ -24,7 +24,7 @@
 
 ## 测量
 
-[workload.ts](workload.ts)拥有合成维度。[Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md)拥有计时终点、校准证据、内存解释和排除项。模型适配器不执行服务商序列化或网络调用;集成用例通过真实工具执行管线运行合成工具体,SDK profile 变体则执行真实文件读取。
+[workload.ts](workload.ts)拥有合成维度。其当前代历史在首个 step 的用户输入之前保留空 system 头节点,因此续聊提示会替换该头节点而不移动历史消息。[Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md)拥有计时终点、校准证据、内存解释和排除项。模型适配器不执行服务商序列化或网络调用;集成用例通过真实工具执行管线运行合成工具体,SDK profile 变体则执行真实文件读取。
 
 ## Dev Note
 

+ 39 - 0
benchmarks/agent-continuation/synthetic-history.bench.ts

@@ -0,0 +1,39 @@
+/** Current-generation benchmark seeds retain the system head across continuation. */
+import { expect, it } from 'vitest'
+import { createSystemMessage } from '@deepseek-ai/dsh-llm'
+import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import { syntheticHistory as browserHistory } from '../long-session-browser/synthetic-history.ts'
+import { syntheticHistory } from './workload.ts'
+
+const header = { type: 'session', version: SESSION_FORMAT_VERSION, id: 'benchmark-seed-check', createdAt: 1_700_000_000_000, cwd: '/bench', isSeeded: false, delegationDepth: 0 }
+
+for (const [name, generate] of [
+  ['continuation', () => [JSON.stringify(header), ...syntheticHistory(2).map(event => JSON.stringify(event))].join('\n')],
+  ['browser', browserHistory],
+] as const) {
+  it(name + ' seed preserves a protected head when the next prompt replaces it', () => {
+    const events = parseSessionLog(generate())
+    expect(events.slice(0, 4).map(event => event.type)).toEqual(['turn/start', 'step/start', 'system/message', 'user/message'])
+    const session = Session.create(SessionId(header.id), events)
+    const head = session.surface.nodes[0]!
+    expect(session.eventAt(head)).toMatchObject({ type: 'system/message', data: { turn: 1, step: 1, message: { role: 'system', content: [] } } })
+    const history = session.deriveMessages()
+    const turn = events.filter(event => event.type === 'turn/start').length + 1
+    session.append('turn/start', { turn })
+    session.append('step/start', { turn, step: 1 })
+    const replacement = session.append('system/message', {
+      turn, step: 1, message: createSystemMessage('Next synthetic prompt', '@deepseek-ai/dsh-system-prompt'),
+    }, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
+    const restored = Session.create(SessionId(header.id), parseSessionLog([
+      JSON.stringify(header), ...session.snapshotEvents().map(event => JSON.stringify(event)),
+    ].join('\n')))
+    expect(restored.surface.nodes[0]).toBe(replacement.seq)
+    expect(restored.deriveMessages()[0]).toMatchObject({ role: 'system', content: [{ type: 'text', text: 'Next synthetic prompt' }] })
+    expect(restored.deriveMessages().slice(1)).toEqual(history)
+    for (const event of events) {
+      if (event.type === 'session/title') expect(session.eventAt(event.data.messageSeqs[0]!)?.type).toBe('user/message')
+      if (event.type === 'tool/result') expect(session.eventAt(event.sourceEventSeqs![0]!)?.type).toBe('tool/call')
+    }
+  })
+}

+ 3 - 0
benchmarks/agent-continuation/workload.ts

@@ -68,6 +68,9 @@ export function syntheticHistory(turns: number): SessionEvent[] {
   for (let turn = 1; turn <= turns; turn++) {
     session.append('turn/start', { turn })
     session.append('step/start', { turn, step: 1 })
+    if (turn === 1) session.append('system/message', {
+      turn, step: 1, message: { id: MessageId('system-head'), role: 'system', content: [], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } },
+    }, { surfaceOp: 'append' })
     session.append('user/message', {
       id: MessageId('prompt-' + String(turn)), role: 'user',
       content: [{ type: 'text', text: 'Inspect synthetic module ' + String(turn) }], source: { kind: 'user' },

+ 2 - 2
benchmarks/long-session-browser/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 benchmarks/long-session-browser/README.md
-README.md: cca0bb0a29dbb330e9bc700631866e6a34a21118
-README.zh.md: 0e9035971da5685a37012c2a98e3b889b2f480ca
+README.md: 009a7a61d9a1a0d4b198ae017def5d3126b713a2
+README.zh.md: 2dd0c52b0f1cc04aac8a78d847401067201afa13

+ 1 - 1
benchmarks/long-session-browser/README.md

@@ -12,6 +12,6 @@ The required Chromium workflow in [long-session.bench.ts](long-session.bench.ts)
 
 Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Diagnostics report marker state and focus after the first-visible wait, and browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets.
 
-The fixture contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence.
+The fixture reserves an empty system head before the first user message, with each user message inside its step. It contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence.
 
 The [decision record](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md) owns calibration, exclusions, and alternatives. The larger [manual diagnostic](../../apps/web/tests/complex-history.perf.ts) remains separate.

+ 1 - 1
benchmarks/long-session-browser/README.zh.md

@@ -12,6 +12,6 @@
 
 三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期对话状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首段可见回复、真实草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本证据仅读取最新 Assistant step,避免重复扫描全部历史文本与无障碍属性。输入观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。诊断报告首段可见等待后的标记状态与焦点,以及首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停重放;首段观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完成测量在 Host 结算后等待新 turn-tail 渲染。测量后,在 DONE 之后发送的真实按键必须无法通过同一个重叠断言。打开、最慢更早分页和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。
 
-fixture(测试前置数据)包含混合语言提示、正文、推理、20 个代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑 stream,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish chunk 构建。其内容不来自模型、工具、外部网络、录制 Session 或私有 Harness 主目录。流式回复以 16 ms 重放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。
+fixture(测试前置数据)在首条用户消息前保留空 system 头节点,每条用户消息都位于其 step 内。它包含混合语言提示、正文、推理、20 个代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑 stream,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish chunk 构建。其内容不来自模型、工具、外部网络、录制 Session 或私有 Harness 主目录。流式回复以 16 ms 重放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。
 
 [决策记录](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md)拥有校准、排除项与替代方案。更大规模的[手动诊断](../../apps/web/tests/complex-history.perf.ts)保持独立。

+ 5 - 2
benchmarks/long-session-browser/synthetic-history.ts

@@ -1,5 +1,5 @@
 /** Synthetic current-generation history and paced reply for browser measurements. */
-import { createAssistantMessage, createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
+import { createAssistantMessage, createSystemMessage, createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
 import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
@@ -26,12 +26,15 @@ export function syntheticHistory(): string {
   const session = Session.create(SessionId(SESSION_ID))
   for (let turn = 1; turn <= HISTORY_TURNS; turn++) {
     session.append('turn/start', { turn })
+    session.append('step/start', { turn, step: 1 })
+    if (turn === 1) session.append('system/message', {
+      turn, step: 1, message: createSystemMessage('', '@deepseek-ai/dsh-system-prompt'),
+    }, { surfaceOp: 'append' })
     const user = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'Review synthetic change ' + String(turn) + ': 检查增量渲染。 '.repeat(30) }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
     if (turn === 1) session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } })
-    session.append('step/start', { turn, step: 1 })
     const callId = ToolCallId('synthetic-tool-' + String(turn))
     const tool = turn % 6 === 0
     const code = turn % 12 === 0

+ 70 - 2
benchmarks/session-open/session-open.bench.ts

@@ -1,9 +1,12 @@
 /** Required performance budgets for cold Session preparation, first history, and Agent resume. */
 
-import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { copyFile, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import {
   runBuiltBenchmarkWorker,
   type BuiltBenchmarkWorkerRun,
@@ -18,6 +21,7 @@ import type {
 } from './session-open.worker.ts'
 import {
   SYNTHETIC_CURRENT_GENERATION,
+  SYNTHETIC_SESSION_ID,
   SYNTHETIC_SESSION_DIRECTORY,
   SYNTHETIC_CURRENT_FILENAME,
   SYNTHETIC_V0_FILENAME,
@@ -152,7 +156,10 @@ function requireReport(
   if (run.report !== undefined) return run.report
   const stderrLines = run.stderr.trim().split('\n')
   const fatal = stderrLines.filter(line => /FATAL ERROR|heap limit|out of memory/i.test(line))
-  const detail = (fatal.length > 0 ? fatal : stderrLines.slice(-10)).join('\n')
+  const context = stderrLines.length <= 20
+    ? stderrLines
+    : [...stderrLines.slice(0, 10), '... stderr middle omitted ...', ...stderrLines.slice(-10)]
+  const detail = (fatal.length > 0 ? fatal : context).join('\n')
   const limit = heapLimitMb === undefined ? 'normal heap' : `${String(heapLimitMb)} MB old space`
   throw new Error(
     `${scenario} failed under ${limit}: exit=${String(run.exitCode)}, signal=${String(run.signal)}, `
@@ -293,6 +300,67 @@ describe('standard hosted reopen calibration', () => {
   })
 })
 
+describe('Session opening benchmark prerequisites', () => {
+  it('retains the exception headline and bounded stderr tail when a worker fails', () => {
+    const headline = 'SessionFormatUnsupportedError: source chronology cannot be migrated'
+    const stderr = [headline, ...Array.from({ length: 30 }, (_, index) => 'stack frame ' + String(index)), 'Node.js test'].join('\n')
+    const run: WorkerRun = { report: undefined, exitCode: 1, signal: null, timedOut: false, stderr }
+    expect(() => requireReport(run, 'agent-resume')).toThrow(headline)
+    expect(() => requireReport(run, 'agent-resume')).toThrow('Node.js test')
+    expect(() => requireReport(run, 'agent-resume')).not.toThrow('stack frame 15')
+    expect(() => requireReport({ ...run, stderr: 'FATAL ERROR: heap limit' }, 'agent-resume', 128))
+      .toThrow('128 MB old space')
+  })
+
+  it('migrates the generated workload and reopens its successor without changing V0', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-session-bench-fixture-'))
+    const contexts: Context[] = []
+    const mount = async () => {
+      const ctx = new Context()
+      contexts.push(ctx)
+      await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
+      return ctx.sessionPersistence
+    }
+    try {
+      const facts = await writeSyntheticReleasedV0Session(root, { turns: 2, textDeltas: 12 })
+      expect({ events: facts.events, rows: facts.rows, frames: facts.frames }).toEqual({ events: 54, rows: 28, frames: 29 })
+      const original = await readFile(facts.path)
+      const directory = join(root, SYNTHETIC_SESSION_DIRECTORY)
+      const persistence = await mount()
+      const read = await persistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
+      const initial = await read.read()
+      const session = Session.fromRestore(read.header.id, initial.events, read.header, read.inheritedEventCount, initial.eventState)
+      expect(read.header.version).toBe(SESSION_FORMAT_VERSION)
+      expect(initial.events.filter(event => event.type === 'system/message')).toHaveLength(1)
+      expect(session.deriveMessages().map(({ id, role, content }) => ({ id, role, content }))).toEqual(
+        [1, 2].flatMap(turn => [
+          { id: 'user-' + String(turn), role: 'user', content: [{ type: 'text', text: 'prompt ' + String(turn) }] },
+          { id: 'assistant-' + String(turn), role: 'assistant', content: [
+            { type: 'reasoning', text: 'r0 r1 r2 ' },
+            { type: 'text', text: Array.from({ length: 12 }, (_, index) => 'w' + String(index) + ' ').join('') },
+          ] },
+        ]),
+      )
+      await read.close()
+      expect(await readdir(directory)).toEqual([SYNTHETIC_V0_FILENAME])
+      const writer = await persistence.open(SessionId(SYNTHETIC_SESSION_ID), 'write')
+      expect((await writer.read()).events).toEqual(initial.events)
+      await writer.close()
+      expect(await readdir(directory)).toContain(SYNTHETIC_CURRENT_FILENAME)
+      const reopened = await (await mount()).open(SessionId(SYNTHETIC_SESSION_ID), 'read')
+      expect((await reopened.read()).events).toEqual(initial.events)
+      await reopened.close()
+      expect(await readFile(facts.path)).toEqual(original)
+    } finally {
+      try {
+        for (const ctx of contexts.reverse()) await ctx.fiber.dispose()
+      } finally {
+        await rm(root, { recursive: true, force: true })
+      }
+    }
+  })
+})
+
 describe('opening a large Session for first open and post-upgrade reopen', () => {
   const suite = new SessionOpenBenchmarkSuite()
 

+ 1 - 1
benchmarks/session-open/synthetic-released-v0-session.ts

@@ -67,13 +67,13 @@ class ReleasedV0FixtureBuilder {
 
   private appendTurn(turn: number, reasoningDeltaCount: number, textDeltaCount: number): void {
     this.appendEvent('turn/start', { turn })
+    this.appendEvent('step/start', { turn, step: 1 })
     this.appendEvent('user/message', {
       id: `user-${String(turn)}`,
       role: 'user',
       content: [{ type: 'text', text: `prompt ${String(turn)}` }],
       source: { kind: 'user' },
     }, { surfaceOp: 'append' })
-    this.appendEvent('step/start', { turn, step: 1 })
     const firstChunkSeq = this.appendChunk(turn, { type: 'block-start', index: 0, blockType: 'reasoning' })
     const reasoningDeltas = Array.from(
       { length: reasoningDeltaCount },

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels