瀏覽代碼

refactor(test): simplify session fixture decoding

_Kerman 1 月之前
父節點
當前提交
f8828ffe3e

+ 2 - 2
.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.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-18-session-snapshot-envelope-projection.md
-2026-08-18-session-snapshot-envelope-projection.md: d4e0b3e7038642f262ecb38a9e1593de5f80b672
-2026-08-18-session-snapshot-envelope-projection.zh.md: 6c4601545c6ac7a0939bd3d1d755ac3d4c91916c
+2026-08-18-session-snapshot-envelope-projection.md: 28f8f7f823b552c68dcb2c54e62f0551e1615134
+2026-08-18-session-snapshot-envelope-projection.zh.md: a8c302c48d3924c3ffd4386a2039d0ea8b7af962

+ 2 - 2
.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.md

@@ -12,9 +12,9 @@ Committed session snapshots copied the persistence envelope on every body row. T
 
 A committed session snapshot is a projection of the persisted JSONL. The envelope projection leaves its first `session` header unchanged, including `version` and `createdAt`; other fixture normalization may still replace volatile header values such as `createdAt`, `id`, and `cwd`. Every body record retains its discriminant, payload, and other top-level fields, while the projection omits `seq`, `time`, `seq0`, and `time0` when present. Nested fields with the same names are payload and remain unchanged.
 
-Snapshot serialization omits those keys from the parsed body object before writing the line. A persisted-JSONL input is parsed once at the snapshot boundary; the dedicated snapshot normalizer combines value normalization, request-header scrubbing, and projection in that object pass rather than serializing and parsing again. Generic log and stream normalization retains sequence envelopes. Runtime persistence is unchanged.
+Snapshot serialization omits those keys from the parsed body object before writing the line. Snapshot comparison composes ordinary value normalization with that projection, while generic log and stream normalization retains sequence envelopes. Runtime persistence is unchanged.
 
-Replay's existing `parseSessionLog` entry point accepts the projected fixture and assigns contiguous synthetic sequence numbers in memory. Synthetic event times start at zero; packed `data.dt` values retain the relative gaps already stored in the fixture. One file must use projected body rows or complete persisted body rows throughout. Projection stays private to each snapshot writer, and replay materialization stays private to the replay parser; the repository fixture-layout gate uses that parser, retains canonical packed rows, and rejects half-present or mixed persistence envelopes.
+Replay's existing `parseSessionLog` entry point accepts the projected fixture and assigns missing sequence fields in memory while decoding. Synthetic event times start at zero; packed `data.dt` values retain the relative gaps already stored in the fixture. Projection stays private to each snapshot writer, and replay synthesis stays inside the replay parser. The repository fixture-layout check uses that parser and retains canonical packed rows.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.zh.md

@@ -12,9 +12,9 @@ Status: implemented
 
 签入仓库的会话快照是持久化 JSONL 的投影。envelope 投影不会修改第一条 `session` header,包括 `version` 与 `createdAt`;其他 fixture 规范化仍可能替换 `createdAt`、`id` 和 `cwd` 等易变 header 值。每条正文记录保留判别字段、payload 和其他顶层字段;投影仅在相应字段存在时省略 `seq`、`time`、`seq0` 与 `time0`。嵌套在 payload 中的同名字段保持不变。
 
-快照序列化会在写入每行前从已解析的正文对象上省略这些键。以持久化 JSONL 为输入的入口只在快照边界解析一次;专用快照 normalizer 在同一轮对象操作中完成值规范化、request header 清理和投影,而不会先序列化再重新解析。通用日志和 stream 规范化会保留序号 envelope。运行时持久化不变。
+快照序列化会在写入每行前从已解析的正文对象上省略这些键。快照比较会组合常规值规范化与该投影;通用日志和 stream 规范化仍保留序号 envelope。运行时持久化不变。
 
