Jelajahi Sumber

fix(snapshot): preserve unexpected request header fields

Remove both unconditional header.system erasures from log normalization and replay comparison encoding, including the unrelated Session-header deletion. The catalog owns released-format migration; comparison must not turn an unexpected field into equality or supply a compatibility shim.

Add direct log/snapshot and catalog-restoration controls that retain unexpected request/header.system and differ from the field-absent fixture. Preserve malformed provenance data as well, closing the owned normalizer coverage gap. Update the two package README pairs with this oracle obligation.

Evidence: pnpm exec vitest run packages/test-support/session-snapshot/tests/normalize.spec.ts packages/test-support/llm-replay/tests/llm-replay.spec.ts --coverage --coverage.include=packages/test-support/session-snapshot/src/normalize.ts --coverage.include=packages/test-support/llm-replay/src/index.ts: 205 tests pass, both files 100% statements/branches/functions/lines. Direct normalizer negative control fails before the fix. Baseline diff3 comment remains untouched for the separate cleanup finding.
Tianyi Cui 3 minggu lalu
induk
melakukan
be53ea7dff

+ 2 - 2
packages/test-support/llm-replay/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/test-support/llm-replay/README.md
-README.md: e8a257c2ee9f95f1e7d20197e6db67b2b4c7b45c
-README.zh.md: 3b91009af945bb4fd1f750d39c74936308d15374
+README.md: 2a9e202410ebfd98083f7d21b7d3531b2c4bd0a8
+README.zh.md: c93dff322f6a2708a921f900d39333149cc9d804

+ 1 - 1
packages/test-support/llm-replay/README.md

@@ -65,7 +65,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### How the fixture works
 
-The fixture is a projection of one selected persisted Session generation produced by running the real agent once — this plugin does not record. The snapshot harness supplies the numerically highest canonical parent path (`<scenario>/session.jsonl` for v0 or `<scenario>/session.vN.jsonl` for a positive generation), and validates filename/header agreement before replay. The fixture keeps the header and every event payload but omits body `seq`/`time` envelopes (`seq0`/`time0` for historical packed rows). Replay supplies contiguous sequences and deterministic timestamps, restores typed values replaced by snapshot tokens, rejects partial or mixed envelopes, decodes the complete physical artifact through the build-static Session format catalog, and migrates historical input in memory before it exposes events or the inherited cut; current input takes direct restoration. For a projected v0 header only, an absent `delegationDepth` denotes `0`. The parser never rewrites or renames the fixture. Runtime persistence continues to write complete logs. Replay expands the compact stream on each current-view `assistant/message` or `assistant/attempt`, so a recorded fixture replays the same logical stream the live model produced. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}`; replay materializes validation-only values, while derivation reads only Assistant settlements, marked summary events, and Session metadata. Every replay and comparison fixture must pass the same content-only catalog validation; replay never repairs a refused artifact.
+The fixture is a projection of one selected persisted Session generation produced by running the real agent once — this plugin does not record. The snapshot harness supplies the numerically highest canonical parent path (`<scenario>/session.jsonl` for v0 or `<scenario>/session.vN.jsonl` for a positive generation), and validates filename/header agreement before replay. The fixture keeps the header and every event payload but omits body `seq`/`time` envelopes (`seq0`/`time0` for historical packed rows). Replay supplies contiguous sequences and deterministic timestamps, restores typed values replaced by snapshot tokens, rejects partial or mixed envelopes, decodes the complete physical artifact through the build-static Session format catalog, and migrates historical input in memory before it exposes events or the inherited cut; current input takes direct restoration. For a projected v0 header only, an absent `delegationDepth` denotes `0`. The parser never rewrites or renames the fixture. Runtime persistence continues to write complete logs. Replay expands the compact stream on each current-view `assistant/message` or `assistant/attempt`, so a recorded fixture replays the same logical stream the live model produced. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}`; replay materializes validation-only values, while derivation reads only Assistant settlements, marked summary events, and Session metadata. Every replay and comparison fixture must pass the same content-only catalog validation; replay never repairs a refused artifact. Comparison encoding preserves the catalog output, including unexpected request-header fields.
 
 ### Nested agents
 

