Explorar el Código

fix(web): keep resume prompt dedupe client-side

Dudu-0223 hace 1 mes
padre
commit
f1e7dbce55

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.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/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md
-2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 67e0048051e7842ef629868cb5c006f3b52dcd26
-2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: a6d3b7ff628ed23c614a728f8e231ee8ed8e1bce
+2026-09-03-resume-headers-do-not-repeat-system-prompts.md: 63acf56998276cdaff400186b7c33761eaf63788
+2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 9c09148f74042b70ef03fb92f1d3a2986a055da9

+ 4 - 4
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md

@@ -10,11 +10,11 @@ Forking a Session copies the source history into the child. The child's first mo
 
 ## Decision
 
-The durable resume header records the request boundary needed for exact Session reconstruction. When the first admitted request of a resumed loop explicitly begins a distinct message series, the loop preserves that fact as `startsSeries: true` on the resume snapshot. Chat compares the full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged ordinary resume does not create a visible repetition.
+The durable resume header remains unchanged because it records the request boundary needed for exact Session reconstruction. Chat now compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition.
 
 A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible.
 
-Trajectory continues to expose every request header and its classified changes. Provider requests and reconstruction stay unchanged; the only Session-event difference is the existing `startsSeries` marker on an explicitly declared resumed-series boundary.
+Trajectory continues to expose every request header and its classified changes. The change is limited to Chat presentation and does not alter provider requests, Session events, or reconstruction.
 
 ## Alternatives considered
 
@@ -26,6 +26,6 @@ Trajectory continues to expose every request header and its classified changes.
 
 ## Consequences
 
-Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts, including the first request of a resumed loop, and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node.
+Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node.
 
-The unit regressions cover initial, series, unchanged resume, resumed-series, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control.
+The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control.

+ 4 - 4
.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md

@@ -10,11 +10,11 @@ fork Session 会把源会话历史复制到子会话。即使 system 字段与
 
 ## 决策
 
-持久化 resume header 记录精确重建 Session 所需的请求边界。恢复后的 loop 首次接纳的请求显式开启独立消息序列时,loop 会在 resume 快照上以 `startsSeries: true` 保留该事实。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的普通 resume 不创建可见的重复行。
+持久化 resume header 保持不变,因为它记录了精确重建 Session 所需的请求边界。Chat 现在会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。
 
 部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。
 
-Trajectory 会继续展示每一条请求 header 及其变化分类。提供方请求与重建保持不变;Session event 的唯一差异,是显式声明的恢复后序列边界会携带已有的 `startsSeries` 标记。
+Trajectory 会继续展示每一条请求 header 及其变化分类。本次改动仅限 Chat 展示,不改变提供方请求、Session event 或重建行为。
 
 ## 考虑过的替代方案
 
@@ -26,6 +26,6 @@ Trajectory 会继续展示每一条请求 header 及其变化分类。提供方
 
 ## 后果
 
-继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点(包括恢复后 loop 的首次请求)与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。
+继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。
 
-单元回归覆盖初始请求、显式序列、未变化 resume、恢复后序列、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。
+单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: cd0c5dbd91ece3ad7786ac6b0a3dac9dd25c8d17
-architecture.zh.md: 7fd56267a542e43e6743380ca9c0dc9973a76457
+architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18
+architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4

+ 1 - 1
docs/architecture.md

@@ -96,7 +96,7 @@ turn/end
 
 Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 
-`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`; `change` carrying `startsSeries: true` when the envelope changed too; or `resume` carrying that field when the loop instance's first request begins the series). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
+`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
 
 Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
 

+ 1 - 1
docs/architecture.zh.md

@@ -100,7 +100,7 @@ turn/end
 
 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 
-`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`;在 envelope 同时变化时为携带 `startsSeries: true` 的 `change`;循环实例的首次请求开启该序列时则为携带此字段的 `resume`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
+`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
 
 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
 

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 8412ff7bcf4b02ab6e4625e29c8a04ac5fa5e9ec
-persistence-catalog.zh.md: 0d4244a6cbe7ac07bfebe82d926ff22b6ab163f8
+persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c
+persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a