-回放的现有 `parseSessionLog` 入口接受投影后的 fixture,并在内存中分配连续的 synthetic 序号。synthetic 事件时间从零开始;打包行的 `data.dt` 保留 fixture 中已有的相对间隔。同一文件必须始终采用投影正文行或完整持久化正文行。投影实现仅位于各快照写入方内部,回放物化实现仅位于回放 parser 内部;仓库 fixture 布局门禁使用该 parser,继续强制规范打包行,并拒绝不完整或混用的持久化 envelope
+回放的现有 `parseSessionLog` 入口接受投影后的 fixture,并在解码时为缺失的序号字段赋值。synthetic 事件时间从零开始;打包行的 `data.dt` 保留 fixture 中已有的相对间隔。投影实现仅位于各快照写入方内部,回放补值实现仅位于回放 parser 内部。仓库 fixture 布局检查使用该 parser,并继续保留规范打包行
 
 ## 曾考虑的替代方案
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 65cf7eebac62d0ab24ae9e4918bab37c7c75c08a
-config-catalog.zh.md: a4522cf1f1eccd290fffae775f9db542ff3d2851
+config-catalog.md: 1ac667c6e62d7090d1581c8341cea8a0b6b3f233
+config-catalog.zh.md: 33dd25e8cc5edb8bebe95ec2f0e5d618058ae873

+ 1 - 1
docs/config-catalog.md

@@ -1156,7 +1156,7 @@ export interface ReplayModelConfig {
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/test-support/llm-replay/src/index.ts:770`](../packages/test-support/llm-replay/src/index.ts)
+Source: [`packages/test-support/llm-replay/src/index.ts:788`](../packages/test-support/llm-replay/src/index.ts)
 
 <a id="deepseek-aidsh-llm-retry"></a>
 

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

@@ -1158,7 +1158,7 @@ export interface ReplayModelConfig {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/test-support/llm-replay/src/index.ts:770`](../packages/test-support/llm-replay/src/index.ts)
+来源:[`packages/test-support/llm-replay/src/index.ts:788`](../packages/test-support/llm-replay/src/index.ts)
 
 <a id="deepseek-aidsh-llm-retry"></a>
 

+ 6 - 6
examples/headless-agent/tests/headless.snapshot.ts

@@ -412,8 +412,8 @@ describe('headless stream-json snapshots', () => {
           await writeFile(compactionSessionFixture, expectedSession)
         }
         const expectedContext = contextFromLogs([expectedSession])
-        expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
-          .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
+        expect(normalizeSessionSnapshot(actual.content, actualContext))
+          .toBe(normalizeSessionSnapshot(expectedSession, expectedContext))
       },
     })
 
@@ -657,8 +657,8 @@ describe('headless stream-json snapshots', () => {
         for (const [index, actual] of actualSessions.entries()) {
           const expected = expectedSessions[index]
           if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
-          expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
-            .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext)))
+          expect(normalizeSessionSnapshot(actual.content, actualContext))
+            .toBe(normalizeSessionSnapshot(expected, expectedContext))
         }
       },
     })
@@ -995,8 +995,8 @@ describe('headless stream-json snapshots', () => {
           await writeFile(ptySessionFixture, expectedSession)
         }
         const expectedContext = contextFromLogs([expectedSession])
-        expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
-          .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
+        expect(normalizeSessionSnapshot(actual.content, actualContext))
+          .toBe(normalizeSessionSnapshot(expectedSession, expectedContext))
       },
     })
 

+ 3 - 2
examples/jsonrpc-agent/tests/sdk.snapshot.ts

@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 import {
   normalizeSessionLog,
+  normalizeSessionSnapshot,
   normalizeStdout,
   refreshFixtureReplacements,
   scrubRequestHeaders,
@@ -408,8 +409,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
       for (const [index, log] of ordered.entries()) {
         const expected = expectedContents[index]
         if (expected === undefined) throw new Error(`no fixture for persisted log ${index}`)
-        expect(scrubRequestHeaders(normalizeSessionLog(log.content, actualContext)))
-          .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext)))
+        expect(normalizeSessionSnapshot(log.content, actualContext))
+          .toBe(normalizeSessionSnapshot(expected, expectedContext))
       }
 
       // The SDK-visible wire stream and turn result match their expected outputs.