+ 1 - 1
packages/test-support/llm-replay/README.zh.md

@@ -65,7 +65,7 @@ kind: "package-reference"
 
 ### fixture 的工作方式
 
-fixture 是运行一次真实 agent 所产生的一份选定持久化 Session generation 投影,本插件不录制。snapshot harness 会提供数值最高的规范 parent 路径(v0 为 `<scenario>/session.jsonl`,正 generation 为 `<scenario>/session.vN.jsonl`),并在 replay 前校验文件名与 header 一致。fixture 保留 header 与每个事件 payload,但省略正文的 `seq`/`time` envelope(历史 packed row 使用 `seq0`/`time0`)。replay 补充连续序号与确定性 timestamp,恢复被 snapshot token 替换的类型化值,拒绝不完整或混合 envelope,通过构建期静态 Session 格式 catalog 解码完整物理产物,并在公开事件或继承 cut 前于内存中迁移历史输入;当前输入直接 restore。仅对投影 v0 header,缺失的 `delegationDepth` 表示 `0`。parser 从不重写或重命名 fixture。runtime persistence 继续写入完整日志。replay 会展开当前视图中每个 `assistant/message` 或 `assistant/attempt` 的紧凑 stream,因此已记录 fixture 会 replay 与在线模型产生的相同逻辑流。fixture 的 `request/header` 内容可能 token 化为 `{{system}}`/`{{tools}}`;replay 会物化仅用于校验的值,而派生只读取 Assistant settlement、带标记的 summary 事件与 Session metadata。每个 replay 与 comparison fixture 都必须通过同一个只基于内容的 catalog 校验;replay 绝不修复被拒绝的产物。
+fixture 是运行一次真实 agent 所产生的一份选定持久化 Session generation 投影,本插件不录制。snapshot harness 会提供数值最高的规范 parent 路径(v0 为 `<scenario>/session.jsonl`,正 generation 为 `<scenario>/session.vN.jsonl`),并在 replay 前校验文件名与 header 一致。fixture 保留 header 与每个事件 payload,但省略正文的 `seq`/`time` envelope(历史 packed row 使用 `seq0`/`time0`)。replay 补充连续序号与确定性 timestamp,恢复被 snapshot token 替换的类型化值,拒绝不完整或混合 envelope,通过构建期静态 Session 格式 catalog 解码完整物理产物,并在公开事件或继承 cut 前于内存中迁移历史输入;当前输入直接 restore。仅对投影 v0 header,缺失的 `delegationDepth` 表示 `0`。parser 从不重写或重命名 fixture。runtime persistence 继续写入完整日志。replay 会展开当前视图中每个 `assistant/message` 或 `assistant/attempt` 的紧凑 stream,因此已记录 fixture 会 replay 与在线模型产生的相同逻辑流。fixture 的 `request/header` 内容可能 token 化为 `{{system}}`/`{{tools}}`;replay 会物化仅用于校验的值,而派生只读取 Assistant settlement、带标记的 summary 事件与 Session metadata。每个 replay 与 comparison fixture 都必须通过同一个只基于内容的 catalog 校验;replay 绝不修复被拒绝的产物。比较编码保留 catalog 输出,包括意外的 request-header 字段。
 
 ### 嵌套 agent
 

+ 0 - 169
packages/test-support/llm-replay/src/index.ts

