Przeglądaj źródła

test: compare current notification outputs without legacy migration shim

Tianyi Cui 1 miesiąc temu
rodzic
commit
ab74a93ed3

+ 2 - 3
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -11,7 +11,6 @@ import {
   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))
 }
 

+ 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: 43ad5b28e55550658a83f5e517516cce24cae740
-README.zh.md: 0f7cfefea8b37ae7cd9150f023c898b3f1ae5501
+README.md: 8ae2354929e6b3eae8106032319f0bb8d4eb92cc
+README.zh.md: 1a0625fb7c0b0a80eb81552cac1345577dcf66da

+ 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. Comparison encoding preserves the catalog output, including unexpected request-header fields.
+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 accepted catalog output, including extension request-header fields; current `header.system` is rejected. Wire-notification expected outputs compare directly with current-writer output, retaining event order, inserted system messages, wrapper fields, and opaque delivery and captured-generation values; only complete Session artifacts use the format migration catalog.
 
 ### 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 绝不修复被拒绝的产物。比较编码保留 catalog 输出,包括意外的 request-header 字段。
+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 字段;当前版本的 `header.system` 会被拒绝。协议通知的预期输出直接与当前写入器输出比较,保留事件顺序、插入的 system 消息、封装字段,以及不透明的交付和捕获代际值;只有完整 Session 产物使用格式迁移 catalog。
 
 ### 嵌套 agent
 

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

@@ -304,193 +304,6 @@ export function prepareSessionSnapshotFixtureForComparison(text: string): string
   return encodeCurrentSessionSnapshotFixture(text, parsed)
 }
 