+ 2 - 2
packages/test-support/acp-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/acp-snapshot/README.md
-README.md: ff239963f3733f07ac3ed255b29fccfdb6841687
-README.zh.md: b328d3dea4a7e76f0c7bf2010728c79046d8113d
+README.md: 3e7f907aed899ddf4c3fd7979ea08d00b985e9e3
+README.zh.md: 75df21a0d4aa023b4d5bca4c404b70ad92b62dc7

文件差異過大導致無法顯示
+ 1 - 1
packages/test-support/acp-snapshot/README.md


文件差異過大導致無法顯示
+ 1 - 1
packages/test-support/acp-snapshot/README.zh.md


+ 39 - 125
packages/test-support/acp-snapshot/src/normalize.ts

@@ -6,8 +6,6 @@
  * @module @deepseek-ai/dsh-acp-snapshot/normalize
  */
 
-import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session'
-
 const SESSION_ID = '{{sessionId}}'
 const CWD = '{{cwd}}'
 const SYSTEM = '{{system}}'
@@ -20,58 +18,6 @@ function isPackedFixtureRow(record: Record<string, unknown>): boolean {
   return typeof record.type === 'string' && PACKED_CHUNK_ROW_TYPES.has(record.type)
 }
 
-/**
- * Materialize projected body envelopes in caller-owned parsed records.
- * Package-private support for normalization and refresh alignment.
- *
- * @param records - session body records in storage order.
- * @returns logical events expanded from the materialized records.
- */
-function materializeFixtureBody(records: readonly Record<string, unknown>[]): SessionEvent[] {
-  let nextSeq = 0
-  let layout: 'unknown' | 'projected' | 'persisted' = 'unknown'
-  return records.flatMap((record, index) => {
-    const packed = isPackedFixtureRow(record)
-    const seqKey = packed ? 'seq0' : 'seq'
-    const timeKey = packed ? 'time0' : 'time'
-    const presentMembers = Number(Object.hasOwn(record, seqKey)) + Number(Object.hasOwn(record, timeKey))
-    if (presentMembers === 1) {
-      throw new Error(`session snapshot line ${index + 2} must carry both ${seqKey}/${timeKey} or neither`)
-    }
-    const recordLayout = presentMembers === 0 ? 'projected' : 'persisted'
-    if (layout === 'unknown') layout = recordLayout
-    else if (layout !== recordLayout) {
-      throw new Error(`session snapshot line ${index + 2} cannot mix projected and persisted body records`)
-    }
-    if (recordLayout === 'projected') {
-      const envelope = { [seqKey]: nextSeq, [timeKey]: 0 }
-      const materialized = Object.hasOwn(record, 'type')
-        ? { type: record.type, ...envelope, ...record }
-        : { ...record, ...envelope }
-      for (const key of Object.keys(record)) Reflect.deleteProperty(record, key)
-      Object.assign(record, materialized)
-    }
-    const decoded = decodeStorageRecord(record)
-    nextSeq += decoded.length
-    return decoded
-  })
-}
-
-/**
- * Parse persisted or projected session JSONL for package-internal comparison.
- * Projected body envelopes are materialized on the returned records.
- *
- * @param rawLog - session JSONL contents.
- * @returns parsed records in file order.
- */
-export function parseComparableSessionLog(rawLog: string): Record<string, unknown>[] {
-  const records = rawLog.split('\n')
-    .filter(line => line.trim().length > 0)
-    .map(line => JSON.parse(line) as Record<string, unknown>)
-  if (records[0]?.type === 'session') materializeFixtureBody(records.slice(1))
-  return records
-}
-
 function omitFixtureEnvelope(record: Record<string, unknown>): void {
   delete record.seq
   delete record.time
@@ -350,10 +296,8 @@ export function normalizeStdout(
  * Normalize a session JSONL log into a stable expected output: the header line's
  * volatile fields (`createdAt`, `id`, `cwd`) are zeroed/scrubbed, ordinary
  * event `time` and packed-row `time0` values are zeroed, and all volatile
- * strings are scrubbed. A projected session fixture has its sequence/time
- * envelopes materialized in the parsed objects before normalization. Packed
- * `data.dt` gaps remain normalized because they carry the same wall-clock noise
- * as their `time0` anchor.
+ * strings are scrubbed. Projected inputs remain projected. Packed `data.dt`
+ * gaps are normalized even when the projected row omits its `time0` anchor.
  * Output is JSONL in the same shape as the input — one compact record per
  * line.
  *
@@ -368,44 +312,33 @@ export function normalizeSessionLog(
   options: NormalizeOptions = {},
 ): string {
   const cwdPathMode = options.cwdPathMode ?? 'canonical'
-  const records = parseComparableSessionLog(rawLog)
-  const normalized = records.map(record => normalizeSessionRecord(record, ctx, cwdPathMode))
-  return normalized.map(r => JSON.stringify(r)).join('\n') + '\n'
-}
-
-/** Normalize one already-parsed session or stream record in place. */
-function normalizeSessionRecord(
-  record: Record<string, unknown>,
-  ctx: NormalizeContext,
-  cwdPathMode: CwdPathMode,
-): Record<string, unknown> {
-  // Header line: { type: 'session', createdAt, id, cwd, … }.
-  if (record.type === 'session') {
-    if ('createdAt' in record) record.createdAt = 0
-  } else if (isPackedFixtureRow(record)) {
-    if ('time0' in record) record.time0 = 0
-    const data = record.data
-    if (data !== null && typeof data === 'object' && Array.isArray((data as { dt?: unknown }).dt)) {
-      (data as { dt: unknown[] }).dt = (data as { dt: unknown[] }).dt.map(() => 0)
+  const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
+  const records = lines.map((line) => {
+    const record = JSON.parse(line) as Record<string, unknown>
+    if (record.type === 'session') {
+      if ('createdAt' in record) record.createdAt = 0
+    } else if (isPackedFixtureRow(record)) {
+      if ('time0' in record) record.time0 = 0
+      const data = record.data
+      if (data !== null && typeof data === 'object' && Array.isArray((data as { dt?: unknown }).dt)) {
+        (data as { dt: unknown[] }).dt = (data as { dt: unknown[] }).dt.map(() => 0)
+      }
+    } else if ('time' in record) {
+      record.time = 0
     }
-  } else if ('time' in record) {
-    record.time = 0
-  }
-  // A hook/result carries the hook's wall-clock runtime (`data.durationMs`),
-  // which is run-to-run noise like `time` — zero it so the expected output reflects
-  // the hook's decision/exit, not how long the shell took.
-  if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') {
-    const data = record.data as Record<string, unknown>
-    if ('durationMs' in data) data.durationMs = 0
-  }
-  return scrubValue(record, ctx, cwdPathMode) as Record<string, unknown>
+    if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') {
+      const data = record.data as Record<string, unknown>
+      if ('durationMs' in data) data.durationMs = 0
+    }
+    return scrubValue(record, ctx, cwdPathMode) as Record<string, unknown>
+  })
+  return records.map(r => JSON.stringify(r)).join('\n') + '\n'
 }
 
 /**
  * Normalize and project persisted session JSONL for a committed fixture.
- * Each non-empty line is parsed once; body records are normalized, request
- * headers are tokenized, and persistence-only envelopes are omitted before
- * the record is serialized.
+ * This composes ordinary log normalization with request-header scrubbing and
+ * persistence-envelope projection.
  *
  * @param rawLog - persisted or already-projected session JSONL.
  * @param ctx - the run's volatile values to scrub.
@@ -417,19 +350,7 @@ export function normalizeSessionSnapshot(
   ctx: NormalizeContext,
   options: NormalizeOptions = {},
 ): string {
-  const cwdPathMode = options.cwdPathMode ?? 'canonical'
-  let recordIndex = 0
-  return rawLog.split('\n').map((line) => {
-    if (line.trim().length === 0) return line
-    const parsed = JSON.parse(line) as Record<string, unknown>
-    const record = normalizeSessionRecord(parsed, ctx, cwdPathMode)
-    if (recordIndex++ === 0) {
-      if (record.type !== 'session') throw new Error('session snapshot must start with a session header')
-      return JSON.stringify(record)
-    }
-    scrubSessionSnapshotBodyRecord(record)
-    return JSON.stringify(record)
-  }).join('\n')
+  return scrubSessionSnapshot(normalizeSessionLog(rawLog, ctx, options))
 }
 
 /**
@@ -486,24 +407,20 @@ export function scrubRequestHeaders(rawLog: string): string {
  * @returns committed snapshot JSONL with request headers tokenized.
  */
 export function scrubSessionSnapshot(rawLog: string): string {
+  const scrubbed = scrubRequestHeaders(rawLog)
   let recordIndex = 0
-  return rawLog.split('\n').map((line) => {
+  return scrubbed.split('\n').map((line) => {
     if (line.trim().length === 0) return line
     const record = JSON.parse(line) as Record<string, unknown>
     if (recordIndex++ === 0) {
       if (record.type !== 'session') throw new Error('session snapshot must start with a session header')
       return line
     }
-    scrubSessionSnapshotBodyRecord(record)
+    omitFixtureEnvelope(record)
     return JSON.stringify(record)
   }).join('\n')
 }
 
-function scrubSessionSnapshotBodyRecord(record: Record<string, unknown>): void {
-  scrubRequestHeaderRecord(record, { system: true, tools: true })
-  omitFixtureEnvelope(record)
-}
-
 /** Which independent request-header payloads a scrubber replaces. */
 interface HeaderScrubOptions {
   system?: boolean
@@ -516,20 +433,17 @@ function scrubHeaderContent(rawLog: string, options: HeaderScrubOptions): string
   const out = lines.map((line) => {
     if (line.trim().length === 0) return line
     const record = JSON.parse(line) as Record<string, unknown>
-    return scrubRequestHeaderRecord(record, options) ? JSON.stringify(record) : line
+    const data = record.data as Record<string, unknown> | null | undefined
+    if (data === null || typeof data !== 'object') return line
+    if (record.type === 'request/header') {
+      const header = data.header as Record<string, unknown> | null | undefined
+      if (header === null || typeof header !== 'object') return line
+      let touched = false
+      if (options.system === true && 'system' in header) { header.system = SYSTEM; touched = true }
+      if (options.tools === true && 'tools' in header) { header.tools = TOOLS; touched = true }
+      return touched ? JSON.stringify(record) : line
+    }
+    return line
   })
   return out.join('\n')
 }
-
-/** Tokenize selected request-header fields on one parsed record. */
-function scrubRequestHeaderRecord(record: Record<string, unknown>, options: HeaderScrubOptions): boolean {
-  if (record.type !== 'request/header') return false
-  const data = record.data as Record<string, unknown> | null | undefined
-  if (data === null || typeof data !== 'object') return false
-  const header = data.header as Record<string, unknown> | null | undefined
-  if (header === null || typeof header !== 'object') return false
-  let touched = false
-  if (options.system === true && 'system' in header) { header.system = SYSTEM; touched = true }
-  if (options.tools === true && 'tools' in header) { header.tools = TOOLS; touched = true }
-  return touched
-}

+ 5 - 7
packages/test-support/acp-snapshot/src/suite.ts

@@ -29,7 +29,6 @@ import {
   extractSnapshotSpillPaths,
   normalizeSessionLog,
   normalizeStdout,
-  parseComparableSessionLog,
   scrubRequestHeaders,
   scrubSessionSnapshot,
   scrubSystemPrompts,
@@ -693,8 +692,8 @@ export function stabilizeFixtureMessageIds(logs: readonly string[], fixtures: re
 /** One packed row's member times, or `undefined` for an ordinary record. */
 function packedTimes(record: Record<string, unknown>): number[] | undefined {
   if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined
-  const row = record as unknown as { time0: number; data: { dt: number[] } }
-  const times = [row.time0]
+  const row = record as unknown as { time0?: number; data: { dt: number[] } }
+  const times = [row.time0 ?? 0]
   for (const gap of row.data.dt) times.push((times[times.length - 1] as number) + gap)
   return times
 }
@@ -1015,8 +1014,8 @@ function normalizedStringMappings(
  * complete record layout aligns and volatile strings form a consistent
  * bijection. Complete durable-message ids are excluded because the later
  * fixture-ready structural pass owns them. Ambiguous layouts or mappings
- * keep fresh strings. Packed timing envelopes expand for alignment, so
- * packing does not shift later records;
+ * keep fresh strings. Packed timing gaps expand from zero when a projected
+ * fixture omits `time0`, so packing does not shift later records;
  * fresh semantic values and fragment arrays remain authoritative.
  *
  * @param fresh The newly harvested session JSONL.
@@ -1033,8 +1032,7 @@ export function stabilizeRefreshLog(
 ): string {
   const freshRecords = parseJsonlRecords(fresh)
   const stable = applyFixtureReplacements(fresh, replacements)
-  const parsedExisting = parseComparableSessionLog(existing)
-  const existingRecords = logicalRecords(parsedExisting)
+  const existingRecords = logicalRecords(parseJsonlRecords(existing))
   const records = parseJsonlRecords(stable)
   const existingContext = fixtureContext(existing)
   const stringMappings = normalizedStringMappings(

+ 5 - 19
packages/test-support/acp-snapshot/tests/normalize.spec.ts

@@ -227,27 +227,13 @@ describe('normalizeSessionLog', () => {
     expect(out).not.toContain('999')
   })
 
-  it('materializes a projected event envelope in the same parse pass', () => {
+  it('normalizes a projected event without adding a persistence envelope', () => {
     const projected = JSON.stringify({ type: 'turn/start', data: { turn: 1 } })
     const out = normalizeSessionLog(`${header({})}\n${projected}\n`, ctx)
-    expect(out).toContain('"seq":0')
-    expect(out).toContain('"time":0')
-  })
-
-  it('rejects incomplete or mixed projected envelopes', () => {
-    expect(() => normalizeSessionLog(`${header({})}\n{"type":"turn/start","seq":0}\n`, ctx))
-      .toThrow(/both seq\/time or neither/)
-    expect(() => normalizeSessionLog([
-      header({}),
-      '{"type":"turn/start","data":{"turn":1}}',
-      '{"type":"turn/end","seq":1,"time":0,"data":{"turn":1}}',
-      '',
-    ].join('\n'), ctx)).toThrow(/cannot mix projected and persisted body records/)
-  })
-
-  it('materializes a typeless projected record without adding a type field', () => {
-    const out = normalizeSessionLog(`${header({})}\n{}\n`, ctx)
-    expect(JSON.parse(out.trimEnd().split('\n')[1] ?? '{}')).toStrictEqual({ seq: 0, time: 0 })
+    expect(JSON.parse(out.trimEnd().split('\n')[1] ?? '{}')).toStrictEqual({
+      type: 'turn/start',
+      data: { turn: 1 },
+    })
   })
 
   it('scrubs cwd and session id deep inside event data', () => {

+ 21 - 3
packages/test-support/llm-replay/src/index.ts

@@ -11,7 +11,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
 import { delimiter as pathDelimiter } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-compaction'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session'
 import type {
   ContentBlock,
   GenerateOptions,
@@ -25,7 +25,8 @@ import type {
   TokenUsage,
 } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
-import { parseReplaySessionLog } from './session-snapshot.ts'
+
+const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
 
 /**
  * One recorded model call. `throw` may replay prefix chunks before failing;
@@ -165,7 +166,24 @@ export interface SessionScript {
  * @returns every event after the header, in log order.
  */
 export function parseSessionLog(text: string): SessionEvent[] {
-  return parseReplaySessionLog(text)
+  const lines = text.split('\n').filter(line => line.trim().length > 0)
+  const events: SessionEvent[] = []
+  let nextSeq = 0
+  // The JSONL backend guarantees line 0 is the session header. Projected
+  // fixtures omit event envelopes; synthesize them while decoding so callers
+  // still receive complete SessionEvent values.
+  for (let i = 1; i < lines.length; i++) {
+    const record = JSON.parse(lines[i] as string) as Record<string, unknown>
+    const packed = PACKED_CHUNK_ROW_TYPES.has(record.type as string)
+    const seqKey = packed ? 'seq0' : 'seq'
+    const timeKey = packed ? 'time0' : 'time'
+    if (!Object.hasOwn(record, seqKey)) record[seqKey] = nextSeq
+    if (!Object.hasOwn(record, timeKey)) record[timeKey] = 0
+    const decoded = decodeStorageRecord(record)
+    events.push(...decoded)
+    nextSeq += decoded.length
+  }
+  return events
 }
 
 /**

+ 0 - 95
packages/test-support/llm-replay/src/session-snapshot.ts

@@ -1,95 +0,0 @@
-/**
- * Private parser support for replay session fixtures.
- * @module @deepseek-ai/dsh-llm-replay/session-snapshot
- */
-
-import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session'
-
-const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
-
-function parseJsonl(text: string): Record<string, unknown>[] {
-  return text.split(/\r?\n/).flatMap((line, index) => {
-    if (line.trim().length === 0) return []
-    let value: unknown
-    try {
-      value = JSON.parse(line) as unknown
-    } catch (error) {
-      throw new Error(`session snapshot line ${index + 1} contains invalid JSON: ${String(error)}`, { cause: error })
-    }
-    if (value === null || typeof value !== 'object' || Array.isArray(value)) {
-      throw new Error(`session snapshot line ${index + 1} must be a JSON object`)
-    }
-    return [{ ...value }]
-  })
-}
-
-function envelopeKeys(record: Record<string, unknown>): readonly [string, string] {
-  return typeof record.type === 'string' && PACKED_CHUNK_ROW_TYPES.has(record.type)
-    ? ['seq0', 'time0']
-    : ['seq', 'time']
-}
-
-/** Insert a synthetic envelope after the discriminant without reparsing the row. */
-function insertSyntheticEnvelope(
-  record: Record<string, unknown>,
-  seqKey: string,
-  timeKey: string,
-  seq: number,
-): void {
-  const entries = Object.entries(record)
-  for (const key of Object.keys(record)) Reflect.deleteProperty(record, key)
-  let inserted = false
-  for (const [key, value] of entries) {
-    record[key] = value
-    if (key === 'type') {
-      record[seqKey] = seq
-      record[timeKey] = 0
-      inserted = true
-    }
-  }
-  if (!inserted) {
-    record[seqKey] = seq
-    record[timeKey] = 0
-  }
-}
-
-/**
- * Parse a persisted or uniformly projected session fixture for replay.
- * Missing body envelopes are synthesized only for the returned event list;
- * projected and persisted rows cannot coexist in one fixture.
- *
- * @param text - session JSONL contents.
- * @returns logical events expanded from ordinary and packed body rows.
- */
-export function parseReplaySessionLog(text: string): SessionEvent[] {
-  const records = parseJsonl(text)
-  if (records[0]?.type !== 'session') throw new Error('session snapshot must start with a session header')
-
-  const events: SessionEvent[] = []
-  let nextSeq = 0
-  let projected: boolean | undefined
-  for (const [index, record] of records.slice(1).entries()) {
-    const [seqKey, timeKey] = envelopeKeys(record)
-    const hasSeq = Object.hasOwn(record, seqKey)
-    const hasTime = Object.hasOwn(record, timeKey)
-    if (hasSeq !== hasTime) {
-      throw new Error(`session snapshot line ${index + 2} must carry both ${seqKey}/${timeKey} or neither`)
-    }
-    if (projected === undefined) projected = !hasSeq
-    else if (projected === hasSeq) {
-      throw new Error(`session snapshot line ${index + 2} cannot mix projected and persisted body records`)
-    }
-    if (projected) insertSyntheticEnvelope(record, seqKey, timeKey, nextSeq)
-    let decoded: SessionEvent[]
-    try {
-      decoded = decodeStorageRecord(record)
-    } catch (error) {
-      /* v8 ignore next -- decodeStorageRecord only throws Error instances; the String arm only satisfies the unknown narrowing. */
-      const detail = error instanceof Error ? error.message : String(error)
-      throw new Error(`session snapshot line ${index + 2}: ${detail}`, { cause: error })
-    }
-    events.push(...decoded)
-    nextSeq += decoded.length
-  }
-  return events
-}

+ 1 - 54
packages/test-support/llm-replay/tests/llm-replay.spec.ts

@@ -109,7 +109,7 @@ describe('parseSessionLog', () => {
     ])
   })
 
-  it('materializes omitted ordinary and packed snapshot envelopes', () => {
+  it('synthesizes omitted ordinary and packed snapshot envelopes', () => {
     const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 7 })
     const ordinary = JSON.stringify({ type: 'turn/start', data: { turn: 1 } })
     const packed = JSON.stringify({
@@ -124,59 +124,6 @@ describe('parseSessionLog', () => {
   })
 })
 
-describe('projected replay fixtures', () => {
-  it('rejects half-present storage envelopes', () => {
-    const fixture = [
-      '{"type":"session","version":0,"id":"s1","createdAt":0}',
-      '{"type":"turn/start","seq":0,"data":{"turn":1}}',
-      '',
-    ].join('\n')
-    expect(() => parseSessionLog(fixture)).toThrow(/both seq\/time or neither/)
-  })
-
-  it.each([
-    [
-      '{"type":"turn/start","data":{"turn":1}}',
-      '{"type":"turn/end","seq":1,"time":1,"data":{"turn":1,"reason":{"kind":"completed"}}}',
-    ],
-    [
-      '{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}}',
-      '{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}',
-    ],
-  ])('rejects mixed projected and persisted body records', (first, second) => {
-    const fixture = `{"type":"session"}\n${first}\n${second}\n`
-    expect(() => parseSessionLog(fixture)).toThrow(/line 3 cannot mix projected and persisted body records/)
-  })
-
-  it('reports malformed storage records with their snapshot line', () => {
-    const fixture = [
-      '{"type":"session"}',
-      '{"type":"turn/start","data":{"turn":1}}',
-      '{"type":"text-chunks","data":{"turn":1,"step":1,"index":0,"dt":[],"texts":[]}}',
-      '',
-    ].join('\n')
-    expect(() => parseSessionLog(fixture)).toThrow(/session snapshot line 3:/)
-  })
-
-  it.each(['null', '1', '[]'])('rejects non-object JSONL records: %s', (record) => {
-    expect(() => parseSessionLog(`${record}\n`)).toThrow(/line 1 must be a JSON object/)
-  })
-
-  it('reports invalid JSON with its line', () => {
-    expect(() => parseSessionLog('{"type":"session"}\n{broken}\n'))
-      .toThrow(/line 2 contains invalid JSON/)
-  })
-
-  it('requires a session header', () => {
-    expect(() => parseSessionLog('')).toThrow(/must start with a session header/)
-    expect(() => parseSessionLog('{"type":"other"}\n')).toThrow(/must start with a session header/)
-  })
-
-  it('materializes an unknown empty storage row as an ordinary event', () => {
-    expect(parseSessionLog('{"type":"session"}\n{}\n')).toStrictEqual([{ seq: 0, time: 0 }])
-  })
-})
-
 describe('deriveReplayScript', () => {
   it('groups one finished assistant/chunk stream into one replay entry', () => {
     const events: SessionEvent[] = TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))

部分文件因文件數量過多而無法顯示