@@ -1173,172 +1173,3 @@ function providerAccepted(entry: ReplayEntry): boolean {
  * non-empty provider catalog registers a routed replay adapter; otherwise a
  * catch-all waterfall intercepts requests.
  *
- * @param ctx - the context whose LLM service receives the replay route or waterfall.
- * @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
- * @returns the {@link ReplayHandle} carrying the disposer and the teardown consumption check.
- */
-export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHandle {
-  const paceMs = config.paceMs ?? 0
-  if (!Number.isInteger(paceMs) || paceMs < 0) {
-    throw new Error(`llm-replay: paceMs must be a non-negative integer, got ${String(config.paceMs)}`)
-  }
-  const scripts = loadSessionScripts(config)
-  // Live-session → its bound script + cursor. A new live session id claims the
-  // next not-yet-bound script (scripts are in bind order); `nextScript` is the
-  // index of the next unclaimed one.
-  const bound = new Map<string, { entries: ReplayEntry[]; cursor: number }>()
-  const liveSessionIds: (string | undefined)[] = Array.from({ length: scripts.length })
-  let nextScript = 0
-  const ANON = '\0anon\0' // the key for a call that carries no sessionId
-  const replay = (options: GenerateOptions): AsyncIterable<StreamChunk> => {
-    const key = options.sessionId ?? ANON
-    let state = bound.get(key)
-    let unrecorded = false
-    if (state === undefined) {
-      const script = scripts[nextScript]
-      if (script === undefined) {
-        // More distinct live sessions made calls than the scenario recorded —
-        // an unrecorded subagent appeared. Defer the throw into the returned
-        // generator (the listener must return an AsyncIterable, not throw).
-        unrecorded = true
-        state = { entries: [], cursor: 0 }
-      } else {
-        const scriptIndex = nextScript
-        nextScript++
-        state = { entries: script.entries, cursor: 0 }
-        bound.set(key, state)
-        if (key !== ANON) liveSessionIds[scriptIndex] = key
-      }
-    }
-    const boundState = state
-    const seenSessions = nextScript
-    const totalScripts = scripts.length
-    const index = boundState.cursor++
-    const entry: ReplayEntry | undefined = boundState.entries[index]
-    return (async function* () {
-      if (unrecorded) {
-        throw new Error(
-          `llm-replay: a model call arrived from an unrecorded session (#${seenSessions + 1}); `
-          + `the scenario recorded only ${totalScripts} session(s) — re-record it`,
-        )
-      }
-      if (entry === undefined) {
-        throw new Error(
-          `llm-replay: script exhausted — session requested model call #${index + 1} `
-          + `but its script has only ${boundState.entries.length}; re-record the scenario`,
-        )
-      }
-      inferStartedSubagents(options.messages, liveSessionIds)
-      const resolved = resolveScriptedEntry(materializeSessionTokens(entry, liveSessionIds), options.messages)
-      if (options.provider === 'deepseek-official' && providerAccepted(resolved)) {
-        const extensions = ctx.get('deepseekLlmApiExtensions')
-        if (extensions !== undefined) {
-          const signal = options.signal ?? new AbortController().signal
-          const prepared = await extensions.prepare({
-            // Replay reproduces post-2xx side effects, not the provider wire body.
-            body: { messages: [] },
-            signal,
-            ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
-            ...options.purpose === undefined ? {} : { purpose: options.purpose },
-          })
-          await prepared.accept()
-        }
-      }
-      yield* replayEntry(resolved, options.signal, paceMs)
-    })()
-  }
-  const providers = config.providers ?? []
-  const dispose = providers.length > 0
-    ? ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
-    : ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
-  return {
-    dispose,
-    assertConsumed(): void {
-      const problems: string[] = []
-      if (nextScript < scripts.length) {
-        problems.push(`${scripts.length - nextScript} recorded script(s) never bound to a live session`)
-      }
-      for (const [key, state] of bound) {
-        if (state.cursor < state.entries.length) {
-          const who = key === ANON ? 'the anonymous session' : `session ${key}`
-          problems.push(`${who} consumed ${state.cursor}/${state.entries.length} recorded call(s)`)
-        }
-      }
-      if (problems.length > 0) {
-        throw new Error(`llm-replay: fixture not fully consumed — ${problems.join('; ')}; the scenario drove fewer model calls than recorded`)
-      }
-    },
-  }
-}
-
-export const name = 'llm-replay'
-export const inject = ['llm']
-
-/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */
-export interface Config {
-  /** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */
-  file?: string
-  /** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */
-  overrideFile?: string
-  /**
-   * Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a
-   * path-separator-delimited list). Each is a recorded subagent session log for
-   * a nested-agent scenario; absent/empty for a single-session scenario.
-   */
-  childFiles?: string[]
-  /** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */
-  providers?: ReplayProviderConfig[]
-  /** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */
-  paceMs?: number
-}
-
-function validateConfiguredModels(providers: ReplayProviderConfig[] | undefined): void {
-  for (const provider of providers ?? []) {
-    for (const model of provider.models ?? []) {
-      const modalities: unknown = model.inputModalities
-      if (modalities !== undefined && (!Array.isArray(modalities)
-        || !modalities.every((modality: unknown) => modality === 'text' || modality === 'image'))) {
-        throw new Error(
-          `llm-replay: provider "${provider.id}" model "${model.id}" inputModalities `
-          + 'must be an array containing only "text" and "image"',
-        )
-      }
-      const imageRequestTokens: unknown = model.imageRequestTokens
-      if (imageRequestTokens !== undefined
-        && (!Number.isSafeInteger(imageRequestTokens) || (imageRequestTokens as number) <= 0)) {
-        throw new Error(
-          `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestTokens `
-          + 'must be a positive safe integer',
-        )
-      }
-      // A text-only route never sends visual tokens: LlmRuntime substitutes
-      // its images with deterministic text before dispatch, so declared
-      // visual pricing would contradict the actual request projection.
-      if (imageRequestTokens !== undefined && model.inputModalities?.includes('image') !== true) {
-        throw new Error(
-          `llm-replay: provider "${provider.id}" model "${model.id}" imageRequestTokens `
-          + 'requires inputModalities to include "image"',
-        )
-      }
-    }
-  }
-}
-
-export function apply(ctx: Context, config: Config = {}): void {
-  const file = config.file ?? process.env.DSH_SNAPSHOT_FILE
-  if (file === undefined || file.length === 0) {
-    throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)')
-  }
-  validateConfiguredModels(config.providers)
-  const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE
-  const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES
-  const childFiles = config.childFiles
-    ?? (childEnv !== undefined && childEnv.length > 0 ? childEnv.split(pathDelimiter) : [])
-  installLlmReplay(ctx, {
-    file,
-    ...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
-    ...childFiles.length > 0 ? { childFiles } : {},
-    ...config.providers !== undefined ? { providers: config.providers } : {},
-    ...config.paceMs !== undefined ? { paceMs: config.paceMs } : {},
-  })
-}