+ 1 - 1
docs/persistence-catalog.md

@@ -577,7 +577,7 @@ Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/
 'request/header': {
   header: EpochHeader
   reason: RequestHeaderReason
-  /** A `change` or `resume` snapshot also begins a distinct model-message series. */
+  /** A changed header also begins a distinct model-message series. */
   startsSeries?: true
 }
 ```

+ 1 - 1
docs/persistence-catalog.zh.md

@@ -579,7 +579,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/header': {
   header: EpochHeader
   reason: RequestHeaderReason
-  /** A `change` or `resume` snapshot also begins a distinct model-message series. */
+  /** A changed header also begins a distinct model-message series. */
   startsSeries?: true
 }
 ```

+ 2 - 2
docs/subsystems/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: b4d5ef6bd65260c7165e2cbfd40ec59d9c552b2a
-session.zh.md: 6011f5f76c166e5e7284789371fdaa258bd56819
+session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31
+session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd

+ 2 - 2
docs/subsystems/session.md

@@ -97,7 +97,7 @@ interface SessionEventMap {
   'request/header': {
     header: EpochHeader
     reason: RequestHeaderReason
-    /** A `change` or `resume` snapshot also begins a distinct model-message series. */
+    /** A changed header also begins a distinct model-message series. */
     startsSeries?: true
   }
   /**
@@ -137,7 +137,7 @@ interface SessionEventMap {
 
 ### The request header event: `request/header`
 
-The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a resume snapshot carries `startsSeries: true` when that request's admitted step explicitly begins a distinct series. A changed request appends a snapshot with reason `'change'`; an unchanged envelope beginning a later explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
+The request envelope — the `EpochHeader` (call config + markers for adapter-supplied defaults + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a changed request appends a snapshot with reason `'change'`; and an unchanged envelope beginning an explicitly declared message series or following a surface replacement appends a snapshot with reason `'series'`. A changed snapshot carries `startsSeries: true` when that request also begins a series. Ordinary append-only later Turns, further Steps, and retries in the same model-message series inherit the latest snapshot. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
 
 ```ts type-equiv
 /**

+ 2 - 2
docs/subsystems/session.zh.md

@@ -97,7 +97,7 @@ interface SessionEventMap {
   'request/header': {
     header: EpochHeader
     reason: RequestHeaderReason
-    /** A `change` or `resume` snapshot also begins a distinct model-message series. */
+    /** A changed header also begins a distinct model-message series. */
     startsSeries?: true
   }
   /**
@@ -137,7 +137,7 @@ interface SessionEventMap {
 
 ### 请求头事件:`request/header`
 
-请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;恢复请求的已接纳步骤显式开启独立序列时,`resume` 快照携带 `startsSeries: true`。请求变化时会追加 reason 为 `'change'` 的快照;未变的信封后续显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
+请求信封(即 `EpochHeader`:调用配置 + 适配器所提供默认值的标记 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 `'change'` 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 `'series'` 的快照。如果发生变化的快照所属请求同时开启序列,它会携带 `startsSeries: true`。普通的仅追加后续 Turn,以及同一模型消息序列内的后续 Step 与重试沿用最新快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
 
 ```ts type-equiv
 /**

+ 1 - 11
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -1362,7 +1362,6 @@ describe('built-in conversation node Definitions', () => {
       }),
       at(5, 'request/header', {
         reason: 'resume',
-        startsSeries: true,
         header: {
           config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
           system: '# Initial',
@@ -1370,14 +1369,6 @@ describe('built-in conversation node Definitions', () => {
         },
       }),
       at(6, 'request/header', {
-        reason: 'resume',
-        header: {
-          config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
-          system: '# Initial',
-          tools: expandedTools,
-        },
-      }),
-      at(7, 'request/header', {
         reason: 'change',
         header: {
           config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
@@ -1392,8 +1383,7 @@ describe('built-in conversation node Definitions', () => {
     expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
       { anchorSeq: 1, data: { text: '# Initial' } },
       { anchorSeq: 4, data: { text: '# Initial' } },
-      { anchorSeq: 5, data: { text: '# Initial' } },
-      { anchorSeq: 7, data: { text: '# Updated' } },
+      { anchorSeq: 6, data: { text: '# Updated' } },
     ])
 
     const windowed = assembler([

+ 2 - 2
packages/core/agent-loop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
-README.md: cb5e9b18eb44e7e91ff11750b5aad3bfc5a29140
-README.zh.md: c1439f704d405ccfc785036c5372fc17582d3819
+README.md: 5e4ab0a741f0b7dae821e52f1a9b0a1faa90ceed
+README.zh.md: ef47f897978c8254242075a559f56faa2dceca19

+ 1 - 1
packages/core/agent-loop/README.md

@@ -88,7 +88,7 @@ The package is the one concrete implementation of the public `Agent` contract. I
 
 ### Request headers and adapter defaults
 
-After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; a resume snapshot carries `startsSeries: true` when its admitted step explicitly begins a distinct series. Unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`.
+After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields and resolves reasoning-effort and output-token defaults under the active turn signal. The loop retains that exact adapter through resolution, `request/header` logging, and dispatch. It writes a full header for the first request, a changed envelope, an explicit message-series start, a request after surface replacement, and resume; unchanged steps, retries, and ordinary later turns in the same series inherit the latest header. Before the next waterfall, the loop removes adapter-default fields so the current route resolves them again, while explicit settings persist. An unhandled route still fails with `NO_ADAPTER`.
 
 ### Source map
 

+ 1 - 1
packages/core/agent-loop/README.zh.md

@@ -88,7 +88,7 @@ const handle = await ctx.agents.create({
 
 ### 请求 header 与适配器默认值
 
-`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;恢复快照的已接纳步骤显式开启独立序列时,该快照携带 `startsSeries: true`。同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。
+`agent/request` 返回后,`ctx.llm.prepareCall()` 会在活跃轮次信号下校验适配器持有的字段,并解析推理强度和输出 token 默认值。循环会在解析、`request/header` 记录与分派期间保留同一个适配器。循环会为首次请求、变化的 envelope、显式消息序列起点、表层替换后的请求及恢复写入完整 header;同一序列内内容未变的步骤、重试与普通后续轮次继承最新 header。下一次 waterfall 前,循环移除适配器默认字段,使当前路由重新解析它们;显式设置则保留。未处理的路由仍以 `NO_ADAPTER` 失败。
 
 ### 源码地图
 

+ 1 - 6
packages/core/agent-loop/src/agent.ts

@@ -505,12 +505,7 @@ export class ReactLoopAgent implements Agent {
     const startsSeries = startsRequestSeries
       || this.requestSurfaceGeneration !== surfaceGeneration
     if (!this.requestHeaderLogged) {
-      const reason = baseline === undefined ? 'initial' : 'resume'
-      this.session.append('request/header', {
-        header,
-        reason,
-        ...reason === 'resume' && startsRequestSeries ? { startsSeries: true } : {},
-      })
+      this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
       this.requestHeaderLogged = true
     } else if (baseline === undefined || !headerEquals(baseline, header)) {
       this.session.append('request/header', {

+ 0 - 32
packages/core/agent-loop/tests/request-reconstruction.spec.ts

@@ -660,43 +660,11 @@ describe('request stability across the loop', () => {
     const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header')
     expect(snapshots).toHaveLength(2)
     expect(snapshots[1]?.data.reason).toBe('resume')
-    expect(snapshots[1]?.data.startsSeries).toBeUndefined()
     // Identical header across the restart: byte-identical continuation.
     expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system)
     expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!)
   })
 
-  it('retains an explicit series boundary on the first request of a resumed loop', async () => {
-    const adapter = new MockAdapter([textResponse('one')])
-    const ctx = await harness(adapter)
-    const agent = await ctx.agentLoop.create(SessionId('series-gen1'), { provider: 'mock', model: 'mock' })
-    send(agent, 'first')
-    await waitForIdle(ctx, agent)
-
-    const adapter2 = new MockAdapter([textResponse('two')])
-    const ctx2 = await harness(adapter2)
-    ctx2.on('agent/pre-step', async (_payload, next) => {
-      const decision = await next()
-      return decision.kind === 'enter'
-        ? { ...decision, startsRequestSeries: true }
-        : decision
-    })
-    const handle = await ctx2.agents.create({
-      sessionId: SessionId('series-gen2'),
-      seed: agent.session.snapshotEvents(),
-      agentOptions: { provider: 'mock', model: 'mock' },
-    })
-    send(handle.agent, 'second series')
-    await waitForIdle(ctx2, handle.agent)
-
-    expect(handle.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header'
-      ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }]
-      : [])).toEqual([
-      { reason: 'initial', startsSeries: undefined },
-      { reason: 'resume', startsSeries: true },
-    ])
-  })
-
   it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => {
     const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
     const ctx = await harness(adapter)

+ 2 - 2
packages/core/session/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: 96eed6773c248266429666cdc065e68f0f9efb09
-README.zh.md: 1faa6bf713c8a9f16e8dc85d50cdff33657e6a96
+README.md: f5cf910854203021a619cc786dfd13705927ffc1
+README.zh.md: 385d7fd63a6e4dec9c23c9d38a352942d7dbc7f9

+ 1 - 1
packages/core/session/README.md

@@ -81,7 +81,7 @@ The package is built on event sourcing: a `Session` is an append-only log of typ
 
 ### Request headers
 
-`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. A loop instance's first request over existing history uses `resume` and carries `startsSeries: true` when its admitted step explicitly begins a distinct message series. A later explicit message-series start or surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail.
+`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail.
 
 ### Source map
 

+ 1 - 1
packages/core/session/README.zh.md

@@ -81,7 +81,7 @@ session.deriveMessages()         // the derived model history
 
 ### 请求 header
 
-`request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。循环实例基于既有历史发出的首次请求使用 `resume`;其已接纳步骤显式开启独立消息序列时,快照携带 `startsSeries: true`。后续显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。
+`request/header` 存储非历史请求 envelope 的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在 envelope 不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。
 
 ### 源码地图
 

+ 6 - 6
packages/core/session/src/types.ts

@@ -244,11 +244,11 @@ export interface RequestContext {
 
 /**
  * Why a `request/header` snapshot was appended: `'initial'` — the log's first
- * header (a new conversation); `'resume'` — a loop instance's first request over
- * existing header history (process restart, fork seed), with `startsSeries`
- * preserving an explicit series boundary; `'change'` — a later request used a
- * different header, with `startsSeries` preserving a coincident series boundary;
- * `'series'` — an unchanged header began an explicitly distinct message series or followed a surface replacement.
+ * header (a new conversation); `'resume'` — a loop instance's first request
+ * over a log that already has header events (process restart, fork seed);
+ * `'change'` — a later request used a different header, with `startsSeries`
+ * preserving a coincident series boundary; `'series'` — an unchanged header
+ * began an explicitly distinct message series or followed a surface replacement.
  */
 export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'
 
@@ -331,7 +331,7 @@ export interface SessionEventMap {
   'request/header': {
     header: EpochHeader
     reason: RequestHeaderReason
-    /** A `change` or `resume` snapshot also begins a distinct model-message series. */
+    /** A changed header also begins a distinct model-message series. */
     startsSeries?: true
   }
   /**