-interface WrappedSessionEvent {
-  readonly sessionId: string
-  readonly event: Record<string, unknown>
-  readonly replace: (event: Readonly<Record<string, unknown>>) => Record<string, unknown>
-}
-
-/** Read one headless or SDK event-notification wrapper. */
-function wrappedSessionEvent(row: Record<string, unknown>): WrappedSessionEvent | undefined {
-  if (row['type'] === 'session_event' && typeof row['sessionId'] === 'string'
-    && row['event'] !== null && typeof row['event'] === 'object' && !Array.isArray(row['event'])) {
-    return {
-      sessionId: row['sessionId'],
-      event: row['event'] as Record<string, unknown>,
-      replace: event => ({ ...row, event }),
-    }
-  }
-  const params = row['params']
-  if (row['method'] !== 'session.event' || params === null || typeof params !== 'object' || Array.isArray(params)) {
-    return undefined
-  }
-  const record = params as Record<string, unknown>
-  if (typeof record['sessionId'] !== 'string'
-    || record['event'] === null || typeof record['event'] !== 'object' || Array.isArray(record['event'])) {
-    return undefined
-  }
-  return {
-    sessionId: record['sessionId'],
-    event: record['event'] as Record<string, unknown>,
-    replace: event => ({ ...row, params: { ...record, event } }),
-  }
-}
-
-interface WrappedEventEntry extends WrappedSessionEvent {
-  readonly rowIndex: number
-}
-
-/** Append one migrated event to the output assigned to its v1 source row. */
-function assignMigratedEvent(
-  assigned: Map<number, Readonly<Record<string, unknown>>[]>,
-  rowIndex: number,
-  event: Readonly<Record<string, unknown>>,
-): void {
-  assigned.set(rowIndex, [...assigned.get(rowIndex) ?? [], event])
-}
-
-/** Restore fixture tokens materialized only to satisfy released-format validation. */
-function restoreProjectedRequestHeader(
-  target: Readonly<Record<string, unknown>>,
-  source: Readonly<Record<string, unknown>>,
-): Readonly<Record<string, unknown>> {
-  if (target['type'] !== 'request/header' || source['type'] !== 'request/header') return target
-  const targetData = target['data'] as Record<string, unknown>
-  const sourceData = source['data'] as Record<string, unknown>
-  const targetHeader = targetData['header'] as Record<string, unknown>
-  const sourceHeader = sourceData['header'] as Record<string, unknown>
-  const sourceTools = sourceHeader['tools']
-  if (sourceTools !== '{{tools}}') return target
-  return {
-    ...target,
-    data: {
-      ...targetData,
-      header: { ...targetHeader, tools: sourceTools },
-    },
-  }
-}
-
-/** Omit delivery cursors whose numeric value depends on the source Session generation. */
-function normalizeWrappedEventProvenance(
-  event: Readonly<Record<string, unknown>>,
-): Readonly<Record<string, unknown>> {
-  if (event['type'] !== 'session-log-deepseek/delivery-accepted') return event
-  const data = event['data']
-  if (data === null || typeof data !== 'object' || Array.isArray(data)) return event
-  const normalized = { ...data as Record<string, unknown> }
-  delete normalized['sessionFormatVersion']
-  delete normalized['throughSeq']
-  return { ...event, data: normalized }
-}
-
-type WrappedEventGroup = [WrappedEventEntry, ...WrappedEventEntry[]]
-
-/** Migrate one session's contiguous v1 notification tail and align its surviving rows. */
-function migrateWrappedEventGroup(
-  entries: Readonly<WrappedEventGroup>,
-): Map<number, Readonly<Record<string, unknown>>[]> {
-  const first = entries[0]
-  if (!entries.some(entry => entry.event['type'] === 'assistant/chunk')) return new Map()
-  const firstSeq = first.event['seq']
-  if (!Number.isSafeInteger(firstSeq) || (firstSeq as number) < 0) {
-    throw new Error('session event comparison requires a non-negative first seq')
-  }
-  for (const [index, entry] of entries.entries()) {
-    if (entry.event['seq'] !== (firstSeq as number) + index || typeof entry.event['type'] !== 'string') {
-      throw new Error(`session event comparison requires a contiguous event tail for ${first.sessionId}`)
-    }
-  }
-  const prefix = Array.from({ length: firstSeq as number }, (_, seq) => ({
-    type: 'feedback/record',
-    seq,
-    time: 0,
-    data: { text: `comparison prefix ${String(seq)}` },
-  }))
-  const restore = sessionFormatCatalog.createRestore({
-    type: 'session',
-    version: 1,
-    id: first.sessionId,
-    createdAt: 0,
-    delegationDepth: 0,
-  }, { recovery: 'strict', validation: 'current' })
-  for (const event of prefix) restore.decodeRow(event)
-  for (const entry of entries) restore.decodeRow(normalizeProjectedRow(entry.event))
-  const migrated = restore.finish().events.slice(prefix.length) as readonly Readonly<Record<string, unknown>>[]
-
-  const assigned = new Map<number, Readonly<Record<string, unknown>>[]>()
-  let migratedIndex = 0
-  let lastChunkRow: number | undefined
-  // Catalog validation preserves every non-chunk event's type and order; only a preceding chunk row yields assistant/attempt.
-  for (const entry of entries) {
-    if (entry.event['type'] === 'assistant/chunk') {
-      lastChunkRow = entry.rowIndex
-      continue
-    }
-    while (migrated[migratedIndex]?.['type'] === 'assistant/attempt') {
-      assignMigratedEvent(assigned, lastChunkRow as number, migrated[migratedIndex] as Record<string, unknown>)
-      migratedIndex += 1
-    }
-    const next = migrated[migratedIndex] as Readonly<Record<string, unknown>>
-    assignMigratedEvent(assigned, entry.rowIndex, restoreProjectedRequestHeader(next, entry.event))
-    migratedIndex += 1
-  }
-  while (migrated[migratedIndex]?.['type'] === 'assistant/attempt') {
-    assignMigratedEvent(assigned, lastChunkRow as number, migrated[migratedIndex] as Record<string, unknown>)
-    migratedIndex += 1
-  }
-  return assigned
-}
-
-/**
- * Project v1 session-event notifications to current settlement cardinality in memory.
- * Non-event protocol rows retain their exact positions, and current v3 input passes through.
- * @param text - headless `session_event` or SDK `session.event` JSONL.
- * @returns comparison JSONL using current Session events without modifying its source file.
- */
-export function prepareSessionEventNotificationsForComparison(text: string): string {
-  const trailingNewline = text.endsWith('\n')
-  const rows = text.split('\n').filter(line => line.trim().length > 0).map((line, index) => {
-    const value: unknown = JSON.parse(line)
-    if (value === null || typeof value !== 'object' || Array.isArray(value)) {
-      throw new Error(`session event comparison line ${String(index + 1)} must be an object`)
-    }
-    return value as Record<string, unknown>
-  })
-  const entries = rows.flatMap((row, rowIndex): WrappedEventEntry[] => {
-    const wrapped = wrappedSessionEvent(row)
-    return wrapped === undefined ? [] : [{ ...wrapped, rowIndex }]
-  })
-  const groups: WrappedEventGroup[] = []
-  for (const entry of entries) {
-    const nextSeq = entry.event['seq']
-    const group = groups.findLast((candidate) => {
-      const previous = candidate.at(-1)
-      const previousSeq = previous?.event['seq']
-      return previous?.sessionId === entry.sessionId
-        && Number.isSafeInteger(previousSeq) && Number.isSafeInteger(nextSeq)
-        && nextSeq === (previousSeq as number) + 1
-    })
-    if (group === undefined) groups.push([entry])
-    else group.push(entry)
-  }
-  const assigned = new Map<number, Readonly<Record<string, unknown>>[]>()
-  for (const group of groups) {
-    for (const [rowIndex, events] of migrateWrappedEventGroup(group)) assigned.set(rowIndex, events)
-  }
-  const output = rows.flatMap((row, rowIndex) => {
-    const wrapped = wrappedSessionEvent(row)
-    if (wrapped === undefined) return [row]
-    const migrated = assigned.get(rowIndex)
-    if (migrated === undefined) {
-      return wrapped.event['type'] === 'assistant/chunk'
-        ? []
-        : [wrapped.replace(normalizeWrappedEventProvenance(wrapped.event))]
-    }
-    return migrated.map(event => wrapped.replace(normalizeWrappedEventProvenance(event)))
-  }).map(row => JSON.stringify(row)).join('\n')
-  return trailingNewline ? `${output}\n` : output
-}
-
 /** Encode one migrated fixture while retaining a projected cwd token. */
 function encodeCurrentSessionSnapshotFixture(text: string, parsed: ParsedSessionFixture): string {
   const header = {

+ 104 - 252
packages/test-support/llm-replay/tests/llm-replay.spec.ts

@@ -31,7 +31,6 @@ import {
   name,
   parseSessionHeader,
   parseSessionLog,
-  prepareSessionEventNotificationsForComparison,
   prepareSessionSnapshotFixtureForComparison,
   resolveScriptedEntry,
 } from '../src/index.ts'
@@ -399,6 +398,23 @@ describe('fixture format diagnostics', () => {
 })
 
 describe('parseSessionLog', () => {
+  const emptySystemHead = {
+    type: 'system/message',
+    seq: 2,
+    time: 0,
+    data: {
+      turn: 1,
+      step: 1,
+      message: {
+        id: expect.stringMatching(/^v2-to-v3-system-/) as unknown,
+        role: 'system',
+        source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
+        content: [],
+      },
+    },
+    surfaceOp: 'append',
+  }
+
   it('reports invalid JSON at its physical source line', () => {
     const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
 
@@ -419,45 +435,57 @@ describe('parseSessionLog', () => {
 
   it('expands range-encoded source provenance', () => {
     const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
-    const events = Array.from({ length: 5 }, (_, seq) => ({
-      type: 'user/message',
-      seq,
-      time: 0,
-      data: { role: 'user', id: `message-${seq}`, content: [], source: { kind: 'user' } },
-      surfaceOp: 'append',
-      ...(seq === 4 ? { sourceEventSeqs: [[0, 2], 3] } : {}),
-    }))
+    const events = [
+      { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
+      { type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } },
+      ...Array.from({ length: 5 }, (_, index) => ({
+        type: 'user/message',
+        seq: index + 2,
+        time: 0,
+        data: { role: 'user', id: `message-${index}`, content: [], source: { kind: 'user' } },
+        surfaceOp: 'append',
+        ...(index === 4 ? { sourceEventSeqs: [[2, 4], 5] } : {}),
+      })),
+    ]
     const parsed = parseSessionLog(`${header}\n${events.map(event => JSON.stringify(event)).join('\n')}\n`)
-    expect(parsed[4]).toEqual({ ...events[4], sourceEventSeqs: [0, 1, 2, 3] })
+    expect(parsed[7]).toEqual({ ...events[6], seq: 7, sourceEventSeqs: [3, 4, 5, 6] })
   })
 
   it('reports malformed range provenance with its source line', () => {
     const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
-    const events = Array.from({ length: 5 }, (_, seq) => ({
-      type: 'user/message',
-      seq,
-      time: 0,
-      data: { role: 'user', id: `message-${seq}`, content: [], source: { kind: 'user' } },
-      surfaceOp: 'append',
-      ...(seq === 4 ? { sourceEventSeqs: [[3, 1]] } : {}),
-    }))
+    const events = [
+      { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
+      { type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } },
+      ...Array.from({ length: 5 }, (_, index) => ({
+        type: 'user/message',
+        seq: index + 2,
+        time: 0,
+        data: { role: 'user', id: `message-${index}`, content: [], source: { kind: 'user' } },
+        surfaceOp: 'append',
+        ...(index === 4 ? { sourceEventSeqs: [[5, 3]] } : {}),
+      })),
+    ]
     expect(() => parseSessionLog(`${header}\n${events.map(event => JSON.stringify(event)).join('\n')}\n`))
-      .toThrow(/session snapshot line 6: sourceEventSeqs range/)
+      .toThrow(/session snapshot line 8: sourceEventSeqs range/)
   })
 
   it('locates malformed range provenance with a materialized v0 seedLength header', () => {
     const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0, seedLength: 0 })
-    const events = Array.from({ length: 3 }, (_, seq) => ({
-      type: 'user/message',
-      seq,
-      time: 0,
-      data: { role: 'user', id: `message-${seq}`, content: [], source: { kind: 'user' } },
-      surfaceOp: 'append',
-      ...(seq === 2 ? { sourceEventSeqs: [[2, 1]] } : {}),
-    }))
+    const events = [
+      { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
+      { type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } },
+      ...Array.from({ length: 3 }, (_, index) => ({
+        type: 'user/message',
+        seq: index + 2,
+        time: 0,
+        data: { role: 'user', id: `message-${index}`, content: [], source: { kind: 'user' } },
+        surfaceOp: 'append',
+        ...(index === 2 ? { sourceEventSeqs: [[4, 3]] } : {}),
+      })),
+    ]
 
     expect(() => parseSessionLog(`${header}\n${events.map(event => JSON.stringify(event)).join('\n')}\n`))
-      .toThrow(/session snapshot line 4: sourceEventSeqs range/)
+      .toThrow(/session snapshot line 6: sourceEventSeqs range/)
   })
 
   it('rejects non-object body rows with their source line', () => {
@@ -488,9 +516,9 @@ describe('parseSessionLog', () => {
       type: 'text-chunks', seq0: 2, time0: 0,
       data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] },
     })
-    expect(parseSessionLog(`${header}\n${turn}\n${step}\n${row}\n`).slice(2)).toEqual([{
+    expect(parseSessionLog(`${header}\n${turn}\n${step}\n${row}\n`).slice(2)).toEqual([emptySystemHead, {
       type: 'assistant/attempt',
-      seq: 2,
+      seq: 3,
       time: 0,
       data: {
         turn: 1,
@@ -511,9 +539,10 @@ describe('parseSessionLog', () => {
     expect(parseSessionLog(`${header}\n${ordinary}\n${step}\n${packed}\n`)).toEqual([
       { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
       { type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } },
+      emptySystemHead,
       {
         type: 'assistant/attempt',
-        seq: 2,
+        seq: 3,
         time: 3,
         data: {
           turn: 1,
@@ -528,6 +557,7 @@ describe('parseSessionLog', () => {
     const source = [
       JSON.stringify({ type: 'session', version: 0, id: 'tokens', createdAt: 7, delegationDepth: 0 }),
       JSON.stringify({ type: 'turn/start', data: { turn: 1 } }),
+      JSON.stringify({ type: 'step/start', data: { turn: 1, step: 1 } }),
       JSON.stringify({
         type: 'request/header',
         data: {
@@ -537,10 +567,23 @@ describe('parseSessionLog', () => {
       }),
     ].join('\n')
 
-    expect(parseSessionLog(source)[1]).toMatchObject({
-      type: 'request/header',
-      data: { header: { system: '{{system}}', tools: [] } },
-    })
+    expect(parseSessionLog(source).slice(2)).toEqual([
+      emptySystemHead,
+      {
+        ...emptySystemHead,
+        seq: 3,
+        data: {
+          ...emptySystemHead.data,
+          message: { ...emptySystemHead.data.message, content: [{ type: 'text', text: '{{system}}' }] },
+        },
+        surfaceOp: { op: 'replace', start: 2, end: 2 },
+        sourceEventSeqs: [2],
+      },
+      {
+        type: 'request/header', seq: 4, time: 0,
+        data: { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, tools: [] } },
+      },
+    ])
   })
 
   it('materializes Python snapshot tool-name projections before format validation', () => {
@@ -590,9 +633,9 @@ describe('parseSessionLog', () => {
       }),
     ].join('\n')
 
-    expect(parseSessionLog(source).slice(2)).toEqual([{
+    expect(parseSessionLog(source).slice(2)).toEqual([emptySystemHead, {
       type: 'assistant/attempt',
-      seq: 2,
+      seq: 3,
       time: 0,
       data: {
         turn: 1,
@@ -642,9 +685,9 @@ describe('parseSessionLog', () => {
       }),
     ].join('\n')
 
-    expect(parseSessionLog(source)[2]).toEqual({
+    expect(parseSessionLog(source)[3]).toEqual({
       type: 'assistant/message',
-      seq: 2,
+      seq: 3,
       time: 0,
       data: {
         turn: 1,
@@ -693,22 +736,34 @@ describe('parseSessionLog', () => {
 })
 
 describe('prepareSessionSnapshotFixtureForComparison', () => {
-  it('keeps unexpected request-header fields observable after current-format restoration', () => {
+  it('keeps arbitrary future 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' } },
+      data: { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, futureHeader: 'unexpected value' } },
     }
     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) as Record<string, unknown>)
     expect(restored.find(record => record.type === 'request/header')).toMatchObject(request)
-    delete (request.data.header as Record<string, unknown>).system
+    delete (request.data.header as Record<string, unknown>).futureHeader
     lines[3] = JSON.stringify(request)
     expect(prepared).not.toEqual(prepareSessionSnapshotFixtureForComparison(`${lines.join('\n')}\n`))
   })
 
+  it('rejects retired request-header system before current-format comparison', () => {
+    const source = projectSessionJsonl(replaySessionJsonl([TEXT_CHUNKS]))
+    const lines = source.trimEnd().split('\n')
+    lines.splice(3, 0, JSON.stringify({
+      type: 'request/header',
+      data: { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, system: 'retired prompt' } },
+    }))
+
+    expect(() => prepareSessionSnapshotFixtureForComparison(lines.join('\n')))
+      .toThrow(/request\/header.*system/)
+  })
+
   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')
@@ -751,211 +806,6 @@ describe('prepareSessionSnapshotFixtureForComparison', () => {
   })
 })
 
-describe('prepareSessionEventNotificationsForComparison', () => {
-  const wrapHeadlessEvent = (event: Record<string, unknown>) => JSON.stringify({
-    type: 'session_event', sessionId: 'session-1', event,
-  })
-
-  it('migrates a v1 notification tail while retaining non-event protocol rows', () => {
-    const events = [
-      { type: 'turn/start', seq: 3, time: 0, data: { turn: 1 } },
-      { type: 'step/start', seq: 4, time: 0, data: { turn: 1, step: 1 } },
-      { type: 'assistant/chunk', seq: 5, time: 5, data: {
-        turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' },
-      } },
-      { type: 'assistant/chunk', seq: 6, time: 6, data: {
-        turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' },
-      } },
-      { type: 'assistant/chunk', seq: 7, time: 7, data: {
-        turn: 1, step: 1, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: 'done' } },
-      } },
-      { type: 'assistant/chunk', seq: 8, time: 8, data: {
-        turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } },
-      } },
-      { type: 'assistant/message', seq: 9, time: 9, data: {
-        turn: 1,
-        step: 1,
-        message: {
-          role: 'assistant',
-          content: [{ type: 'text', text: 'done' }],
-          source: { kind: 'model', provider: 'fixture', model: 'fixture' },
-          id: 'message-1',
-        },
-      }, sourceEventSeqs: [5, 6, 7, 8], surfaceOp: 'append' },
-      { type: 'step/end', seq: 10, time: 10, data: { turn: 1, step: 1 } },
-      { type: 'turn/end', seq: 11, time: 11, data: { turn: 1, reason: { kind: 'completed' } } },
-    ]
-    const source = [
-      ...events.map(event => ({ type: 'session_event', sessionId: 'session-1', event })),
-      { type: 'session_event', sessionId: 'session-1', event: {
-        type: 'turn/end', seq: 2, time: 12, data: { turn: 1, reason: { kind: 'completed' } },
-      } },
-      { type: 'session_event', sessionId: 'session-1', event: {
-        type: 'feedback/record', seq: 12, time: 13, data: { text: 'resumed parent' },
-      } },
-      { type: 'result', status: 'completed' },
-    ].map(row => JSON.stringify(row)).join('\n') + '\n'
-
-    const prepared = prepareSessionEventNotificationsForComparison(source)
-    const rows = prepared.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
-    const migrated = rows.flatMap((row) => {
-      const event = row['event']
-      return event !== null && typeof event === 'object' ? [event] : []
-    })
-
-    expect(migrated.some(event => 'type' in event && event.type === 'assistant/chunk')).toBe(false)
-    expect(migrated.find(event => 'type' in event && event.type === 'assistant/message')).toMatchObject({
-      seq: 5,
-      data: { stream: expect.any(Array) as unknown },
-    })
-    expect(rows.at(-1)).toEqual({ type: 'result', status: 'completed' })
-    expect(prepared.endsWith('\n')).toBe(true)
-  })
-
-  it('leaves a current notification stream byte-identical', () => {
-    const current = `${JSON.stringify({
-      method: 'session.event',
-      params: {
-        sessionId: 'session-1',
-        event: { type: 'assistant/attempt', seq: 0, time: 0, data: { turn: 1, step: 1, stream: [] } },
-      },
-    })}\n`
-
-    expect(prepareSessionEventNotificationsForComparison(current)).toBe(current)
-  })
-
-  it('omits generation-dependent delivery cursors', () => {
-    const source = `${JSON.stringify({
-      method: 'session.event',
-      params: {
-        sessionId: 'session-1',
-        event: {
-          type: 'session-log-deepseek/delivery-accepted',
-          seq: 3,
-          time: 0,
-          data: { sessionId: 'source-1', sessionFormatVersion: 1, throughSeq: 21 },
-        },
-      },
-    })}\n`
-
-    expect(JSON.parse(prepareSessionEventNotificationsForComparison(source))).toMatchObject({
-      params: { event: { data: { sessionId: 'source-1' } } },
-    })
-  })
-
-  it.each([
-    { tools: '{{tools}}', expected: '{{tools}}' },
-    { tools: ['read'], expected: [{ name: 'read', description: '', parameters: {} }] },
-  ])('migrates a projected request header with tools $tools', ({ tools, expected }) => {
-    const events = [
-      { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
-      { type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } },
-      {
-        type: 'request/header',
-        seq: 2,
-        time: 0,
-        data: {
-          header: {
-            config: { provider: 'fixture', model: 'fixture' },
-            system: '{{system}}',
-            tools,
-          },
-          reason: 'initial',
-        },
-      },
-      {
-        type: 'assistant/chunk',
-        seq: 3,
-        time: 0,
-        data: { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
-      },
-      { type: 'step/end', seq: 4, time: 0, data: { turn: 1, step: 1 } },
-    ]
-    const source = events.map(event => JSON.stringify({
-      type: 'session_event',
-      sessionId: 'session-1',
-      event,
-    })).join('\n')
-
-    const rows = prepareSessionEventNotificationsForComparison(source).split('\n')
-      .map(line => JSON.parse(line) as { event: Record<string, unknown> })
-    const request = rows.find(row => row.event['type'] === 'request/header') as {
-      event: { data: { header: { tools: unknown } } }
-    }
-
-    expect(request.event.data.header.tools).toEqual(expected)
-    expect(rows.some(row => row.event['type'] === 'assistant/chunk')).toBe(false)
-    expect(rows.some(row => row.event['type'] === 'assistant/attempt')).toBe(true)
-  })
-
-  it('assigns an unterminated final chunk group to its last source row', () => {
-    const events = [
-      { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
-      { type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } },
-      {
-        type: 'assistant/chunk',
-        seq: 2,
-        time: 0,
-        data: { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
-      },
-    ]
-    const source = events.map(event => JSON.stringify({
-      method: 'session.event',
-      params: { sessionId: 'session-1', event },
-    })).join('\n')
-
-    const rows = prepareSessionEventNotificationsForComparison(source).split('\n')
-      .map(line => JSON.parse(line) as { params: { event: Record<string, unknown> } })
-
-    expect(rows).toHaveLength(3)
-    expect(rows.at(-1)?.params.event['type']).toBe('assistant/attempt')
-    expect(prepareSessionEventNotificationsForComparison('{"type":"result"}')).toBe('{"type":"result"}')
-  })
-
-  it('keeps malformed SDK wrappers and delivery payloads outside migration unchanged', () => {
-    const rows = [
-      { method: 'session.event', params: { sessionId: 7, event: {} } },
-      { method: 'session.event', params: { sessionId: 'session-1' } },
-      {
-        method: 'session.event',
-        params: {
-          sessionId: 'session-1',
-          event: { type: 'session-log-deepseek/delivery-accepted', seq: 0, time: 0, data: null },
-        },
-      },
-    ]
-    const source = rows.map(row => JSON.stringify(row)).join('\n')
-
-    expect(prepareSessionEventNotificationsForComparison(source)).toBe(source)
-  })
-
-  it('rejects malformed notification rows and invalid v1 tails before migration', () => {
-    expect(() => prepareSessionEventNotificationsForComparison('null'))
-      .toThrow('session event comparison line 1 must be an object')
-
-    expect(() => prepareSessionEventNotificationsForComparison(wrapHeadlessEvent({
-      type: 'assistant/chunk', seq: -1, time: 0, data: {},
-    }))).toThrow('session event comparison requires a non-negative first seq')
-    expect(() => prepareSessionEventNotificationsForComparison([
-      wrapHeadlessEvent({ type: 'assistant/chunk', seq: 0, time: 0, data: {} }),
-      wrapHeadlessEvent({ type: 7, seq: 1, time: 0, data: {} }),
-    ].join('\n'))).toThrow('session event comparison requires a contiguous event tail for session-1')
-  })
-
-  it('delegates malformed request headers to the released-format validator', () => {
-    const source = [
-      wrapHeadlessEvent({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }),
-      wrapHeadlessEvent({ type: 'request/header', seq: 1, time: 0, data: null }),
-      wrapHeadlessEvent({ type: 'assistant/chunk', seq: 2, time: 0, data: {
-        turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' },
-      } }),
-    ].join('\n')
-
-    expect(() => prepareSessionEventNotificationsForComparison(source))
-      .toThrow(/request\/header 1 data must be a JSON object/)
-  })
-})
-
 describe('deriveReplayScript', () => {
   it('expands one finished embedded Assistant stream into one replay entry', () => {
     const events: SessionEvent[] = [streamEvent(1, 1, 1, TEXT_CHUNKS)]
@@ -1132,25 +982,27 @@ describe('deriveReplayScript', () => {
       JSON.stringify({
         type: 'session', version: 0, id: 'invalid-compact', createdAt: 0, delegationDepth: 0,
       }),
+      JSON.stringify({ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }),
+      JSON.stringify({ type: 'step/start', seq: 1, time: 0, data: { turn: 1, step: 1 } }),
       JSON.stringify({
-        type: 'user/message', seq: 0, time: 0,
+        type: 'user/message', seq: 2, time: 0,
         data: { role: 'user', id: 'source', content: [], source: { kind: 'user' } },
         surfaceOp: 'append',
       }),
       JSON.stringify({
-        type: 'compaction/start', seq: 1, time: 0,
+        type: 'compaction/start', seq: 3, time: 0,
         data: { compactionId: COMPACTION_ID, turn: 1 },
       }),
       JSON.stringify({
         type: 'compaction/summary',
-        seq: 2,
+        seq: 4,
         time: 0,
         data: {
           compactionId: COMPACTION_ID,
           summary: [{ type: 'text', text: 'missing source events' }],
           llmStreamCall: true,
-          shadowedRange: { start: 0, end: 0 },
-          shadowedSeqs: [0],
+          shadowedRange: { start: 2, end: 2 },
+          shadowedSeqs: [2],
           shadowedTokenCount: 20,
           provider: 'mock',
           model: 'mock',