+ 16 - 0
packages/test-support/llm-replay/tests/llm-replay.spec.ts

@@ -693,6 +693,22 @@ describe('parseSessionLog', () => {
 })
 
 describe('prepareSessionSnapshotFixtureForComparison', () => {
+  it('keeps unexpected request-header fields observable after current-format restoration', () => {
+    const source = projectSessionJsonl(replaySessionJsonl([TEXT_CHUNKS]))
+    const lines = source.trimEnd().split('\n')
+    const request = {
+      type: 'request/header',
+      data: { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, system: 'unexpected prompt' } },
+    }
+    lines.splice(3, 0, JSON.stringify(request))
+    const prepared = prepareSessionSnapshotFixtureForComparison(`${lines.join('\n')}\n`)
+    const restored = prepared.trimEnd().split('\n').map(line => JSON.parse(line))
+    expect(restored.find(record => record.type === 'request/header')).toMatchObject(request)
+    delete (request.data.header as Record<string, unknown>).system
+    lines[3] = JSON.stringify(request)
+    expect(prepared).not.toEqual(prepareSessionSnapshotFixtureForComparison(`${lines.join('\n')}\n`))
+  })
+
   it('encodes a migrated fixture without inventing a trailing newline and retains its cwd token', () => {
     const projected = projectSessionJsonl(replaySessionJsonl([TEXT_CHUNKS])).trimEnd()
     const [headerLine, ...bodyLines] = projected.split('\n')

+ 2 - 2
packages/test-support/session-snapshot/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/test-support/session-snapshot/README.md
-README.md: 94d728eaf36454fcffc0d77e737c3ea4eaa14b03
-README.zh.md: 7099ab7831695bd9569067976b3053fb6a508469
+README.md: 8deaa4c9854094b8a7a6517c41de52309c2e9611
+README.zh.md: 1494be346c272fcafc07bc3df5277253a3b00362

+ 1 - 1
packages/test-support/session-snapshot/README.md

@@ -70,7 +70,7 @@ defineAcpSnapshotSuite({
 
 Each recorded-session directory carries a closed `snapshot.yml` manifest plus canonical parent and contiguous child roles. Parent filenames are `session[.vN].jsonl`; children are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A role may retain older generations, but the harness selects the numerically highest one. An owning manifest may declare `sessionFormat.version` plus one or more closed `coverage` names to keep that historical generation as an explicit migration fixture; absence tracks the current writer. The manifest also names the scenario, shipped profile, composition/header class, recording source, and only the replay, platform, permission, environment, workspace, or input facts the completed Session cannot reconstruct. The adapter registers expected-output, Session-log, and optional `workspace.expected/` comparisons; guards reject orphan directories, missing roles, noncanonical names, absolute paths, malformed manifests, and platform-specific separators.
 
-`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison restores every selected persisted or projected fixture through the current build-static Session format catalog before identity redaction and normalization, so retained v0/v1/v2 replay input and fresh `session.v3.jsonl` writer output compare as one v3 logical Session without rewriting or renaming historical files. Expected and harvested logs use the same strict restoration path; source filenames cannot alter format validation. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v3 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion.
+`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison restores every selected persisted or projected fixture through the current build-static Session format catalog before identity redaction and normalization, so retained v0/v1/v2 replay input and fresh `session.v3.jsonl` writer output compare as one v3 logical Session without rewriting or renaming historical files. Expected and harvested logs use the same strict restoration path; source filenames cannot alter format validation. Normalization preserves unexpected request-header fields, including `system`, so regressions remain visible. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v3 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion.
 
 Known snapshot spill paths normalize to stable locator tokens, including paths quoted inside JSON omission notices with JSON-escaped Windows separators. Refresh extraction preserves the matched serialized path spelling for literal replacement. Normalization changes only the locator: saved byte lengths and omission counts remain comparison evidence.
 

+ 1 - 1
packages/test-support/session-snapshot/README.zh.md

@@ -70,7 +70,7 @@ defineAcpSnapshotSuite({
 
 每个已记录 Session 目录携带封闭的 `snapshot.yml` manifest,以及规范 parent 与连续 child 角色。parent 文件名是 `session[.vN].jsonl`;child 是 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本使用小写 `.vN`,且每个文件名与其 header 一致。一个角色可以保留旧 generation,但 harness 会选择数值最高的一项。拥有 fixture 的 manifest 可以声明 `sessionFormat.version` 与一个或多个封闭 `coverage` 名称,把该历史 generation 保留为显式迁移 fixture;省略此字段时跟随当前 writer。manifest 还会指名场景、随附 profile、组合/header 类别、录制来源,以及已完成 Session 无法重建的 replay、平台、权限、环境、workspace 或输入事实。适配器注册预期输出、Session 日志与可选 `workspace.expected/` 比较;保护会拒绝遗留目录、缺失角色、非规范名称、绝对路径、malformed manifest 与平台专用分隔符。
 
-`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过当前构建期静态 Session 格式目录恢复每个选定的持久化或投影 fixture,再进行身份脱敏与规范化,因此保留的 v0/v1/v2 replay 输入与新生成的 `session.v3.jsonl` writer 输出会作为同一个 v3 logical Session 比较,且不会重写或重命名历史文件。预期日志与收集日志使用同一条严格恢复路径;来源文件名不能改变格式校验。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v3 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。
+`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过当前构建期静态 Session 格式目录恢复每个选定的持久化或投影 fixture,再进行身份脱敏与规范化,因此保留的 v0/v1/v2 replay 输入与新生成的 `session.v3.jsonl` writer 输出会作为同一个 v3 logical Session 比较,且不会重写或重命名历史文件。预期日志与收集日志使用同一条严格恢复路径;来源文件名不能改变格式校验。归一化保留意外的 request-header 字段(包括 `system`),使回归保持可见。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v3 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。
 
 已知的快照 spill 路径会规范化为稳定的定位信息 token,包括 JSON 省略通知中带引号、使用 JSON 转义 Windows 分隔符的路径。刷新提取会保留匹配路径的序列化写法,以便进行字面替换。规范化只改变定位信息:保存字节数与省略计数仍作为比较证据。
 

+ 0 - 13
packages/test-support/session-snapshot/src/normalize.ts

@@ -384,19 +384,6 @@ export function normalizeSessionLog(
     if (Object.hasOwn(record, 'sourceEventSeqs')) {
       record.sourceEventSeqs = decodeSeqRanges(record.sourceEventSeqs)
     }
-    // The system prompt is a surface `system/message` node in the current
-    // vocabulary; retired generations also carry the removed `system` header
-    // member. Drop it so both sides of a comparison normalize identically.
-    if (record.type === 'request/header' && record.data !== null && typeof record.data === 'object') {
-      const data = record.data as Record<string, unknown>
-      const header = data.header
-      if (header !== null && typeof header === 'object' && !Array.isArray(header)
-        && 'system' in (header as Record<string, unknown>)) {
-        const copy = { ...header as Record<string, unknown> }
-        delete copy['system']
-        data.header = copy
-      }
-    }
     return scrubValue(record, ctx, cwdPathMode, identityMode) as Record<string, unknown>
   })
   return records.map(r => JSON.stringify(r)).join('\n') + '\n'

+ 20 - 0
packages/test-support/session-snapshot/tests/normalize.spec.ts

@@ -265,6 +265,18 @@ describe('normalizeSessionLog', () => {
   const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
   const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
 
+  it('keeps unexpected request-header fields observable in comparisons', () => {
+    const request = (system: boolean) => event({
+      type: 'request/header',
+      data: { header: { config: { model: 'mock' }, ...(system ? { system: 'unexpected prompt' } : {}) } },
+    })
+    for (const normalize of [normalizeSessionLog, normalizeSessionSnapshot]) {
+      const actual = normalize(`${header({})}\n${request(true)}\n`, ctx)
+      expect(actual).toContain('"system":"unexpected prompt"')
+      expect(actual).not.toEqual(normalize(`${header({})}\n${request(false)}\n`, ctx))
+    }
+  })
+
   it('zeroes the header createdAt', () => {
     const out = normalizeSessionLog(`${header({})}\n`, ctx)
     expect(out).toContain('"createdAt":0')
@@ -791,6 +803,14 @@ describe('normalizeSessionSnapshot', () => {
     expect(opaqueEvent?.data).toEqual({ capturedFormatVersion: 5, sessionFormatVersion: 4 })
   })
 
+  it('preserves an unexpected session-reference payload instead of omitting its fields', () => {
+    const raw = JSON.stringify({
+      type: 'user/message',
+      data: { source: { kind: 'session-reference', form: 'recall', version: 1, references: {} } },
+    })
+    expect(normalizeSessionFormatProvenance(raw)).toBe(raw)
+  })
+
   it('keeps session-reference lookalikes outside Message source positions unchanged', () => {
     const lookalike = [
       JSON.stringify({ type: 'session', version: 1, id: 's', createdAt: 0, delegationDepth: 0 }),