1
0
Эх сурвалжийг харах

perf(session-format): stream released v0-to-v2 migrations

imccyu 1 сар өмнө
parent
commit
46196d6f95
31 өөрчлөгдсөн 2675 нэмэгдсэн , 1105 устгасан
  1. 1 0
      apps/web/tests/clickable-links-gallery.e2e.ts
  2. 1 1
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  3. 8 12
      apps/web/tests/scaffold.ts
  4. 4 1
      packages/session/session-format-catalog/src/generated.ts
  5. 29 11
      packages/session/session-format-catalog/tests/catalog.spec.ts
  6. 169 263
      packages/session/session-format-v0-to-v1/src/codec.ts
  7. 1 1
      packages/session/session-format-v0-to-v1/src/index.ts
  8. 168 33
      packages/session/session-format-v0-to-v1/src/migration.ts
  9. 35 1
      packages/session/session-format-v0-to-v1/src/payload-validation.ts
  10. 24 2
      packages/session/session-format-v0-to-v1/src/relationships.ts
  11. 81 0
      packages/session/session-format-v0-to-v1/src/testing/restore.ts
  12. 65 0
      packages/session/session-format-v0-to-v1/src/testing/validation.ts
  13. 24 51
      packages/session/session-format-v0-to-v1/src/validation.ts
  14. 151 143
      packages/session/session-format-v0-to-v1/tests/codec.spec.ts
  15. 2 2
      packages/session/session-format-v0-to-v1/tests/legacy.spec.ts
  16. 208 32
      packages/session/session-format-v0-to-v1/tests/migration.spec.ts
  17. 49 12
      packages/session/session-format-v0-to-v1/tests/relationships.spec.ts
  18. 93 8
      packages/session/session-format-v0-to-v1/tests/validation.spec.ts
  19. 94 76
      packages/session/session-format-v1-to-v2/src/codec.ts
  20. 1 1
      packages/session/session-format-v1-to-v2/src/index.ts
  21. 496 184
      packages/session/session-format-v1-to-v2/src/migration.ts
  22. 105 0
      packages/session/session-format-v1-to-v2/src/testing/validation.ts
  23. 37 100
      packages/session/session-format-v1-to-v2/src/validation.ts
  24. 79 28
      packages/session/session-format-v1-to-v2/tests/codec.spec.ts
  25. 509 22
      packages/session/session-format-v1-to-v2/tests/migration.spec.ts
  26. 27 1
      packages/session/session-format-v1-to-v2/tests/validation.spec.ts
  27. 65 81
      packages/test-support/llm-replay/src/index.ts
  28. 55 31
      packages/test-support/llm-replay/tests/llm-replay.spec.ts
  29. 2 1
      scripts/gen-session-format-catalog.spec.ts
  30. 4 1
      scripts/gen-session-format-catalog.ts
  31. 88 6
      scripts/session-fixture-layout.ts

+ 1 - 0
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -242,6 +242,7 @@ function galleryFixture(imageUrl: string): string {
       ...(call.meta === undefined ? {} : { meta: call.meta }),
     }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
   }
+  session.append('step/end', { turn: 1, step: 1 })
   session.append('step/start', { turn: 1, step: 2 })
   session.append('assistant/message', {
     stream: [],

+ 1 - 1
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -201,4 +201,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}}
+- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}}

+ 8 - 12
apps/web/tests/scaffold.ts

@@ -869,19 +869,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
  * in-memory record-mode harvest, so the on-disk zstd default never matters.
  */
 function rawSessionLog(session: Session): string {
-  const encoded = sessionFormatCatalog.encodeCurrent({
-    header: {
-      ...session.header,
-      delegationDepth: session.header.delegationDepth ?? 0,
-    },
-    inheritedEventCount: session.inheritedEventCount,
-    // Session validates durable payloads as JSON; its closed event unions do
-    // not carry the index signature used by the format package's JSON types.
-    events: session.snapshotEvents() as unknown as readonly SessionFormatEvent[],
-  })
+  const encodedEvents = (session.snapshotEvents() as unknown as readonly SessionFormatEvent[])
+    .map(event => sessionFormatCatalog.encodeCurrentEvent(event))
+  const header = sessionFormatCatalog.encodeCurrentHeader({
+    ...session.header,
+    delegationDepth: session.header.delegationDepth ?? 0,
+  }, session.inheritedEventCount)
   return [
-    JSON.stringify(encoded.header),
-    ...encoded.rows.map(record => JSON.stringify(record)),
+    JSON.stringify(header),
+    ...encodedEvents.map(record => JSON.stringify(record)),
     '',
   ].join('\n')
 }

+ 4 - 1
packages/session/session-format-catalog/src/generated.ts

@@ -13,13 +13,16 @@ import { assertReleasedV2Header, releasedV2SessionFormatCodec, restoreReleasedV2
 export const sessionFormatCatalog = createSessionFormatCatalog({
   currentVersion: 2,
   codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec],
-  encodeCurrentArtifact: artifact => releasedV2SessionFormatCodec.encodeArtifact(artifact),
+  currentEncoder: releasedV2SessionFormatCodec,
   migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2],
   restoreCurrent(artifact) {
     const restored = restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
     validateInstalledCurrentSessionArtifact(restored)
     return restored
   },
+  restoreTransformedCurrent(artifact) {
+    return restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
+  },
   restoreCurrentHeader(header) {
     assertReleasedV2Header(header)
     validateInstalledCurrentSessionHeader(header)

+ 29 - 11
packages/session/session-format-catalog/tests/catalog.spec.ts

@@ -27,10 +27,11 @@ describe('first-party Session format catalog', () => {
     })
 
     const v1Header = { ...header, version: 1 }
-    const current = sessionFormatCatalog.decodeArtifact(v1Header, [
-      { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } },
-    ])
-    expect(sessionFormatCatalog.migrate(current)).toMatchObject({
+    const restore = sessionFormatCatalog.createRestore(v1Header, {
+      recovery: 'strict', validation: 'current',
+    })
+    restore.decodeRow({ type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } })
+    expect(restore.finish()).toMatchObject({
       header: { version: 2, id: 'catalog' },
     })
   })
@@ -39,21 +40,38 @@ describe('first-party Session format catalog', () => {
     const header = {
       type: 'session', version: 2, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
     }
-    const extended = sessionFormatCatalog.decodeArtifact(header, [{
+    const restore = (rows: readonly unknown[]) => {
+      const current = sessionFormatCatalog.createRestore(header, {
+        recovery: 'strict', validation: 'current',
+      })
+      for (const row of rows) current.decodeRow(row)
+      return current.finish()
+    }
+    const extended = restore([{
+      type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
+    }])
+    expect(extended.events).toEqual([{
       type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
     }])
-    expect(sessionFormatCatalog.migrate(extended).events).toEqual(extended.events)
 
-    const unknownRequired = sessionFormatCatalog.decodeArtifact(header, [{
+    expect(() => restore([{
       type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future',
-    }])
-    expect(() => sessionFormatCatalog.migrate(unknownRequired)).toThrow(/unknown event type/)
+    }])).toThrow(/unknown event type/)
 
-    const extension = sessionFormatCatalog.decodeArtifact(header, [{
+    const extension = restore([{
       type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,
     }])
-    expect(sessionFormatCatalog.migrate(extension).events).toEqual([{
+    expect(extension.events).toEqual([{
       type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,
     }])
   })
+
+  it('validates complete relationships after streaming migration', () => {
+    const stream = sessionFormatCatalog.createRestore({
+      type: 'session', version: 1, id: 'invalid-stream', createdAt: 1, delegationDepth: 0,
+    }, { recovery: 'strict', validation: 'current' })
+    stream.decodeRow({ type: 'step/start', seq: 0, time: 2, data: { turn: 1, step: 1 } })
+
+    expect(() => stream.finish()).toThrow(/open turn/)
+  })
 })

+ 169 - 263
packages/session/session-format-v0-to-v1/src/codec.ts

@@ -1,32 +1,46 @@
 import {
   SessionFormatError,
-  isSessionFormatJsonObject,
   sessionFormatCount,
   sessionFormatSafeInteger,
-  snapshotSessionFormatArtifact,
   snapshotSessionFormatJson,
 } from '@deepseek-ai/dsh-session-format'
 import type {
-  EncodedSessionFormatArtifact,
-  SessionFormatArtifact,
+  SessionFormatArtifactDecoder,
   SessionFormatCodec,
-  SessionFormatEncodeOptions,
   SessionFormatEvent,
+  SessionFormatEventRun,
   SessionFormatHeader,
   SessionFormatJsonObject,
   SessionFormatJsonValue,
+  SessionFormatMigrationContext,
+  SessionFormatRecovery,
 } from '@deepseek-ai/dsh-session-format'
-import {
-  assertReleasedSessionFormatHeader,
-  assertReleasedV0SourceArtifact,
-  assertReleasedV1PhysicalArtifact,
-} from './validation.ts'
+import { assertReleasedSessionFormatHeader } from './validation.ts'
 import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts'
 
 const PHYSICAL_HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'delegationDepth'] as const
 const PHYSICAL_HEADER_OPTIONAL = ['cwd', 'parentSession', 'seedLength', 'origin', 'agentPreset'] as const
 const PACKED_TAGS = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
 
+/** A released packed Assistant row retained until v1-to-v2 embeds its compact stream. */
+export interface ReleasedAssistantChunkRun extends SessionFormatEventRun {
+  readonly runType: 'released-assistant-chunks'
+  readonly turn: number
+  readonly step: number
+  readonly lastSeq: number
+  readonly lastTime: number
+  readonly stream: SessionFormatJsonObject
+}
+
+/**
+ * Test whether a compact migration item is a released packed Assistant row.
+ * @param run - compact migration item to classify.
+ * @returns whether the item carries the released Assistant chunk representation.
+ */
+export function isReleasedAssistantChunkRun(run: SessionFormatEventRun): run is ReleasedAssistantChunkRun {
+  return run.runType === 'released-assistant-chunks'
+}
+
 /** Frozen physical JSON codec for the released v0 layout. */
 export const releasedV0SessionFormatCodec = createReleasedCodec(0)
 
@@ -37,40 +51,88 @@ function createReleasedCodec(version: 0 | 1) {
   return Object.freeze({
     version,
     decodeHeader: (value: unknown) => decodeHeader(value, version),
-    decodeArtifact(headerValue: unknown, rowValues: readonly unknown[]) {
+    createDecoder(headerValue: unknown, recovery: SessionFormatRecovery) {
       const physical = decodePhysicalHeader(headerValue, version)
-      const artifact = snapshotSessionFormatArtifact({
+      const scanner = scanRows(recovery === 'recoverable')
+      return {
         header: physical.header,
-        inheritedEventCount: physical.inheritedEventCount,
-        events: scanRows(rowValues, false).events,
-      }, `released v${version} artifact`)
-      if (version === 0) assertReleasedV0SourceArtifact(artifact)
-      else assertReleasedV1PhysicalArtifact(artifact)
-      return artifact
+        headerInheritedEventCount: physical.inheritedEventCount,
+        decodeRow: (rowValue, context) => { scanner.decodeRow(rowValue, context) },
+        finish(_context) {
+          scanner.finish(physical.inheritedEventCount)
+          return physical.inheritedEventCount
+        },
+      } satisfies SessionFormatArtifactDecoder
     },
-    decodeRecoverableArtifact(headerValue: unknown, rowValues: readonly unknown[]) {
-      const physical = decodePhysicalHeader(headerValue, version)
-      const recovered = scanRows(rowValues, true)
-      const artifact = snapshotSessionFormatArtifact({
-        header: physical.header,
-        inheritedEventCount: physical.inheritedEventCount,
-        events: recovered.events,
-      }, `released v${version} recoverable artifact`)
-      if (version === 0) assertReleasedV0SourceArtifact(artifact)
-      else assertReleasedV1PhysicalArtifact(artifact)
-      return artifact
+  } satisfies SessionFormatCodec)
+}
+
+function scanRows(
+  recoverable: boolean,
+): {
+  decodeRow(
+    rowValue: unknown,
+    context: SessionFormatMigrationContext,
+  ): void
+  finish(inheritedEventCount: number): void
+} {
+  let rowIndex = 0
+  let eventCount = 0
+  let issue: SessionFormatError | undefined
+  return {
+    decodeRow(rowValue, context) {
+      const currentRow = rowIndex
+      rowIndex += 1
+      let packed = false
+      let decoded: SessionFormatEvent | ReleasedAssistantChunkRun
+      try {
+        const record = releasedV0Record(rowValue, `released Session row ${currentRow}`)
+        const type = record['type']
+        if (typeof type === 'string' && PACKED_TAGS.has(type)) {
+          packed = true
+          decoded = decodePackedRun(record, type, currentRow)
+        } else {
+          decoded = decodeEvent(record, currentRow)
+        }
+      } catch (error: unknown) {
+        const current = error instanceof SessionFormatError
+          ? error
+          : new SessionFormatError(`released Session row ${currentRow} is malformed`, { cause: error })
+        if (!recoverable) throw current
+        issue ??= current
+        return
+      }
+      if (issue !== undefined) {
+        if (!packed && (decoded as SessionFormatEvent).type === 'turn/end') throw issue
+        return
+      }
+      const seq = packed
+        ? (decoded as ReleasedAssistantChunkRun).firstSeq
+        : (decoded as SessionFormatEvent).seq
+      if (seq !== eventCount) {
+        const gap = new SessionFormatError(
+          `released Session row ${currentRow} has seq gap (expected ${eventCount}, got ${seq})`,
+        )
+        if (!recoverable) throw gap
+        issue = gap
+        if (!packed && (decoded as SessionFormatEvent).type === 'turn/end') throw gap
+        return
+      }
+      if (packed) {
+        const run = decoded as ReleasedAssistantChunkRun
+        eventCount += run.eventCount
+        context.emitRun(run)
+      } else {
+        eventCount += 1
+        context.emitEvent(decoded as SessionFormatEvent)
+      }
     },
-    encodeArtifact(artifact: SessionFormatArtifact, options: SessionFormatEncodeOptions) {
-      if (version === 0) assertReleasedV0SourceArtifact(artifact)
-      else assertReleasedV1PhysicalArtifact(artifact)
-      return encodeArtifact(artifact, options, version)
+    finish(inheritedEventCount) {
+      if (inheritedEventCount > eventCount) {
+        throw new SessionFormatError('Session inheritedEventCount exceeds its event count')
+      }
     },
-  } satisfies SessionFormatCodec & {
-    encodeArtifact(
-      artifact: SessionFormatArtifact,
-      options: SessionFormatEncodeOptions,
-    ): EncodedSessionFormatArtifact
-  })
+  }
 }
 
 function decodeHeader(value: unknown, version: 0 | 1): SessionFormatHeader {
@@ -109,7 +171,7 @@ function decodePhysicalHeader(
   if (record['origin'] !== undefined && record['origin'] !== 'subagent') {
     throw new SessionFormatError(`released v${version} header origin must be "subagent"`)
   }
-  const header = snapshotSessionFormatJson({
+  const header = {
     version,
     id: record['id'],
     createdAt,
@@ -119,101 +181,34 @@ function decodePhysicalHeader(
     ...(record['origin'] === undefined ? {} : { origin: record['origin'] }),
     delegationDepth,
     ...(record['agentPreset'] === undefined ? {} : { agentPreset: record['agentPreset'] }),
-  }, `released v${version} logical header`) as SessionFormatHeader
+  } as SessionFormatHeader
   assertReleasedSessionFormatHeader(header, version)
   return { header, inheritedEventCount: seedLength }
 }
 
-function encodeArtifact(
-  artifact: SessionFormatArtifact,
-  options: SessionFormatEncodeOptions,
-  version: 0 | 1,
-): EncodedSessionFormatArtifact {
-  const header = artifact.header
-  const physicalHeader = snapshotSessionFormatJson({
-    type: 'session',
-    version,
-    id: header.id,
-    createdAt: header.createdAt,
-    ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
-    ...(header.parentSession === undefined ? {} : { parentSession: header.parentSession }),
-    ...(header.isSeeded ? { seedLength: artifact.inheritedEventCount } : {}),
-    ...(header.origin === undefined ? {} : { origin: header.origin }),
-    delegationDepth: header.delegationDepth,
-    ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }),
-  }, `released v${version} encoded header`) as SessionFormatJsonObject
-  const records = options.packChunks ? packChunkRuns(artifact.events) : [...artifact.events]
-  const rows = Object.freeze(records.map(record => encodeProvenance(record)))
-  return Object.freeze({ header: physicalHeader, rows })
-}
-
-function scanRows(
-  rowValues: readonly unknown[],
-  recoverable: boolean,
-): { readonly events: readonly SessionFormatEvent[] } {
-  const events: SessionFormatEvent[] = []
-  let issue: SessionFormatError | undefined
-  for (const [rowIndex, value] of rowValues.entries()) {
-    let decoded: readonly SessionFormatEvent[]
-    try {
-      const row = snapshotSessionFormatJson(value, `released Session row ${rowIndex}`)
-      decoded = decodeRow(row, rowIndex)
-    } catch (error: unknown) {
-      const current = error instanceof SessionFormatError
-        ? error
-        : new SessionFormatError(`released Session row ${rowIndex} is malformed`, { cause: error })
-      if (!recoverable) throw current
-      issue ??= current
-      continue
-    }
-    if (issue !== undefined) {
-      if (decoded.some(event => event.type === 'turn/end')) throw issue
-      continue
-    }
-    const rowStart = events.length
-    for (const event of decoded) {
-      if (event.seq !== events.length) {
-        const gap = new SessionFormatError(
-          `released Session row ${rowIndex} has seq gap (expected ${events.length}, got ${event.seq})`,
-        )
-        events.length = rowStart
-        if (!recoverable) throw gap
-        issue = gap
-        break
-      }
-      events.push(event)
-    }
-    if (issue !== undefined) {
-      if (decoded.some(event => event.type === 'turn/end')) throw issue
-      continue
-    }
-  }
-  return Object.freeze({ events: Object.freeze(events) })
-}
-
-function decodeRow(value: SessionFormatJsonValue, rowIndex: number): readonly SessionFormatEvent[] {
-  const record = releasedV0Record(value, `released Session row ${rowIndex}`)
-  const type = record['type']
-  if (typeof type === 'string' && PACKED_TAGS.has(type)) return expandPackedRow(record, type, rowIndex)
+function decodeEvent(
+  record: Record<string, SessionFormatJsonValue>,
+  rowIndex: number,
+): SessionFormatEvent {
   if (record['sourceEventSeqs'] !== undefined) {
     const seq = sessionFormatCount(record['seq'], `released Session row ${rowIndex} seq`)
-    return Object.freeze([{
+    return {
       ...record,
       sourceEventSeqs: decodeSeqRanges(record['sourceEventSeqs'], seq),
-    } as unknown as SessionFormatEvent])
+    } as unknown as SessionFormatEvent
   }
-  return Object.freeze([record as unknown as SessionFormatEvent])
+  return record as unknown as SessionFormatEvent
 }
 
-function expandPackedRow(
+function decodePackedRun(
   row: Record<string, SessionFormatJsonValue>,
   type: string,
   rowIndex: number,
-): readonly SessionFormatEvent[] {
+): ReleasedAssistantChunkRun {
   const label = `released ${type} row ${rowIndex}`
   assertReleasedV0Keys(row, ['type', 'seq0', 'time0', 'data'], [], label)
   const seq0 = sessionFormatCount(row['seq0'], `${label} seq0`)
-  let time = sessionFormatSafeInteger(row['time0'], `${label} time0`)
+  const time0 = sessionFormatSafeInteger(row['time0'], `${label} time0`)
   const data = releasedV0Record(row['data'], `${label} data`)
   const isTool = type === 'tool-call-chunks'
   assertReleasedV0Keys(
@@ -230,37 +225,73 @@ function expandPackedRow(
   if (!Array.isArray(gaps) || gaps.length !== payload.length - 1) {
     throw new SessionFormatError(`${label} dt length must match its payload`)
   }
-  for (const gap of gaps) sessionFormatSafeInteger(gap, `${label} dt member`)
-  if (typeof data['turn'] !== 'number' || typeof data['step'] !== 'number' || typeof data['index'] !== 'number') {
-    throw new SessionFormatError(`${label} turn, step, and index must be numbers`)
+  let lastTime = time0
+  for (const gap of gaps) {
+    const validGap = sessionFormatSafeInteger(gap, `${label} dt member`)
+    lastTime = sessionFormatSafeInteger(lastTime + validGap, `${label} member time`)
   }
+  const turn = sessionFormatCount(data['turn'], `${label} turn`)
+  const step = sessionFormatCount(data['step'], `${label} step`)
+  const chunkIndex = sessionFormatCount(data['index'], `${label} index`)
   if (isTool && (typeof data['id'] !== 'string'
+    || data['id'].length === 0
     || (data['name'] !== undefined && typeof data['name'] !== 'string'))) {
     throw new SessionFormatError(`${label} id and optional name must be strings`)
   }
-  const output: SessionFormatEvent[] = []
-  for (let index = 0; index < payload.length; index += 1) {
-    if (index > 0) time = sessionFormatSafeInteger(time + (gaps[index - 1] as number), `${label} member time`)
-    const member = payload[index] as string
-    const chunk = type === 'text-chunks'
-      ? { type: 'text-delta', index: data['index'], text: member }
-      : type === 'reasoning-chunks'
-        ? { type: 'reasoning-delta', index: data['index'], text: member }
+  const lastSeq = sessionFormatCount(seq0 + payload.length - 1, `${label} final seq`)
+  const stream = (type === 'tool-call-chunks'
+    ? {
+      type,
+      time0,
+      index: chunkIndex,
+      dt: gaps,
+      id: data['id'],
+      ...(data['name'] === undefined ? {} : { name: data['name'] }),
+      args: payload,
+    }
+    : { type, time0, index: chunkIndex, dt: gaps, texts: payload }) as SessionFormatJsonObject
+  const run: ReleasedAssistantChunkRun = {
+    runType: 'released-assistant-chunks',
+    firstSeq: seq0,
+    eventCount: payload.length,
+    turn,
+    step,
+    lastSeq,
+    lastTime,
+    stream,
+    expand: () => expandAssistantChunkRun(run),
+  }
+  return run
+}
+
+function* expandAssistantChunkRun(run: ReleasedAssistantChunkRun): Iterable<SessionFormatEvent> {
+  const stream = run.stream
+  const gaps = stream['dt'] as readonly number[]
+  const members = stream['type'] === 'tool-call-chunks'
+    ? stream['args'] as readonly string[]
+    : stream['texts'] as readonly string[]
+  let time = run.stream['time0'] as number
+  for (let index = 0; index < members.length; index += 1) {
+    if (index > 0) time += gaps[index - 1] as number
+    const member = members[index] as string
+    const chunk = stream['type'] === 'text-chunks'
+      ? { type: 'text-delta', index: stream['index'], text: member }
+      : stream['type'] === 'reasoning-chunks'
+        ? { type: 'reasoning-delta', index: stream['index'], text: member }
         : {
           type: 'tool-call-delta',
-          index: data['index'],
-          id: data['id'],
-          ...(data['name'] === undefined ? {} : { name: data['name'] }),
+          index: stream['index'],
+          id: stream['id'],
+          ...(stream['name'] === undefined ? {} : { name: stream['name'] }),
           argumentsDelta: member,
         }
-    output.push(snapshotSessionFormatJson({
+    yield {
       type: 'assistant/chunk',
-      seq: sessionFormatCount(seq0 + index, `${label} member seq`),
+      seq: run.firstSeq + index,
       time,
-      data: { turn: data['turn'], step: data['step'], chunk },
-    }, `${label} member`) as SessionFormatEvent)
+      data: { turn: run.turn, step: run.step, chunk },
+    } as SessionFormatEvent
   }
-  return Object.freeze(output)
 }
 
 function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly SessionFormatJsonValue[] {
@@ -287,130 +318,5 @@ function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): rea
   if (hasRange && output.some((member, index) => index > 0 && member <= (output[index - 1] as number))) {
     throw new SessionFormatError('sourceEventSeqs ranges must be strictly increasing')
   }
-  return Object.freeze(output)
-}
-
-function encodeProvenance(record: SessionFormatEvent | SessionFormatJsonObject): SessionFormatJsonObject {
-  if (!Object.hasOwn(record, 'sourceEventSeqs')) return record
-  const sourceEventSeqs = record['sourceEventSeqs'] as readonly SessionFormatJsonValue[]
-  const values = sourceEventSeqs.map(value => sessionFormatCount(value, 'sourceEventSeqs member'))
-  return snapshotSessionFormatJson({ ...record, sourceEventSeqs: encodeSeqRanges(values) }) as SessionFormatJsonObject
-}
-
-function encodeSeqRanges(values: readonly number[]): readonly SessionFormatJsonValue[] {
-  if (values.some((value, index) => index > 0 && value <= (values[index - 1] as number))) return Object.freeze([...values])
-  const output: SessionFormatJsonValue[] = []
-  for (let start = 0; start < values.length;) {
-    let end = start
-    while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1
-    if (end - start >= 2) output.push(Object.freeze([values[start] as number, values[end] as number]))
-    else for (let index = start; index <= end; index += 1) output.push(values[index] as number)
-    start = end + 1
-  }
-  return Object.freeze(output)
-}
-
-type ChunkKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
-
-function packChunkRuns(events: readonly SessionFormatEvent[]): readonly (SessionFormatEvent | SessionFormatJsonObject)[] {
-  const output: Array<SessionFormatEvent | SessionFormatJsonObject> = []
-  let kind: ChunkKind | undefined
-  let run: SessionFormatEvent[] = []
-  const flush = (): void => {
-    if (kind !== undefined && run.length >= 3) output.push(buildPackedRow(kind, run))
-    else output.push(...run)
-    kind = undefined
-    run = []
-  }
-  for (const event of events) {
-    const candidate = classifyChunk(event)
-    const previous = run.at(-1)
-    if (candidate !== undefined && candidate === kind && previous !== undefined && continuesChunk(previous, event, candidate)) {
-      run.push(event)
-      continue
-    }
-    flush()
-    if (candidate === undefined) output.push(event)
-    else {
-      kind = candidate
-      run = [event]
-    }
-  }
-  flush()
-  return Object.freeze(output)
-}
-
-function classifyChunk(event: SessionFormatEvent): ChunkKind | undefined {
-  if (event.type !== 'assistant/chunk' || !hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined
-  const data = event.data
-  if (!isSessionFormatJsonObject(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined
-  const chunk = data['chunk']
-  if (!isSessionFormatJsonObject(chunk)
-    || typeof chunk['index'] !== 'number'
-    || typeof chunk['type'] !== 'string') return undefined
-  if (chunk['type'] === 'text-delta' || chunk['type'] === 'reasoning-delta') {
-    return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk['text'] === 'string'
-      ? chunk['type']
-      : undefined
-  }
-  if (chunk['type'] !== 'tool-call-delta') return undefined
-  const exact = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
-    || hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta'])
-  return exact && typeof chunk['id'] === 'string'
-    && typeof chunk['argumentsDelta'] === 'string'
-    && (chunk['name'] === undefined || typeof chunk['name'] === 'string')
-    ? 'tool-call-delta'
-    : undefined
-}
-
-function continuesChunk(previous: SessionFormatEvent, next: SessionFormatEvent, kind: ChunkKind): boolean {
-  const previousData = previous.data as SessionFormatJsonObject
-  const nextData = next.data as SessionFormatJsonObject
-  const previousChunk = previousData['chunk'] as SessionFormatJsonObject
-  const nextChunk = nextData['chunk'] as SessionFormatJsonObject
-  if (!Number.isSafeInteger(next.time - previous.time)) return false
-  if (nextData['turn'] !== previousData['turn'] || nextData['step'] !== previousData['step']) return false
-  if (nextChunk['index'] !== previousChunk['index']) return false
-  if (kind !== 'tool-call-delta') return true
-  return nextChunk['id'] === previousChunk['id']
-    && Object.hasOwn(nextChunk, 'name') === Object.hasOwn(previousChunk, 'name')
-    && nextChunk['name'] === previousChunk['name']
-}
-
-function buildPackedRow(kind: ChunkKind, run: readonly SessionFormatEvent[]): SessionFormatJsonObject {
-  const first = run[0] as SessionFormatEvent
-  const firstData = first.data as SessionFormatJsonObject
-  const firstChunk = firstData['chunk'] as SessionFormatJsonObject
-  const base = {
-    turn: firstData['turn'],
-    step: firstData['step'],
-    index: firstChunk['index'],
-    dt: run.slice(1).map((event, index) => event.time - (run[index] as SessionFormatEvent).time),
-  }
-  if (kind === 'tool-call-delta') {
-    return snapshotSessionFormatJson({
-      type: 'tool-call-chunks',
-      seq0: first.seq,
-      time0: first.time,
-      data: {
-        ...base,
-        id: firstChunk['id'],
-        ...(firstChunk['name'] === undefined ? {} : { name: firstChunk['name'] }),
-        args: run.map(event => ((event.data as SessionFormatJsonObject)['chunk'] as SessionFormatJsonObject)['argumentsDelta']),
-      },
-    }) as SessionFormatJsonObject
-  }
-  return snapshotSessionFormatJson({
-    type: kind === 'text-delta' ? 'text-chunks' : 'reasoning-chunks',
-    seq0: first.seq,
-    time0: first.time,
-    data: {
-      ...base,
-      texts: run.map(event => ((event.data as SessionFormatJsonObject)['chunk'] as SessionFormatJsonObject)['text']),
-    },
-  }) as SessionFormatJsonObject
-}
-
-function hasExactKeys(record: Readonly<Record<string, unknown>>, keys: readonly string[]): boolean {
-  return Object.keys(record).length === keys.length && keys.every(key => Object.hasOwn(record, key))
+  return output
 }

+ 1 - 1
packages/session/session-format-v0-to-v1/src/index.ts

@@ -7,7 +7,7 @@ export { assertReleasedPayloadSemantics } from './payload-validation.ts'
 export { assertReleasedArtifactRelationships } from './relationships.ts'
 export {
   assertReleasedSurfaceMetadata,
-  assertReleasedV1Artifact,
+  assertReleasedEventPayload,
   assertReleasedV1Header,
   restoreReleasedV1Artifact,
 } from './validation.ts'

+ 168 - 33
packages/session/session-format-v0-to-v1/src/migration.ts

@@ -3,19 +3,20 @@ import {
   SessionFormatUnsupportedMigrationError,
   defineSessionFormatMigration,
   sessionFormatCount,
-  snapshotSessionFormatArtifact,
 } from '@deepseek-ai/dsh-session-format'
 import type {
   SessionFormatEvent,
+  SessionFormatEventRun,
   SessionFormatHeader,
   SessionFormatJsonObject,
   SessionFormatJsonValue,
+  SessionFormatMigrationContext,
+  SessionFormatMigrationStage,
+  SessionFormatMigrationStageInput,
 } from '@deepseek-ai/dsh-session-format'
+import { isReleasedAssistantChunkRun } from './codec.ts'
 import {
   assertReleasedEventPayload,
-  assertNormalizedReleasedV0Artifact,
-  assertReleasedV0SourceArtifact,
-  assertReleasedV1Artifact,
   assertReleasedV1Header,
 } from './validation.ts'
 import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts'
@@ -29,45 +30,179 @@ export const sessionFormatV0ToV1 = defineSessionFormatMigration({
     assertHeaderVersion(header, 0)
     return { ...header, version: 1 }
   },
-  migrate(source) {
-    assertReleasedV0SourceArtifact(source)
-    const events = normalizeReleasedV0Events(source.events, source.header.id)
-    assertNormalizedReleasedV0Artifact({ ...source, events })
-    const target = snapshotSessionFormatArtifact({
-      header: { ...source.header, version: 1 },
-      inheritedEventCount: source.inheritedEventCount,
-      events,
-    }, 'released v0-to-v1 target')
-    assertReleasedV1Artifact(target)
-    return target
+  createStage(input) {
+    return new ReleasedV0ToV1Stage(input)
   },
-  validateTarget: assertReleasedV1Artifact,
   validateTargetHeader: assertReleasedV1Header,
 })
 
+class ReleasedV0ToV1Stage implements SessionFormatMigrationStage {
+  readonly headerInheritedEventCount: number
+  private readonly state: LegacyNormalizationState = { messageIds: new Map(), retryIds: new Map() }
+
+  constructor(private readonly input: SessionFormatMigrationStageInput) {
+    assertHeaderVersion(input.sourceHeader, 0)
+    this.headerInheritedEventCount = input.sourceInheritedEventCount
+  }
+
+  transformEvent(
+    event: SessionFormatEvent,
+    context: SessionFormatMigrationContext,
+  ): void {
+    const normalized = normalizeReleasedV0Event(event, this.input.sourceHeader.id, this.state)
+    assertSourceDeliveryMarker(normalized, this.input)
+    context.emitEvent(normalized)
+  }
+
+  transformRun(
+    run: SessionFormatEventRun,
+    context: SessionFormatMigrationContext,
+  ): void {
+    if (isReleasedAssistantChunkRun(run)) {
+      context.emitRun(run)
+      return
+    }
+    for (const event of run.expand()) this.transformEvent(event, context)
+  }
+
+  finish(_context: SessionFormatMigrationContext): number {
+    return this.input.sourceInheritedEventCount
+  }
+}
+
 function assertHeaderVersion(header: SessionFormatHeader, version: 0 | 1): void {
   if (header.version !== version) throw new SessionFormatError(`expected format v${version} header`)
 }
 
-function normalizeReleasedV0Events(
-  events: readonly SessionFormatEvent[],
+interface LegacyNormalizationState {
+  readonly messageIds: Map<number, string>
+  readonly retryIds: Map<string, string>
+  compactionId?: string
+}
+
+function normalizeReleasedV0Event(
+  event: SessionFormatEvent,
+  sessionId: string,
+  state: LegacyNormalizationState,
+): SessionFormatEvent {
+  const named = normalizeLegacyCompactionType(event)
+  assertSupportedLegacyType(named, sessionId)
+  const start = normalizeLegacyTurnStart(named, sessionId)
+  const end = normalizeLegacyTurnEnd(start, sessionId)
+  const header = normalizeLegacyRequestHeader(end, sessionId)
+  const steering = normalizeLegacySteering(header, sessionId)
+  const retry = normalizeLegacyRetry(steering, sessionId, state.retryIds)
+  const compaction = normalizeLegacyCompaction(retry, sessionId, state)
+  const message = normalizeLegacyMessage(compaction, sessionId, state.messageIds)
+  if (message.type !== 'assistant/chunk') assertReleasedEventPayload(message, 0)
+  const messageId = eventMessageId(message)
+  if (messageId !== undefined) state.messageIds.set(message.seq, messageId)
+  return message
+}
+
+function normalizeLegacyCompactionType(event: SessionFormatEvent): SessionFormatEvent {
+  const type: string = event.type
+  switch (type) {
+    case 'compact/start':
+      return { ...event, type: 'compaction/start' }
+    case 'compact/summary':
+      return { ...event, type: 'compaction/summary' }
+    case 'compact/end':
+      return { ...event, type: 'compaction/end' }
+    case 'compact/prune':
+      return { ...event, type: 'compaction/prune' }
+    default:
+      return event
+  }
+}
+
+function assertSourceDeliveryMarker(
+  event: SessionFormatEvent,
+  input: SessionFormatMigrationStageInput,
+): void {
+  if (event.type !== 'session-log-deepseek/delivery-accepted') return
+  const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`)
+  const acceptedVersion = data['sessionFormatVersion'] ?? 0
+  const inherited = input.sourceHeader.parentSession !== undefined
+    && event.seq < input.sourceInheritedEventCount
+  if (acceptedVersion === 0 && !inherited && data['sessionId'] !== input.sourceHeader.id) {
+    throw new SessionFormatError('current-generation delivery marker names the wrong Session')
+  }
+}
+
+function normalizeLegacyRetry(
+  event: SessionFormatEvent,
   sessionId: string,
-): readonly SessionFormatEvent[] {
-  const messageIds = new Map<number, string>()
-  const output: SessionFormatEvent[] = []
-  for (const event of events) {
-    assertSupportedLegacyType(event, sessionId)
-    const start = normalizeLegacyTurnStart(event, sessionId)
-    const end = normalizeLegacyTurnEnd(start, sessionId)
-    const header = normalizeLegacyRequestHeader(end, sessionId)
-    const steering = normalizeLegacySteering(header, sessionId)
-    const message = normalizeLegacyMessage(steering, sessionId, messageIds)
-    assertReleasedEventPayload(message, 0)
-    output.push(message)
-    const messageId = eventMessageId(message)
-    if (messageId !== undefined) messageIds.set(message.seq, messageId)
+  retryIds: Map<string, string>,
+): SessionFormatEvent {
+  if (event.type !== 'llm/retry') return event
+  const data = releasedV0Record(event.data, `llm/retry ${event.seq} data`)
+  const chain = [data['turn'], data['step'], data['provider'], data['policyKey']]
+    .map(value => JSON.stringify(value))
+    .join('\0')
+  const retryId = data['retryId']
+  if (typeof retryId === 'string' && retryId.length > 0) {
+    retryIds.set(chain, retryId)
+    return event
   }
-  return Object.freeze(output)
+  if (Object.hasOwn(data, 'retryId')) return event
+  const migratedRetryId = retryIds.get(chain) ?? `legacy-retry:${sessionId}:${event.seq}`
+  retryIds.set(chain, migratedRetryId)
+  return { ...event, data: { ...data, retryId: migratedRetryId } }
+}
+
+function normalizeLegacyCompaction(
+  event: SessionFormatEvent,
+  sessionId: string,
+  state: LegacyNormalizationState,
+): SessionFormatEvent {
+  if (event.type === 'session/end-seed') {
+    delete state.compactionId
+    return event
+  }
+  if (event.type === 'compaction/start') {
+    const data = releasedV0Record(event.data, `compaction/start ${event.seq} data`)
+    const existing = data['compactionId']
+    if (typeof existing === 'string' && existing.length > 0) {
+      state.compactionId = existing
+      return event
+    }
+    if (Object.hasOwn(data, 'compactionId')) return event
+    const id = `legacy-compaction:${sessionId}:${event.seq}`
+    state.compactionId = id
+    return { ...event, data: { ...data, compactionId: id } }
+  }
+  const compactionId = state.compactionId
+  if (compactionId === undefined) return event
+  if (event.type === 'compaction/summary' || event.type === 'compaction/end') {
+    const normalized = addLegacyCompactionId(event, compactionId)
+    if (event.type === 'compaction/end') delete state.compactionId
+    return normalized
+  }
+  if (event.type !== 'user/message') return event
+  const data = releasedV0Record(event.data, `user/message ${event.seq} data`)
+  const source = data['source']
+  if (!releasedIsRecord(source) || source['kind'] !== 'plugin' || source['plugin'] !== 'compact'
+    || Object.hasOwn(source, 'compactionId')) return event
+  return {
+    ...event,
+    data: {
+      ...data,
+      source: {
+        ...source,
+        compactionId,
+      },
+    },
+  }
+}
+
+function addLegacyCompactionId(
+  event: SessionFormatEvent,
+  compactionId: string,
+): SessionFormatEvent {
+  const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`)
+  if (Object.hasOwn(data, 'compactionId')) return event
+  return { ...event, data: { ...data, compactionId } }
 }
 
 function normalizeLegacyRequestHeader(event: SessionFormatEvent, sessionId: string): SessionFormatEvent {

+ 35 - 1
packages/session/session-format-v0-to-v1/src/payload-validation.ts

@@ -1,4 +1,5 @@
 import { SessionFormatError, sessionFormatCount, sessionFormatSafeInteger } from '@deepseek-ai/dsh-session-format'
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 import type {
   SessionFormatEvent,
   SessionFormatJsonValue,
@@ -489,7 +490,12 @@ function messageValue(
   if (role === undefined) literalValue(message['role'], ['system', 'user', 'assistant'], `${label} role`)
   else literalValue(message['role'], [role], `${label} role`)
   contentBlocksValue(message['content'], `${label} content`, version)
-  messageSourceValue(message['source'], `${label} source`, version, expected)
+  const source = releasedV0Record(message['source'], `${label} source`)
+  if (version < 2 && expected === 'user' && source['kind'] === 'goal' && source['change'] !== undefined) {
+    legacyGoalMessageValue(message, source, label)
+  } else {
+    messageSourceValue(source, `${label} source`, version, expected)
+  }
   if (expected === 'tool') {
     const content = message['content']
     const block = Array.isArray(content) && content.length === 1
@@ -502,6 +508,34 @@ function messageValue(
   }
 }
 
+function legacyGoalMessageValue(message: JsonRecord, source: JsonRecord, label: string): void {
+  assertReleasedV0Keys(source, ['kind', 'goalId', 'revision', 'round', 'change'], [], `${label} source`)
+  nonEmptyString(source['goalId'], `${label} source goalId`)
+  positiveIntegerValue(source['revision'], `${label} source revision`)
+  if (source['round'] !== 0) throw new SessionFormatError(`${label} legacy goal source round must be 0`)
+  const change = releasedV0Record(source['change'], `${label} source change`)
+  goalChangeValue(change, `${label} source change`)
+  const ref = releasedV0Record(
+    change['operation'] === 'clear' ? change['cleared'] : change['goal'],
+    `${label} source change ref`,
+  )
+  if (source['goalId'] !== ref['id'] || source['revision'] !== ref['revision']) {
+    throw new SessionFormatError(`${label} legacy goal source does not match its change`)
+  }
+  const payload = change['operation'] === 'clear'
+    ? { cleared: change['cleared'], clearedAt: change['clearedAt'] }
+    : {
+      goal: change['goal'],
+      roundsStarted: change['roundsStarted'],
+      createdAt: change['createdAt'],
+      updatedAt: change['updatedAt'],
+    }
+  const expected = [{ type: 'text', text: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
+  if (!deepEqualJson(message['content'], expected)) {
+    throw new SessionFormatError(`${label} legacy goal content does not match its change`)
+  }
+}
+
 function messageSourceValue(
   value: SessionFormatJsonValue | undefined,
   label: string,

+ 24 - 2
packages/session/session-format-v0-to-v1/src/relationships.ts

@@ -34,6 +34,8 @@ export interface ReleasedRelationshipExtensions {
   readonly stepEvents?: ReadonlySet<string>
   /** Title-request model input was source-validated and preserved across sequence remapping. */
   readonly preservedSourceTitleRequestText?: true
+  /** Admit the released resume pattern whose next-turn inbox insert omitted the prior turn/end. */
+  readonly legacyInterruptedTurnRestart?: true
 }
 
 /**
@@ -75,7 +77,22 @@ export function assertReleasedArtifactRelationships(
     }
 
     switch (event.type) {
-      case 'turn/start':
+      case 'turn/start': {
+        const previous = artifact.events[event.seq - 1]
+        if (extensions.legacyInterruptedTurnRestart === true
+          && openTurn !== null
+          && openStep === null
+          && data['turn'] === openTurn + 1
+          && nextTurn === openTurn
+          && previous?.type === 'agent/inbox/spliced') {
+          const splice = releasedV0Record(previous.data, `agent/inbox/spliced ${previous.seq} data`)
+          if (splice['target'] === 'next-turn'
+            && Array.isArray(splice['inserted'])
+            && splice['inserted'].length > 0) {
+            openTurn = null
+            nextTurn += 1
+          }
+        }
         if (openTurn !== null || data['turn'] !== nextTurn) {
           throw new SessionFormatError(`turn/start ${JSON.stringify(data['turn'])} does not open expected turn ${nextTurn}`)
         }
@@ -84,6 +101,7 @@ export function assertReleasedArtifactRelationships(
         toolLifecycles.clear()
         nextStep = 1
         break
+      }
       case 'turn/end':
         if (openTurn !== data['turn']) {
           throw new SessionFormatError(`turn/end ${JSON.stringify(data['turn'])} has no matching open turn`)
@@ -200,7 +218,11 @@ export function assertReleasedArtifactRelationships(
         break
       }
       case 'llm/retry':
-        requireOpenStep(event, data, openTurn, openStep)
+        if (openTurn !== data['turn']
+          || data['step'] !== (openStep ?? nextStep - 1)
+          || openTurn === null) {
+          throw new SessionFormatError('llm/retry does not match the current turn and step')
+        }
         if (data['provider'] !== openStepProvider) {
           throw new SessionFormatError('llm/retry provider does not match the open request/header')
         }

+ 81 - 0
packages/session/session-format-v0-to-v1/src/testing/restore.ts

@@ -0,0 +1,81 @@
+import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
+import type {
+  SessionFormatArtifact,
+  SessionFormatCurrentEncoder,
+  SessionFormatRecovery,
+} from '@deepseek-ai/dsh-session-format'
+import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec } from '../codec.ts'
+import { sessionFormatV0ToV1 } from '../migration.ts'
+import { assertReleasedV1Header } from '../validation.ts'
+import {
+  assertReleasedV1Artifact,
+  assertReleasedV1MigrationSource,
+} from './validation.ts'
+
+const unusedEncoder: SessionFormatCurrentEncoder = {
+  /* v8 ignore next -- this restore-only test facade never encodes a header. */
+  encodeHeader: header => header,
+  /* v8 ignore next -- this restore-only test facade never encodes an event. */
+  encodeEvent: event => event,
+}
+
+const catalog = createSessionFormatCatalog({
+  currentVersion: 1,
+  codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec],
+  currentEncoder: unusedEncoder,
+  migrations: [sessionFormatV0ToV1],
+  restoreCurrent(artifact) {
+    assertReleasedV1Artifact(artifact)
+    return artifact
+  },
+  restoreTransformedCurrent(artifact) {
+    assertReleasedV1MigrationSource(artifact)
+    return artifact
+  },
+  /* v8 ignore next 3 -- this restore-only test facade never performs a header-only read. */
+  restoreCurrentHeader(header) {
+    assertReleasedV1Header(header)
+    return header
+  },
+})
+
+function restore(
+  header: unknown,
+  rows: readonly unknown[],
+  recovery: SessionFormatRecovery,
+  validation: 'transformed' | 'current',
+): SessionFormatArtifact {
+  const current = catalog.createRestore(header, { recovery, validation })
+  for (const row of rows) current.decodeRow(row)
+  return current.finish()
+}
+
+/**
+ * Restore released v0 rows through the production decoder and v0-to-v1 stage.
+ * @param header - released v0 physical header.
+ * @param rows - released v0 physical rows.
+ * @param recovery - strict or recoverable row policy.
+ * @returns the transformed released v1 artifact.
+ */
+export function restoreV0ToV1(
+  header: unknown,
+  rows: readonly unknown[],
+  recovery: SessionFormatRecovery = 'strict',
+): SessionFormatArtifact {
+  return restore(header, rows, recovery, 'transformed')
+}
+
+/**
+ * Restore released v1 rows through the production decoder and current collector.
+ * @param header - released v1 physical header.
+ * @param rows - released v1 physical rows.
+ * @param recovery - strict or recoverable row policy.
+ * @returns the restored released v1 artifact.
+ */
+export function restoreV1(
+  header: unknown,
+  rows: readonly unknown[],
+  recovery: SessionFormatRecovery = 'strict',
+): SessionFormatArtifact {
+  return restore(header, rows, recovery, 'current')
+}

+ 65 - 0
packages/session/session-format-v0-to-v1/src/testing/validation.ts

@@ -0,0 +1,65 @@
+import type { SessionFormatArtifact } from '@deepseek-ai/dsh-session-format'
+import { RELEASED_V0_EVENT_DISPOSITIONS } from '../dispositions.ts'
+import { assertReleasedArtifactRelationships } from '../relationships.ts'
+import {
+  assertReleasedArtifactCoordinates,
+  assertReleasedEventPayload,
+  assertReleasedSessionFormatHeader,
+} from '../validation.ts'
+
+const RELEASED_V0_EVENT_TYPE_SET: ReadonlySet<string> = new Set(Object.keys(RELEASED_V0_EVENT_DISPOSITIONS))
+
+/**
+ * Validate one decoded released-v0 source artifact in tests.
+ * @param artifact - released-v0 source artifact.
+ */
+export function assertReleasedV0SourceArtifact(artifact: SessionFormatArtifact): void {
+  assertReleasedSessionFormatHeader(artifact.header, 0)
+  assertReleasedArtifactCoordinates(artifact, true, RELEASED_V0_EVENT_TYPE_SET, false, true)
+}
+
+/**
+ * Validate normalized v0 output before the identity header bump in tests.
+ * @param artifact - normalized v0 artifact.
+ */
+export function assertNormalizedReleasedV0Artifact(artifact: SessionFormatArtifact): void {
+  assertReleasedSessionFormatHeader(artifact.header, 0)
+  assertReleasedArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET, false, true)
+  for (const event of artifact.events) assertReleasedEventPayload(event, 0)
+  assertReleasedArtifactRelationships(artifact, { legacyInterruptedTurnRestart: true })
+}
+
+/**
+ * Validate v1 input accepted by the v1-to-v2 migration in tests.
+ * @param artifact - v1 migration source artifact.
+ */
+export function assertReleasedV1MigrationSource(artifact: SessionFormatArtifact): void {
+  assertReleasedSessionFormatHeader(artifact.header, 1)
+  assertReleasedArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET, false, true)
+  for (const event of artifact.events) {
+    if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) assertReleasedEventPayload(event, 1)
+  }
+  assertReleasedArtifactRelationships(artifact, { legacyInterruptedTurnRestart: true })
+}
+
+/**
+ * Validate the exact released-v1 logical artifact in tests.
+ * @param artifact - released-v1 logical artifact.
+ */
+export function assertReleasedV1Artifact(artifact: SessionFormatArtifact): void {
+  assertReleasedSessionFormatHeader(artifact.header, 1)
+  assertReleasedArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET, false, true)
+  for (const event of artifact.events) {
+    if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) assertReleasedEventPayload(event, 1)
+  }
+  assertReleasedArtifactRelationships(artifact)
+}
+
+/**
+ * Validate released-v1 physical decoding without interpreting event vocabulary in tests.
+ * @param artifact - released-v1 physical artifact.
+ */
+export function assertReleasedV1PhysicalArtifact(artifact: SessionFormatArtifact): void {
+  assertReleasedSessionFormatHeader(artifact.header, 1)
+  assertReleasedArtifactCoordinates(artifact, false, undefined, true)
+}

+ 24 - 51
packages/session/session-format-v0-to-v1/src/validation.ts

@@ -4,8 +4,8 @@ import {
   SessionFormatUnsupportedMigrationError,
   sessionFormatCount,
   sessionFormatSafeInteger,
-  snapshotSessionFormatJson,
 } from '@deepseek-ai/dsh-session-format'
+import { isJsonValue } from '@deepseek-ai/dsh-util-values'
 import type {
   SessionFormatArtifact,
   SessionFormatEvent,
@@ -14,7 +14,6 @@ import type {
 } from '@deepseek-ai/dsh-session-format'
 import { RELEASED_V0_EVENT_DISPOSITIONS } from './dispositions.ts'
 import { assertReleasedPayloadSemantics } from './payload-validation.ts'
-import { assertReleasedArtifactRelationships } from './relationships.ts'
 import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts'
 
 const HEADER_REQUIRED = ['version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const
@@ -23,8 +22,15 @@ const EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const
 const SURFACE_EVENT_TYPES = new Set(['user/message', 'assistant/message', 'tool/result'])
 const SURFACE_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const
 const LOG_OPTIONAL = ['ignorable'] as const
-const LEGACY_SOURCE_TYPES = new Set(['steering/message', 'request/header-delta', 'mode/set'])
-const RELEASED_V0_EVENT_TYPE_SET: ReadonlySet<string> = new Set(Object.keys(RELEASED_V0_EVENT_DISPOSITIONS))
+const LEGACY_SOURCE_TYPES = new Set([
+  'steering/message',
+  'request/header-delta',
+  'mode/set',
+  'compact/start',
+  'compact/summary',
+  'compact/end',
+  'compact/prune',
+])
 
 /**
  * Validate the logical header shared by released v0 and v1.
@@ -62,39 +68,6 @@ export function assertReleasedV1Header(header: SessionFormatHeader): void {
   assertReleasedSessionFormatHeader(header, 1)
 }
 
-/**
- * Validate v0 before historical normalizers run.
- * @param artifact - decoded released-v0 source.
- */
-export function assertReleasedV0SourceArtifact(artifact: SessionFormatArtifact): void {
-  assertReleasedSessionFormatHeader(artifact.header, 0)
-  assertArtifactCoordinates(artifact, true, RELEASED_V0_EVENT_TYPE_SET)
-}
-
-/**
- * Validate normalized v0 events before the identity header version changes.
- * @param artifact - normalized released-v0 artifact.
- */
-export function assertNormalizedReleasedV0Artifact(artifact: SessionFormatArtifact): void {
-  assertReleasedSessionFormatHeader(artifact.header, 0)
-  assertArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET)
-  for (const event of artifact.events) assertReleasedEventPayload(event, 0)
-  assertReleasedArtifactRelationships(artifact)
-}
-
-/**
- * Validate the exact logical image emitted by the released v1 writer.
- * @param artifact - decoded or migration-produced v1 artifact.
- */
-export function assertReleasedV1Artifact(artifact: SessionFormatArtifact): void {
-  assertReleasedV1Header(artifact.header)
-  assertArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET)
-  for (const event of artifact.events) {
-    if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) assertReleasedEventPayload(event, 1)
-  }
-  assertReleasedArtifactRelationships(artifact)
-}
-
 /**
  * Restore v1 against the installed build's ordinary event vocabulary without freezing payload additions.
  * @param artifact - vocabulary-neutral released-v1 physical decode.
@@ -106,24 +79,24 @@ export function restoreReleasedV1Artifact(
   knownEventTypes: ReadonlySet<string>,
 ): SessionFormatArtifact {
   assertReleasedV1Header(artifact.header)
-  assertArtifactCoordinates(artifact, false, knownEventTypes)
+  assertReleasedArtifactCoordinates(artifact, false, knownEventTypes)
   return artifact
 }
 
 /**
- * Validate released-v1 physical layout without interpreting event vocabulary.
- * @param artifact - physical-codec output.
+ * Validate released-v0/v1 artifact coordinates under an explicit vocabulary policy.
+ * @param artifact - logical artifact to validate.
+ * @param allowLegacySteering - whether to accept the retired steering event name.
+ * @param knownEventTypes - installed event vocabulary, when vocabulary-aware.
+ * @param vocabularyNeutral - whether unknown event types remain opaque.
+ * @param frozenEnvelope - whether event envelopes must already be frozen.
  */
-export function assertReleasedV1PhysicalArtifact(artifact: SessionFormatArtifact): void {
-  assertReleasedV1Header(artifact.header)
-  assertArtifactCoordinates(artifact, false, undefined, true)
-}
-
-function assertArtifactCoordinates(
+export function assertReleasedArtifactCoordinates(
   artifact: SessionFormatArtifact,
   allowLegacySteering: boolean,
   knownEventTypes?: ReadonlySet<string>,
   vocabularyNeutral = false,
+  frozenEnvelope = false,
 ): void {
   const inheritedEventCount = sessionFormatCount(artifact.inheritedEventCount, 'Session inheritedEventCount')
   if (inheritedEventCount > artifact.events.length) {
@@ -151,7 +124,6 @@ function assertArtifactCoordinates(
         `format v1 contains unknown required event type ${JSON.stringify(type)} at seq ${index}`,
       )
     }
-    const frozenEnvelope = !vocabularyNeutral && knownEventTypes === RELEASED_V0_EVENT_TYPE_SET
     const surface = disposition !== undefined ? SURFACE_EVENT_TYPES.has(type) : type === 'steering/message'
     const optional = frozenEnvelope
       ? surface ? SURFACE_OPTIONAL : LOG_OPTIONAL
@@ -195,8 +167,7 @@ export function assertReleasedSurfaceMetadata(
       }
       seen.add(current)
     }
-    if (sources.length === 0
-      && (type !== 'assistant/message' || assistantSources === 'forbid-assistant')) {
+    if (sources.length === 0 && type !== 'assistant/message') {
       throw new SessionFormatError(`${type} ${seq} sourceEventSeqs must be non-empty`)
     }
   }
@@ -220,7 +191,7 @@ export function assertReleasedEventPayload(event: SessionFormatEvent, version: 0
   /* v8 ignore next -- artifact coordinate validation admits only the frozen inventory before payload validation. */
   if (disposition === undefined) {
     throw new SessionFormatUnsupportedMigrationError(
-      `format v0 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`,
+      `format v0 contains unknown historical event type ${JSON.stringify(event.type)} at seq ${event.seq}; migration refuses unknown historical events even when ignorable`,
     )
   }
   const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`)
@@ -238,7 +209,9 @@ export function assertReleasedEventPayload(event: SessionFormatEvent, version: 0
     : disposition.optional
   assertReleasedV0Keys(data, disposition.required, versionOptional, `${event.type} ${event.seq} data`)
   for (const key of disposition.opaque) {
-    if (Object.hasOwn(data, key)) snapshotSessionFormatJson(data[key], `${event.type} ${event.seq} opaque ${key}`)
+    if (Object.hasOwn(data, key) && !isJsonValue(data[key])) {
+      throw new SessionFormatError(`${event.type} ${event.seq} opaque ${key} is not lossless JSON`)
+    }
   }
   assertReleasedPayloadSemantics(event, version)
 }

+ 151 - 143
packages/session/session-format-v0-to-v1/tests/codec.spec.ts

@@ -1,5 +1,14 @@
 import { describe, expect, it } from 'vitest'
-import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
+import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
+import type {
+  SessionFormatArtifact,
+  SessionFormatArtifactDecoder,
+  SessionFormatEvent,
+  SessionFormatEventRun,
+  SessionFormatJsonValue,
+  SessionFormatMigrationContext,
+  SessionFormatRecovery,
+} from '@deepseek-ai/dsh-session-format'
 import {
   releasedV0SessionFormatCodec,
   releasedV1SessionFormatCodec,
@@ -11,33 +20,42 @@ const fullHeader = {
 } as const
 const textBlock = { type: 'text', text: 'text' } as const
 
-function chunk(
-  seq: number,
-  type: 'text-delta' | 'reasoning-delta' | 'tool-call-delta',
-  value: string,
-  options: { turn?: number; step?: number; index?: number; time?: number; name?: string } = {},
-): SessionFormatEvent {
-  const stream = type === 'tool-call-delta'
-    ? { type, index: options.index ?? 0, id: 'call', ...(options.name === undefined ? {} : { name: options.name }), argumentsDelta: value }
-    : { type, index: options.index ?? 0, text: value }
-  return {
-    type: 'assistant/chunk', seq, time: options.time ?? seq + 1,
-    data: { turn: options.turn ?? 1, step: options.step ?? 0, chunk: stream },
+class DecodedItemCollector implements SessionFormatMigrationContext {
+  readonly values: Array<SessionFormatEvent | SessionFormatEventRun> = []
+
+  emitEvent(event: SessionFormatEvent): void {
+    this.values.push(event)
   }
-}
 
-function artifact(events: readonly SessionFormatEvent[], overrides: Partial<SessionFormatArtifact> = {}): SessionFormatArtifact {
-  return {
-    header: { version: 1, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0 },
-    inheritedEventCount: 0,
-    events,
-    ...overrides,
+  emitRun(run: SessionFormatEventRun): void {
+    this.values.push(run)
   }
 }
 
+function decodeRow(
+  decoder: SessionFormatArtifactDecoder,
+  row: SessionFormatJsonValue,
+): Array<SessionFormatEvent | SessionFormatEventRun> {
+  const output = new DecodedItemCollector()
+  decoder.decodeRow(row, output)
+  return output.values
+}
+
+function decodeArtifact(
+  codec: typeof releasedV0SessionFormatCodec,
+  header: unknown,
+  rows: readonly unknown[],
+  recovery: SessionFormatRecovery = 'strict',
+): SessionFormatArtifact {
+  const decoder = codec.createDecoder(header, recovery)
+  const context = new SessionFormatEventCollector()
+  for (const row of rows) decoder.decodeRow(row, context)
+  return { header: decoder.header, inheritedEventCount: decoder.finish(context), events: context.values }
+}
+
 describe('released v0/v1 physical codecs', () => {
-  it('round-trips every physical header field and seeded zero cut', () => {
-    const decoded = releasedV1SessionFormatCodec.decodeArtifact(fullHeader, [])
+  it('decodes every physical header field and seeded zero cut', () => {
+    const decoded = decodeArtifact(releasedV1SessionFormatCodec, fullHeader, [])
     expect(decoded).toEqual({
       header: {
         version: 1, id: 'codec', createdAt: 1, cwd: '/work', parentSession: 'parent',
@@ -46,11 +64,8 @@ describe('released v0/v1 physical codecs', () => {
       inheritedEventCount: 0,
       events: [],
     })
-    expect(releasedV1SessionFormatCodec.encodeArtifact(decoded, { packChunks: false }).header).toEqual(fullHeader)
-    expect(releasedV0SessionFormatCodec.encodeArtifact({
-      ...decoded,
-      header: { ...decoded.header, version: 0 },
-    }, { packChunks: false }).header).toEqual({ ...fullHeader, version: 0 })
+    expect(releasedV0SessionFormatCodec.decodeHeader({ ...fullHeader, version: 0 }))
+      .toEqual({ ...decoded.header, version: 0 })
   })
 
   it.each([
@@ -69,43 +84,60 @@ describe('released v0/v1 physical codecs', () => {
     expect(() => releasedV1SessionFormatCodec.decodeHeader(header)).toThrow()
   })
 
-  it('packs and expands text, reasoning, and named tool-call runs exactly', () => {
-    const events = [
-      chunk(0, 'text-delta', 'a'), chunk(1, 'text-delta', 'b'), chunk(2, 'text-delta', 'c'),
-      chunk(3, 'reasoning-delta', 'd'), chunk(4, 'reasoning-delta', 'e'), chunk(5, 'reasoning-delta', 'f'),
-      chunk(6, 'tool-call-delta', '{', { name: 'read' }),
-      chunk(7, 'tool-call-delta', '}', { name: 'read' }),
-      chunk(8, 'tool-call-delta', '', { name: 'read' }),
-    ]
-    const v0 = { ...artifact(events), header: { ...artifact(events).header, version: 0 } }
-    const encoded = releasedV0SessionFormatCodec.encodeArtifact(v0, { packChunks: true })
-    expect(encoded.rows.map(row => row['type'])).toEqual(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
-    expect(releasedV0SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows).events).toEqual(events)
-
-    const unnamed = [
-      chunk(0, 'tool-call-delta', 'a'),
-      chunk(1, 'tool-call-delta', 'b'),
-      chunk(2, 'tool-call-delta', 'c'),
-    ]
-    const unnamedV0 = { ...artifact(unnamed), header: { ...artifact(unnamed).header, version: 0 } }
-    const unnamedEncoded = releasedV0SessionFormatCodec.encodeArtifact(unnamedV0, { packChunks: true })
-    expect(unnamedEncoded.rows[0]?.['data']).not.toHaveProperty('name')
-    expect(releasedV0SessionFormatCodec.decodeArtifact(unnamedEncoded.header, unnamedEncoded.rows).events).toEqual(unnamed)
-  })
+  it('expands valid text, reasoning, and named or unnamed tool-call rows exactly', () => {
+    const cases = [
+      [{
+        type: 'text-chunks', seq0: 0, time0: 1,
+        data: { turn: 1, step: 2, index: 3, dt: [2], texts: ['a', 'b'] },
+      }, [
+        { type: 'assistant/chunk', seq: 0, time: 1, data: {
+          turn: 1, step: 2, chunk: { type: 'text-delta', index: 3, text: 'a' },
+        } },
+        { type: 'assistant/chunk', seq: 1, time: 3, data: {
+          turn: 1, step: 2, chunk: { type: 'text-delta', index: 3, text: 'b' },
+        } },
+      ]],
+      [{
+        type: 'reasoning-chunks', seq0: 0, time0: 4,
+        data: { turn: 2, step: 3, index: 1, dt: [1], texts: ['c', 'd'] },
+      }, [
+        { type: 'assistant/chunk', seq: 0, time: 4, data: {
+          turn: 2, step: 3, chunk: { type: 'reasoning-delta', index: 1, text: 'c' },
+        } },
+        { type: 'assistant/chunk', seq: 1, time: 5, data: {
+          turn: 2, step: 3, chunk: { type: 'reasoning-delta', index: 1, text: 'd' },
+        } },
+      ]],
+      [{
+        type: 'tool-call-chunks', seq0: 0, time0: 6,
+        data: { turn: 3, step: 4, index: 2, id: 'call', name: 'read', dt: [1], args: ['{', '}'] },
+      }, [
+        { type: 'assistant/chunk', seq: 0, time: 6, data: {
+          turn: 3, step: 4,
+          chunk: { type: 'tool-call-delta', index: 2, id: 'call', name: 'read', argumentsDelta: '{' },
+        } },
+        { type: 'assistant/chunk', seq: 1, time: 7, data: {
+          turn: 3, step: 4,
+          chunk: { type: 'tool-call-delta', index: 2, id: 'call', name: 'read', argumentsDelta: '}' },
+        } },
+      ]],
+      [{
+        type: 'tool-call-chunks', seq0: 0, time0: 8,
+        data: { turn: 4, step: 5, index: 0, id: 'call', dt: [], args: ['x'] },
+      }, [
+        { type: 'assistant/chunk', seq: 0, time: 8, data: {
+          turn: 4, step: 5,
+          chunk: { type: 'tool-call-delta', index: 0, id: 'call', argumentsDelta: 'x' },
+        } },
+      ]],
+    ] as const
 
-  it('keeps short or non-continuing chunk runs unpacked', () => {
-    const events = [
-      chunk(0, 'text-delta', 'a'),
-      chunk(1, 'text-delta', 'b', { index: 1 }),
-      chunk(2, 'text-delta', 'c', { turn: 2 }),
-      chunk(3, 'text-delta', 'd', { step: 1 }),
-      chunk(4, 'tool-call-delta', 'a'),
-      chunk(5, 'tool-call-delta', 'b', { name: 'read' }),
-      chunk(6, 'tool-call-delta', 'c', { name: 'read' }),
-    ]
-    const v0 = { ...artifact(events), header: { ...artifact(events).header, version: 0 } }
-    const encoded = releasedV0SessionFormatCodec.encodeArtifact(v0, { packChunks: true })
-    expect(encoded.rows).toEqual(events)
+    for (const [row, expected] of cases) {
+      const decoder = releasedV1SessionFormatCodec.createDecoder(fullHeader, 'strict')
+      const item = decodeRow(decoder, row)[0]
+      if (item === undefined || !('runType' in item)) throw new Error('expected a packed Assistant run')
+      expect([...(item as SessionFormatEventRun).expand()]).toEqual(expected)
+    }
   })
 
   it.each([
@@ -118,7 +150,7 @@ describe('released v0/v1 physical codecs', () => {
     ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 0, index: 0, id: 'id', name: 1, dt: [], args: ['a'] } }],
     ['unsafe time sum', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 0, index: 0, dt: [1], texts: ['a', 'b'] } }],
   ])('refuses malformed packed row: %s', (_name, row) => {
-    expect(() => releasedV1SessionFormatCodec.decodeArtifact(fullHeader, [row])).toThrow()
+    expect(() => decodeArtifact(releasedV1SessionFormatCodec, fullHeader, [row])).toThrow()
   })
 
   it.each([
@@ -135,39 +167,83 @@ describe('released v0/v1 physical codecs', () => {
         id: 'u', role: 'user', content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
       }, sourceEventSeqs, surfaceOp: 'append' },
     ]
-    expect(() => releasedV1SessionFormatCodec.decodeArtifact(
+    expect(() => decodeArtifact(releasedV1SessionFormatCodec,
       { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 },
       rows,
     )).toThrow()
   })
 
   it('contains non-SessionFormatError row failures during recoverable scans', () => {
-    const bad = new Proxy({}, { ownKeys: () => { throw new Error('proxy failure') } })
-    const recovered = releasedV1SessionFormatCodec.decodeRecoverableArtifact(
+    const bad = new Proxy({}, { get: () => { throw new Error('proxy failure') } })
+    const decoder = releasedV1SessionFormatCodec.createDecoder(
       { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 },
-      [bad],
+      'recoverable',
     )
-    expect(recovered).toMatchObject({ events: [] })
+    expect(decodeRow(decoder, bad)).toEqual([])
+    expect(() => decodeRow(decoder, {
+      type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } },
+    })).toThrow('released Session row 0 is malformed')
+  })
+
+  it('streams the same row-atomic recoverable prefix', () => {
+    const create = (header: unknown) => releasedV1SessionFormatCodec.createDecoder(header, 'recoverable')
+    const header = { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 }
+
+    const malformed = create(header)
+    expect(decodeRow(malformed, null)).toEqual([])
+    const malformedProxy = create(header)
+    const proxy = new Proxy({}, { get: () => { throw new Error('proxy failure') } })
+    expect(decodeRow(malformedProxy, proxy)).toEqual([])
+    expect(decodeRow(malformedProxy, { type: 'step/start', seq: 0, time: 1, data: { turn: 1, step: 1 } })).toEqual([])
+    expect(() => decodeRow(malformedProxy, {
+      type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } },
+    })).toThrow('released Session row 0 is malformed')
+
+    const gap = create(header)
+    expect(decodeRow(gap, { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } })).toEqual([])
+    const terminalGap = create(header)
+    expect(() => decodeRow(terminalGap, {
+      type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } },
+    })).toThrow(/seq gap/)
+
+    const seeded = create({ ...header, seedLength: 1 })
+    expect(() => seeded.finish(new DecodedItemCollector())).toThrow(/inheritedEventCount exceeds/)
+
+    const packed = create(header)
+    const runs = decodeRow(packed, {
+      type: 'text-chunks', seq0: 0, time0: 1,
+      data: { turn: 1, step: 1, index: 0, dt: [1], texts: ['a', 'b'] },
+    })
+    expect(runs).toHaveLength(1)
+    expect([...((runs[0] as SessionFormatEventRun).expand())]).toHaveLength(2)
+    const provenance = create(header)
+    decodeRow(provenance, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+    const item = decodeRow(provenance, {
+      type: 'user/message', seq: 1, time: 2,
+      data: { id: 'm', role: 'user', content: [], source: { kind: 'user' } },
+      sourceEventSeqs: [0], surfaceOp: 'append',
+    })[0]
+    expect(item !== undefined && !('runType' in item) ? item.sourceEventSeqs : undefined).toEqual([0])
   })
 
   it('ignores decodable non-terminal rows after the first recoverable issue', () => {
     const header = { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 }
-    const recovered = releasedV1SessionFormatCodec.decodeRecoverableArtifact(header, [
+    const recovered = decodeArtifact(releasedV1SessionFormatCodec, header, [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
       { type: 'turn/start', seq: 4, time: 2, data: { turn: 2 } },
       { type: 'step/start', seq: 1, time: 3, data: { turn: 1, step: 0 } },
-    ])
+    ], 'recoverable')
     expect(recovered).toMatchObject({ events: [{ seq: 0 }] })
   })
 
   it('rejects strict gaps and a recoverable gap row that itself closes a turn', () => {
     const currentHeader = { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 }
-    expect(() => releasedV1SessionFormatCodec.decodeArtifact(currentHeader, [
+    expect(() => decodeArtifact(releasedV1SessionFormatCodec, currentHeader, [
       { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } },
     ])).toThrow(/seq gap/)
-    expect(() => releasedV1SessionFormatCodec.decodeRecoverableArtifact(currentHeader, [
+    expect(() => decodeArtifact(releasedV1SessionFormatCodec, currentHeader, [
       { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
-    ])).toThrow(/seq gap/)
+    ], 'recoverable')).toThrow(/seq gap/)
   })
 
   it('refuses overlapping ranges after a valid first range', () => {
@@ -182,74 +258,6 @@ describe('released v0/v1 physical codecs', () => {
         data: { id: 'u', role: 'user', content: [textBlock], source: { kind: 'user' } },
       },
     ]
-    expect(() => releasedV1SessionFormatCodec.decodeArtifact(header, rows)).toThrow(/strictly increasing/)
-  })
-
-  it('keeps non-consecutive provenance scalar and leaves invalid v0 chunks unpacked', () => {
-    const events = [
-      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
-      { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
-      { type: 'step/end', seq: 2, time: 3, data: { turn: 1, step: 1 } },
-      {
-        type: 'user/message', seq: 3, time: 4, surfaceOp: 'append', sourceEventSeqs: [0, 2],
-        data: { id: 'u', role: 'user', content: [textBlock], source: { kind: 'user' } },
-      },
-    ] as SessionFormatEvent[]
-    expect(releasedV1SessionFormatCodec.encodeArtifact(artifact(events), { packChunks: false }).rows[3])
-      .toEqual(events[3])
-
-    const invalidChunk = { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 0, chunk: null } }
-    const v0 = {
-      header: { version: 0, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0 },
-      inheritedEventCount: 0,
-      events: [invalidChunk],
-    } as unknown as SessionFormatArtifact
-    expect(releasedV0SessionFormatCodec.encodeArtifact(v0, { packChunks: true }).rows).toEqual([invalidChunk])
-
-    const causal = {
-      ...v0,
-      events: [
-        { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
-        { type: 'user/message', seq: 1, time: 2, surfaceOp: 'append', data: {
-          id: 'one', role: 'user', content: [textBlock], source: { kind: 'user' },
-        } },
-        { type: 'user/message', seq: 2, time: 3, surfaceOp: 'append', data: {
-          id: 'two', role: 'user', content: [textBlock], source: { kind: 'user' },
-        } },
-        { type: 'user/message', seq: 3, time: 4, surfaceOp: 'append', sourceEventSeqs: [2, 0], data: {
-          id: 'three', role: 'user', content: [textBlock], source: { kind: 'user' },
-        } },
-      ],
-    } as unknown as SessionFormatArtifact
-    expect(releasedV0SessionFormatCodec.encodeArtifact(causal, { packChunks: false }).rows[3]?.['sourceEventSeqs'])
-      .toEqual([2, 0])
-
-    for (const badData of [
-      null,
-      { turn: 1, step: 1, chunk: null },
-      { turn: 1, step: 1, chunk: { type: 'other', index: 0 } },
-      { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } },
-      { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } },
-    ]) {
-      const bad = { ...v0, events: [{ type: 'assistant/chunk', seq: 0, time: 1, data: badData }] } as unknown as SessionFormatArtifact
-      expect(releasedV0SessionFormatCodec.encodeArtifact(bad, { packChunks: true }).rows).toHaveLength(1)
-    }
-
-    const farTimes = [
-      chunk(0, 'text-delta', 'a', { time: Number.MIN_SAFE_INTEGER }),
-      chunk(1, 'text-delta', 'b', { time: Number.MAX_SAFE_INTEGER }),
-      chunk(2, 'text-delta', 'c', { time: Number.MAX_SAFE_INTEGER }),
-    ]
-    const far = { ...v0, events: farTimes } as unknown as SessionFormatArtifact
-    expect(releasedV0SessionFormatCodec.encodeArtifact(far, { packChunks: true }).rows).toHaveLength(3)
-
-    const ignorable = [
-      { ...chunk(0, 'text-delta', 'a'), ignorable: true },
-      { ...chunk(1, 'text-delta', 'b'), ignorable: true },
-      { ...chunk(2, 'text-delta', 'c'), ignorable: true },
-    ] as SessionFormatEvent[]
-    const ignorableV0 = { ...v0, events: ignorable } as unknown as SessionFormatArtifact
-    expect(releasedV0SessionFormatCodec.encodeArtifact(ignorableV0, { packChunks: true }).rows)
-      .toEqual(ignorable)
+    expect(() => decodeArtifact(releasedV1SessionFormatCodec, header, rows)).toThrow(/strictly increasing/)
   })
 })

+ 2 - 2
packages/session/session-format-v0-to-v1/tests/legacy.spec.ts

@@ -1,9 +1,9 @@
 import { describe, expect, it } from 'vitest'
 import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
 import {
-  releasedV0SessionFormatCodec,
   sessionFormatV0ToV1,
 } from '../src/index.ts'
+import { restoreV0ToV1 } from '../src/testing/restore.ts'
 
 const header = {
   type: 'session',
@@ -14,7 +14,7 @@ const header = {
 } as const
 
 function migrate(rows: readonly unknown[]) {
-  return sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(header, rows))
+  return restoreV0ToV1(header, rows)
 }
 
 describe('released v0 legacy normalization', () => {

+ 208 - 32
packages/session/session-format-v0-to-v1/tests/migration.spec.ts

@@ -1,11 +1,33 @@
 import { describe, expect, it } from 'vitest'
+import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
+import type {
+  SessionFormatEvent,
+  SessionFormatEventRun,
+} from '@deepseek-ai/dsh-session-format'
 import {
   RELEASED_V0_EVENT_TYPES,
-  releasedV0SessionFormatCodec,
   releasedV1SessionFormatCodec,
   restoreReleasedV1Artifact,
   sessionFormatV0ToV1,
 } from '../src/index.ts'
+import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts'
+
+function createMigrationStage(id: string) {
+  const sourceHeader = { version: 0, id, createdAt: 1, isSeeded: false, delegationDepth: 0 }
+  const stage = sessionFormatV0ToV1.createStage({
+    sourceHeader,
+    targetHeader: sessionFormatV0ToV1.migrateHeader(sourceHeader),
+    sourceInheritedEventCount: 0,
+    sourceKind: 'decoded',
+  })
+  return {
+    transform(event: SessionFormatEvent): SessionFormatEvent[] {
+      const context = new SessionFormatEventCollector()
+      stage.transformEvent(event, context)
+      return context.values
+    },
+  }
+}
 
 describe('released Session format v0 to v1', () => {
   it('changes only the version of a canonical decoded artifact', () => {
@@ -27,19 +49,71 @@ describe('released Session format v0 to v1', () => {
         data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] },
       },
     ]
-    const source = releasedV0SessionFormatCodec.decodeArtifact(header, rows)
-
-    const migrated = sessionFormatV0ToV1.migrate(source)
+    const migrated = restoreV0ToV1(header, rows)
 
     expect(migrated).toEqual({
-      ...source,
-      header: { ...source.header, version: 1 },
+      header: {
+        version: 1, id: 'identity', createdAt: 1, cwd: '/work', isSeeded: false, delegationDepth: 0,
+      },
+      inheritedEventCount: 0,
+      events: [
+        rows[0],
+        rows[1],
+        { type: 'assistant/chunk', seq: 2, time: 4, data: {
+          turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' },
+        } },
+        { type: 'assistant/chunk', seq: 3, time: 5, data: {
+          turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
+        } },
+        { type: 'assistant/chunk', seq: 4, time: 6, data: {
+          turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'c' },
+        } },
+      ],
+    })
+  })
+
+  it('expands an unrecognized compact run through the identity stage', () => {
+    const sourceHeader = {
+      version: 0, id: 'generic-run', createdAt: 1, isSeeded: false, delegationDepth: 0,
+    } as const
+    const stage = sessionFormatV0ToV1.createStage({
+      sourceHeader,
+      targetHeader: sessionFormatV0ToV1.migrateHeader(sourceHeader),
+      sourceInheritedEventCount: 0,
+      sourceKind: 'transformed',
     })
-    sessionFormatV0ToV1.validateTarget(migrated)
-    expect(releasedV1SessionFormatCodec.encodeArtifact(migrated, { packChunks: true })).toEqual({
-      header: { ...header, version: 1 },
-      rows,
+    const source = {
+      type: 'feedback/record', seq: 0, time: 2, data: { text: 'retained' },
+    } as const
+    const run: SessionFormatEventRun = {
+      runType: 'test-run', firstSeq: 0, eventCount: 1, *expand() { yield source },
+    }
+    const output = new SessionFormatEventCollector()
+
+    stage.transformRun(run, output)
+
+    expect(output.values).toEqual([source])
+  })
+
+  it('accepts an inherited delivery marker for its source generation', () => {
+    const sourceHeader = {
+      version: 0, id: 'child', createdAt: 1, parentSession: 'parent', isSeeded: true, delegationDepth: 0,
+    } as const
+    const stage = sessionFormatV0ToV1.createStage({
+      sourceHeader,
+      targetHeader: sessionFormatV0ToV1.migrateHeader(sourceHeader),
+      sourceInheritedEventCount: 2,
+      sourceKind: 'decoded',
     })
+    const marker = {
+      type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
+      data: { sessionId: 'parent', throughSeq: 0 },
+    } as const
+    const output = new SessionFormatEventCollector()
+
+    stage.transformEvent(marker, output)
+
+    expect(output.values).toEqual([marker])
   })
 
   it('recovers only the complete row prefix and refuses a later committing turn end', () => {
@@ -61,13 +135,13 @@ describe('released Session format v0 to v1', () => {
       data: { turn: 2, step: 0, index: 0, dt: [1], texts: ['x', 'y'] },
     }
 
-    expect(releasedV0SessionFormatCodec.decodeRecoverableArtifact(header, [...prefix, badRow]))
-      .toEqual(releasedV0SessionFormatCodec.decodeArtifact(header, prefix))
-    expect(() => releasedV0SessionFormatCodec.decodeRecoverableArtifact(header, [
+    expect(restoreV0ToV1(header, [...prefix, badRow], 'recoverable'))
+      .toEqual(restoreV0ToV1(header, prefix))
+    expect(() => restoreV0ToV1(header, [
       ...prefix,
       badRow,
       { type: 'turn/end', seq: 2, time: 6, data: { turn: 2, reason: { kind: 'interrupted' } } },
-    ])).toThrow(/seq gap/)
+    ], 'recoverable')).toThrow(/seq gap/)
   })
 
   it('requires canonical delegation depth and decodes provenance without mutating source rows', () => {
@@ -100,15 +174,15 @@ describe('released Session format v0 to v1', () => {
       provenanceRow,
     ]
 
-    const decoded = releasedV0SessionFormatCodec.decodeArtifact(header, rows)
+    const migrated = restoreV0ToV1(header, rows)
 
-    expect(() => releasedV0SessionFormatCodec.decodeArtifact(incompleteHeader, rows)).toThrow(/delegationDepth/)
-    expect(decoded.header.delegationDepth).toBe(0)
-    expect(decoded.events[3]?.sourceEventSeqs).toEqual([0, 1, 2])
+    expect(() => restoreV0ToV1(incompleteHeader, rows)).toThrow(/delegationDepth/)
+    expect(migrated.header.delegationDepth).toBe(0)
+    expect(migrated.events[3]?.sourceEventSeqs).toEqual([0, 1, 2])
     expect(provenanceRow.sourceEventSeqs).toEqual([[0, 2]])
-    const migrated = sessionFormatV0ToV1.migrate(decoded)
-    expect(releasedV1SessionFormatCodec.encodeArtifact(migrated, { packChunks: false }).header)
-      .toEqual({ ...header, version: 1 })
+    expect(migrated.header).toEqual({
+      version: 1, id: 'old', createdAt: 1, isSeeded: false, delegationDepth: 0,
+    })
   })
 
   it('refuses v1-only generation fields in v0 and accepts them in v1', () => {
@@ -122,10 +196,91 @@ describe('released Session format v0 to v1', () => {
     const v0 = { type: 'session', version: 0, id: 'delivery', createdAt: 1, delegationDepth: 0 }
     const v1 = { ...v0, version: 1 }
 
-    expect(() => sessionFormatV0ToV1.migrate(
-      releasedV0SessionFormatCodec.decodeArtifact(v0, [prefix, event]),
-    )).toThrow(/unexpected member "sessionFormatVersion"/)
-    expect(releasedV1SessionFormatCodec.decodeArtifact(v1, [prefix, event]).events).toEqual([prefix, event])
+    expect(() => restoreV0ToV1(v0, [prefix, event])).toThrow(/unexpected member "sessionFormatVersion"/)
+    expect(restoreV1(v1, [prefix, event]).events).toEqual([prefix, event])
+  })
+
+  it('does not synthesize over an explicitly invalid retry id', () => {
+    const header = { type: 'session', version: 0, id: 'retry', createdAt: 1, delegationDepth: 0 }
+    const rows = [
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+      { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+      { type: 'request/header', seq: 2, time: 3, data: {
+        header: { config: { provider: 'p', model: 'm' } }, reason: 'initial',
+      } },
+      { type: 'llm/retry', seq: 3, time: 4, data: {
+        retryId: null, turn: 1, step: 1, provider: 'p', mode: 'normal', policyKey: 'k',
+        retry: 1, maxRetries: 1, delayMs: 0, failure: { message: 'retry', code: 'SERVER' },
+      } },
+    ]
+    expect(() => restoreV0ToV1(header, rows)).toThrow(/retryId/)
+
+    const transformer = createMigrationStage('retry')
+    const retry = (seq: number, attempt: number) => ({
+      type: 'llm/retry', seq, time: seq + 1,
+      data: {
+        turn: 1, step: 1, provider: 'p', mode: 'normal', policyKey: 'k', retry: attempt,
+        maxRetries: 2, delayMs: 0, failure: { message: 'retry', code: 'SERVER' },
+      },
+    })
+    expect(transformer.transform(retry(0, 1))[0]?.data).toMatchObject({ retryId: 'legacy-retry:retry:0' })
+    expect(transformer.transform(retry(1, 2))[0]?.data).toMatchObject({ retryId: 'legacy-retry:retry:0' })
+  })
+
+  it('normalizes legacy compaction names and assigns one deterministic id to the group', () => {
+    const transformer = createMigrationStage('compact')
+    const rows = [
+      { type: 'compaction/start', seq: 0, time: 1, data: { turn: null } },
+      {
+        type: 'compaction/summary', seq: 1, time: 2,
+        data: {
+          summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start: 0, end: 0 },
+          shadowedSeqs: [0], shadowedTokenCount: 0, provider: 'p', model: 'm',
+        },
+      },
+      {
+        type: 'compaction/prune', seq: 2, time: 3,
+        data: { shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [0], shadowedTokenCount: 1 },
+      },
+      {
+        type: 'user/message', seq: 3, time: 4,
+        data: {
+          id: 'checkpoint', role: 'user', content: [{ type: 'text', text: 'summary' }],
+          source: { kind: 'plugin', plugin: 'compact' },
+        },
+        sourceEventSeqs: [0, 1], surfaceOp: { op: 'replace', start: 0, end: 0 },
+      },
+      { type: 'compaction/end', seq: 4, time: 5, data: { turn: null } },
+    ]
+
+    const migrated = rows.map(row => transformer.transform(row)[0])
+    expect(migrated.map(event => event?.type === 'user/message'
+      ? (event.data as { source: { compactionId: string } }).source.compactionId
+      : (event?.data as { compactionId: string }).compactionId))
+      .toEqual([
+        'legacy-compaction:compact:0',
+        'legacy-compaction:compact:0',
+        undefined,
+        'legacy-compaction:compact:0',
+        'legacy-compaction:compact:0',
+      ])
+
+    const historical = rows.map(row => ({
+      ...row,
+      type: row.type.replace(/^compaction\//, 'compact/'),
+    }))
+    const historicalTransformer = createMigrationStage('compact')
+    expect(historical.map(row => historicalTransformer.transform(row as SessionFormatEvent)[0])).toEqual(migrated)
+
+    const reset = createMigrationStage('compact')
+    expect(() => reset.transform({
+      ...(rows[0] as SessionFormatEvent), data: { turn: null, compactionId: null },
+    })).toThrow(/compactionId/)
+    reset.transform(rows[0] as never)
+    expect(reset.transform({ type: 'feedback/record', seq: 1, time: 2, data: { text: 'kept' } })[0]?.type)
+      .toBe('feedback/record')
+    reset.transform({ type: 'session/end-seed', seq: 2, time: 3, data: {} })
+    expect(() => reset.transform({ ...(rows[1] as SessionFormatEvent), seq: 3 })).toThrow(/compactionId/)
   })
 
   it('preserves a complete canonical multi-owner log except for header.version', () => {
@@ -209,9 +364,14 @@ describe('released Session format v0 to v1', () => {
       },
       { type: 'turn/end', seq: 19, time: 20, data: { turn: 1, reason: { kind: 'completed' } } },
     ]
-    const source = releasedV0SessionFormatCodec.decodeArtifact(physicalHeader, rows)
-    const migrated = sessionFormatV0ToV1.migrate(source)
-    expect(migrated).toEqual({ ...source, header: { ...source.header, version: 1 } })
+    const migrated = restoreV0ToV1(physicalHeader, rows)
+    expect(migrated).toEqual({
+      header: {
+        version: 1, id: 'full-identity', createdAt: 1, cwd: '/work', isSeeded: false, delegationDepth: 0,
+      },
+      inheritedEventCount: 0,
+      events: rows,
+    })
   })
 
   it('keeps the v1 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
@@ -219,10 +379,17 @@ describe('released Session format v0 to v1', () => {
       type: 'session', version: 1, id: 'ordinary-growth', createdAt: 1, delegationDepth: 0,
     }
     const ordinary = { type: 'ordinary/post-v1', seq: 0, time: 1, data: { required: true } }
-    const decoded = releasedV1SessionFormatCodec.decodeArtifact(physicalHeader, [ordinary])
+    const decoder = releasedV1SessionFormatCodec.createDecoder(physicalHeader, 'strict')
+    const events = new SessionFormatEventCollector()
+    decoder.decodeRow(ordinary, events)
+    const decoded = {
+      header: decoder.header,
+      inheritedEventCount: decoder.finish(events),
+      events: events.values,
+    }
 
     expect(decoded.events).toEqual([ordinary])
-    expect(() => { sessionFormatV0ToV1.validateTarget(decoded) }).toThrow(/unknown required event/)
+    expect(RELEASED_V0_EVENT_TYPES).not.toContain(ordinary.type)
 
     const generatedCurrentTypes = new Set([...RELEASED_V0_EVENT_TYPES, ordinary.type])
     expect(() => restoreReleasedV1Artifact(decoded, generatedCurrentTypes)).not.toThrow()
@@ -230,10 +397,19 @@ describe('released Session format v0 to v1', () => {
     const frozenFutureV1SourceTypes = new Set(generatedCurrentTypes)
     expect(() => restoreReleasedV1Artifact(decoded, frozenFutureV1SourceTypes)).not.toThrow()
 
-    const extendedKnownPayload = releasedV1SessionFormatCodec.decodeArtifact(physicalHeader, [{
+    const extendedDecoder = releasedV1SessionFormatCodec.createDecoder(physicalHeader, 'strict')
+    const extendedEvents = new SessionFormatEventCollector()
+    extendedDecoder.decodeRow({
+      type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
+    }, extendedEvents)
+    const extendedKnownPayload = {
+      header: extendedDecoder.header,
+      inheritedEventCount: extendedDecoder.finish(extendedEvents),
+      events: extendedEvents.values,
+    }
+    expect(extendedKnownPayload.events).toEqual([{
       type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
     }])
-    expect(() => { sessionFormatV0ToV1.validateTarget(extendedKnownPayload) }).toThrow(/unexpected member/)
     expect(() => restoreReleasedV1Artifact(extendedKnownPayload, generatedCurrentTypes)).not.toThrow()
   })
 })

+ 49 - 12
packages/session/session-format-v0-to-v1/tests/relationships.spec.ts

@@ -1,11 +1,9 @@
 import { describe, expect, it } from 'vitest'
 import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
 import {
-  releasedV0SessionFormatCodec,
-  releasedV1SessionFormatCodec,
-  assertReleasedV1Artifact,
-  sessionFormatV0ToV1,
+  assertReleasedArtifactRelationships,
 } from '../src/index.ts'
+import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts'
 
 const header = {
   type: 'session', version: 1, id: 'relationships', createdAt: 1, delegationDepth: 0,
@@ -15,9 +13,7 @@ const user = (id: string, source: object = { kind: 'user' }) => ({
 })
 
 function decode(rows: readonly unknown[], physicalHeader: unknown = header) {
-  const artifact = releasedV1SessionFormatCodec.decodeArtifact(physicalHeader, rows)
-  assertReleasedV1Artifact(artifact)
-  return artifact
+  return restoreV1(physicalHeader, rows)
 }
 
 describe('released v1 whole-artifact relationships', () => {
@@ -299,6 +295,7 @@ describe('released v1 whole-artifact relationships', () => {
     })
     expect(() => decode([...prefix, retry({ maxRetries: undefined })])).toThrow()
     expect(() => decode([...prefix, retry({ retry: 2, maxRetries: 1 })])).toThrow()
+    expect(() => decode([...prefix, retry({ turn: 2 })])).toThrow(/current turn/)
     expect(() => decode([...prefix, retry({ provider: 'q' })])).toThrow(/provider/)
     expect(() => decode([...prefix, retry({ failure: { message: 'x', code: 'X', status: 99 } })])).toThrow(/status/)
     expect(() => decode([...prefix, retry({ delayMs: -0.5 })])).toThrow(/non-negative/)
@@ -309,6 +306,25 @@ describe('released v1 whole-artifact relationships', () => {
       .toHaveLength(4)
   })
 
+  it('accepts a legacy retry scheduled immediately after its step closes', () => {
+    const rows = [
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+      { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+      { type: 'request/header', seq: 2, time: 3, data: { header: { config: { provider: 'p', model: 'm' } }, reason: 'initial' } },
+      { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
+      {
+        type: 'llm/retry', seq: 4, time: 5,
+        data: {
+          retryId: 'r', turn: 1, step: 1, provider: 'p', mode: 'normal', policyKey: 'k', retry: 1,
+          maxRetries: 2, delayMs: 1, failure: { message: 'retry', code: 'SERVER' },
+        },
+      },
+      { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
+    ]
+
+    expect(decode(rows).events).toHaveLength(rows.length)
+  })
+
   it('enforces command pairing and authoritative source-event rules', () => {
     expect(() => decode([{
       type: 'command/done', seq: 0, time: 1, data: { commandId: 'c', kind: 'success' },
@@ -366,7 +382,9 @@ describe('released v1 whole-artifact relationships', () => {
     }
     const decoded = decode([...prefix, inertV0])
     expect(decoded.events[1]).toEqual(inertV0)
-    expect(() => { sessionFormatV0ToV1.validateTarget(decoded) }).not.toThrow()
+    expect(() => {
+      assertReleasedArtifactRelationships(decoded, { legacyInterruptedTurnRestart: true })
+    }).not.toThrow()
   })
 
   it('validates versioned subagent descriptors by source/current policy', () => {
@@ -376,7 +394,7 @@ describe('released v1 whole-artifact relationships', () => {
     }
     expect(decode([future]).events).toEqual([future])
     const v0Header = { ...header, version: 0 }
-    expect(() => sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(v0Header, [future])))
+    expect(() => restoreV0ToV1(v0Header, [future]))
       .toThrow(SessionFormatUnsupportedMigrationError)
   })
 
@@ -465,10 +483,30 @@ describe('released v1 whole-artifact relationships', () => {
         data: { sessionId: 'wrong', throughSeq: 0 },
       },
     ]
-    expect(() => sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(v0, rows)))
+    expect(() => restoreV0ToV1(v0, rows))
       .toThrow(/wrong Session/)
   })
 
+  it('accepts the released interrupted-turn restart sequence during v0 migration', () => {
+    const v0 = { ...header, version: 0 }
+    const rows = [
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+      { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+      { type: 'step/end', seq: 2, time: 3, data: { turn: 1, step: 1 } },
+      {
+        type: 'agent/inbox/spliced', seq: 3, time: 4,
+        data: { target: 'next-turn', start: 0, inserted: [user('queued')] },
+      },
+      { type: 'turn/start', seq: 4, time: 5, data: { turn: 2 } },
+      { type: 'turn/end', seq: 5, time: 6, data: { turn: 2, reason: { kind: 'completed' } } },
+    ]
+
+    expect(restoreV0ToV1(v0, rows).events).toEqual(rows)
+    expect(() => restoreV0ToV1(v0, rows.map(candidate => candidate.seq === 3
+      ? { ...candidate, data: { ...candidate.data, inserted: [] } }
+      : candidate))).toThrow(/does not open expected turn/)
+  })
+
   it('accepts recoverable open tails for later interrupted-turn repair', () => {
     const v0 = { ...header, version: 0 }
     const rows = [
@@ -500,8 +538,7 @@ describe('released v1 whole-artifact relationships', () => {
       },
       { type: 'compaction/start', seq: 7, time: 8, data: { compactionId: 'c', turn: 1 } },
     ]
-    const recovered = releasedV0SessionFormatCodec.decodeRecoverableArtifact(v0, rows)
-    expect(sessionFormatV0ToV1.migrate(recovered).events).toHaveLength(rows.length)
+    expect(restoreV0ToV1(v0, rows, 'recoverable').events).toHaveLength(rows.length)
   })
 
   it('refuses invalid core openings, request placement, and replacement placement', () => {

+ 93 - 8
packages/session/session-format-v0-to-v1/tests/validation.spec.ts

@@ -4,12 +4,16 @@ import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
 import {
   RELEASED_V0_EVENT_TYPES,
   RELEASED_V0_EVENT_DISPOSITIONS,
-  assertReleasedV1Artifact,
-  releasedV0SessionFormatCodec,
-  releasedV1SessionFormatCodec,
-  sessionFormatV0ToV1,
 } from '../src/index.ts'
-import { assertReleasedEventPayload } from '../src/validation.ts'
+import { assertReleasedEventPayload, assertReleasedSurfaceMetadata } from '../src/validation.ts'
+import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts'
+import {
+  assertNormalizedReleasedV0Artifact,
+  assertReleasedV0SourceArtifact,
+  assertReleasedV1Artifact,
+  assertReleasedV1MigrationSource,
+  assertReleasedV1PhysicalArtifact,
+} from '../src/testing/validation.ts'
 
 const v0Header = {
   type: 'session', version: 0, id: 'validation', createdAt: 1, delegationDepth: 0,
@@ -290,6 +294,15 @@ describe('released event and payload inventory', () => {
     }
   })
 
+  it('refuses opaque members that are not lossless JSON without snapshotting them', () => {
+    const data = {
+      ...(validPayloads['tool/result'] as Record<string, unknown>),
+      meta: { value: undefined },
+    }
+    expect(() => { assertPayload('tool/result', data as unknown as SessionFormatJsonValue) })
+      .toThrow(/opaque meta is not lossless JSON/)
+  })
+
   it.each([
     ['user/message content block', 'user/message', {
       ...userMessage,
@@ -312,10 +325,58 @@ describe('released event and payload inventory', () => {
 
   it('refuses unknown v0 events even when the envelope marks them ignorable', () => {
     const row = { type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true }
-    expect(() => releasedV0SessionFormatCodec.decodeArtifact(v0Header, [row]))
+    expect(() => restoreV0ToV1(v0Header, [row]))
       .toThrow(/unknown historical event.*refuses.*ignorable/)
   })
 
+  it('exercises each released test validation policy', () => {
+    const v0 = {
+      header: { version: 0, id: 'validation', createdAt: 1, isSeeded: false, delegationDepth: 0 },
+      inheritedEventCount: 0,
+      events: [],
+    } as const
+    const v1 = { ...v0, header: { ...v0.header, version: 1 } } as const
+    const forwardCompatible = {
+      ...v1,
+      events: [{ type: 'plugin/future', seq: 0, time: 1, data: {}, ignorable: true }],
+    } as const
+
+    expect(() => { assertReleasedV0SourceArtifact(v0) }).not.toThrow()
+    expect(() => { assertNormalizedReleasedV0Artifact({
+      ...v0,
+      events: [{ type: 'feedback/record', seq: 0, time: 1, data: { text: 'retained' } }],
+    }) }).not.toThrow()
+    expect(() => { assertReleasedV1MigrationSource(v1) }).not.toThrow()
+    expect(() => { assertReleasedV1MigrationSource(forwardCompatible) }).not.toThrow()
+    expect(() => { assertReleasedV1PhysicalArtifact(v1) }).not.toThrow()
+    expect(() => { assertReleasedV0SourceArtifact({
+      ...v0,
+      events: [{
+        type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
+        data: { turn: 1, content: [], source: { kind: 'user' } },
+      }],
+    }) }).not.toThrow()
+    expect(() => { assertReleasedV0SourceArtifact({
+      ...v0,
+      events: [{ type: 'compact/start', seq: 0, time: 1, data: { turn: null } }],
+    }) }).not.toThrow()
+    expect(() => { assertReleasedV0SourceArtifact({
+      ...v0,
+      events: [{ type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true }],
+    }) }).toThrow(/unknown historical event/)
+  })
+
+  it('permits empty Assistant provenance only under the released-v1 policy', () => {
+    const assistant = {
+      type: 'assistant/message', seq: 1, time: 2, data: {},
+      sourceEventSeqs: [], surfaceOp: 'append',
+    }
+    expect(() => { assertReleasedSurfaceMetadata(assistant, 1, assistant.type, 'allow-empty-assistant') })
+      .not.toThrow()
+    expect(() => { assertReleasedSurfaceMetadata(assistant, 1, assistant.type, 'forbid-assistant') })
+      .toThrow(/obsolete chunk provenance/)
+  })
+
   it('keeps capturedFormatVersion v1-only inside session-reference sources', () => {
     const data = {
       id: 'reference', role: 'user', content: [textBlock],
@@ -329,9 +390,9 @@ describe('released event and payload inventory', () => {
       },
     }
     const event = { type: 'user/message', seq: 0, time: 1, data, surfaceOp: 'append' }
-    expect(() => sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(v0Header, [event])))
+    expect(() => restoreV0ToV1(v0Header, [event]))
       .toThrow(/capturedFormatVersion/)
-    expect(releasedV1SessionFormatCodec.decodeArtifact(v1Header, [event]).events).toEqual([event])
+    expect(restoreV1(v1Header, [event]).events).toEqual([event])
   })
 
   it('accepts every released nested union variant and optional member', () => {
@@ -489,6 +550,30 @@ describe('released event and payload inventory', () => {
     }
   })
 
+  it('validates legacy round-zero goal mutation messages', () => {
+    const change = {
+      kind: 'goal/change', version: 1, operation: 'clear',
+      cleared: { id: 'goal', revision: 2 }, clearedAt: 3,
+    }
+    const content = [{
+      type: 'text',
+      text: `<goal_state>${JSON.stringify({ cleared: change.cleared, clearedAt: change.clearedAt })}</goal_state>`,
+    }]
+    const message = {
+      id: 'legacy-goal', role: 'user', content,
+      source: { kind: 'goal', goalId: 'goal', revision: 2, round: 0, change },
+    }
+    expect(() => { assertPayload('user/message', message) }).not.toThrow()
+    expect(() => { assertPayload('user/message', {
+      ...message, source: { ...message.source, round: 1 },
+    }) }).toThrow(/round must be 0/)
+    expect(() => { assertPayload('user/message', {
+      ...message, source: { ...message.source, goalId: 'other' },
+    }) }).toThrow(/does not match/)
+    expect(() => { assertPayload('user/message', { ...message, content: [textBlock] }) })
+      .toThrow(/content does not match/)
+  })
+
   it('refuses malformed logical headers, cuts, event envelopes, and surface metadata', () => {
     const base = v1Artifact('turn/start', { turn: 1 })
     const invalidHeaders = [

+ 94 - 76
packages/session/session-format-v1-to-v2/src/codec.ts

@@ -1,40 +1,42 @@
 import {
   SessionFormatError,
   sessionFormatCount,
-  snapshotSessionFormatArtifact,
+  sessionFormatSafeInteger,
   snapshotSessionFormatJson,
 } from '@deepseek-ai/dsh-session-format'
 import type {
-  EncodedSessionFormatArtifact,
-  SessionFormatArtifact,
+  SessionFormatArtifactDecoder,
   SessionFormatCodec,
+  SessionFormatCurrentEncoder,
   SessionFormatEvent,
   SessionFormatHeader,
   SessionFormatJsonObject,
   SessionFormatJsonValue,
+  SessionFormatRecovery,
 } from '@deepseek-ai/dsh-session-format'
-import { assertReleasedV2Header, assertReleasedV2PhysicalArtifact } from './validation.ts'
+import { assertReleasedV2Header } from './validation.ts'
 
 const HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const
 const HEADER_OPTIONAL = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const
+const EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const
+const EVENT_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const
+const EVENT_KEYS: ReadonlySet<string> = new Set([...EVENT_REQUIRED, ...EVENT_OPTIONAL])
 /** Frozen physical JSON codec for released v2. */
 export const releasedV2SessionFormatCodec = Object.freeze({
   version: 2,
   decodeHeader(value: unknown) {
     return decodePhysicalHeader(value)
   },
-  decodeArtifact(headerValue: unknown, rowValues: readonly unknown[]) {
-    return decodeArtifact(headerValue, rowValues, false)
+  createDecoder(headerValue: unknown, recovery: SessionFormatRecovery) {
+    return createDecoder(headerValue, recovery)
   },
-  decodeRecoverableArtifact(headerValue: unknown, rowValues: readonly unknown[]) {
-    return decodeArtifact(headerValue, rowValues, true)
+  encodeHeader(header: SessionFormatHeader, inheritedEventCount: number) {
+    return encodeHeader(header, inheritedEventCount)
   },
-  encodeArtifact(artifact: SessionFormatArtifact) {
-    return encodeArtifact(artifact)
+  encodeEvent(event: SessionFormatEvent) {
+    return encodeProvenance(event)
   },
-} satisfies SessionFormatCodec & {
-  encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatArtifact
-})
+} satisfies SessionFormatCodec & SessionFormatCurrentEncoder)
 
 function decodePhysicalHeader(value: unknown): SessionFormatHeader {
   const snapshot = snapshotSessionFormatJson(value, 'released v2 physical header')
@@ -70,78 +72,96 @@ function decodePhysicalHeader(value: unknown): SessionFormatHeader {
   return header
 }
 
-function decodeArtifact(
+function createDecoder(
   headerValue: unknown,
-  rowValues: readonly unknown[],
-  recoverable: boolean,
-): SessionFormatArtifact {
+  recovery: SessionFormatRecovery,
+): SessionFormatArtifactDecoder {
   const header = decodePhysicalHeader(headerValue)
-  const events: SessionFormatEvent[] = []
+  let rowIndex = 0
+  let eventCount = 0
+  let inheritedEventCount: number | undefined
   let issue: SessionFormatError | undefined
-  for (const [rowIndex, value] of rowValues.entries()) {
-    let event: SessionFormatEvent
-    try {
-      event = decodeEvent(value, rowIndex)
-    } catch (error: unknown) {
-      const current = error instanceof SessionFormatError
-        ? error
-        : new SessionFormatError(`released v2 row ${rowIndex} is malformed`, { cause: error })
-      if (!recoverable) throw current
-      issue ??= current
-      continue
-    }
-    if (issue !== undefined) {
-      if (event.type === 'turn/end') throw issue
-      continue
-    }
-    if (event.seq !== events.length) {
-      const gap = new SessionFormatError(
-        `released v2 row ${rowIndex} has seq gap (expected ${events.length}, got ${event.seq})`,
-      )
-      if (!recoverable) throw gap
-      issue = gap
-      if (event.type === 'turn/end') throw issue
-      continue
-    }
-    events.push(event)
+  return {
+    header,
+    decodeRow(value, context) {
+      const currentRow = rowIndex
+      rowIndex += 1
+      let event: SessionFormatEvent
+      try {
+        event = decodeEvent(value, currentRow)
+      } catch (error: unknown) {
+        const current = error instanceof SessionFormatError
+          ? error
+          : new SessionFormatError(`released v2 row ${currentRow} is malformed`, { cause: error })
+        if (recovery === 'strict') throw current
+        issue ??= current
+        return
+      }
+      if (issue !== undefined) {
+        if (event.type === 'turn/end') throw issue
+        return
+      }
+      if (event.seq !== eventCount) {
+        const gap = new SessionFormatError(
+          `released v2 row ${currentRow} has seq gap (expected ${eventCount}, got ${event.seq})`,
+        )
+        if (recovery === 'strict') throw gap
+        issue = gap
+        if (event.type === 'turn/end') throw issue
+        return
+      }
+      eventCount += 1
+      if (event.type === 'session/end-seed') {
+        const data = jsonRecord(event.data, `session/end-seed ${event.seq} data`)
+        if (data['inherited'] === true) inheritedEventCount = event.seq
+      }
+      context.emitEvent(event)
+    },
+    finish(_context) {
+      if (header.isSeeded && inheritedEventCount === undefined) {
+        throw new SessionFormatError('released v2 seeded Session lacks an inherited end-seed marker')
+      }
+      if (!header.isSeeded && inheritedEventCount !== undefined) {
+        throw new SessionFormatError('released v2 unseeded Session contains an inherited end-seed marker')
+      }
+      return inheritedEventCount ?? 0
+    },
   }
-  const inheritedEventCount = deriveInheritedEventCount(header, events)
-  const artifact = snapshotSessionFormatArtifact({ header, inheritedEventCount, events }, 'released v2 artifact')
-  assertReleasedV2PhysicalArtifact(artifact)
-  return artifact
 }
 
 function decodeEvent(value: unknown, rowIndex: number): SessionFormatEvent {
-  const snapshot = snapshotSessionFormatJson(value, `released v2 row ${rowIndex}`)
-  const record = jsonRecord(snapshot, `released v2 row ${rowIndex}`)
+  const record = jsonRecord(value as SessionFormatJsonValue, `released v2 row ${rowIndex}`)
+  const missing = EVENT_REQUIRED.find(key => !Object.hasOwn(record, key))
+  if (missing !== undefined) throw new SessionFormatError(`released v2 row ${rowIndex} lacks required field ${missing}`)
+  const unexpected = Object.keys(record).find(key => !EVENT_KEYS.has(key))
+  if (unexpected !== undefined) {
+    throw new SessionFormatError(`released v2 row ${rowIndex} has unexpected field ${unexpected}`)
+  }
+  if (typeof record['type'] !== 'string') {
+    throw new SessionFormatError(`released v2 row ${rowIndex} type must be a string`)
+  }
+  sessionFormatSafeInteger(record['time'], `released v2 row ${rowIndex} time`)
+  if (record['ignorable'] !== undefined && record['ignorable'] !== true) {
+    throw new SessionFormatError(`released v2 row ${rowIndex} ignorable must be true when present`)
+  }
   if (record['sourceEventSeqs'] === undefined) return record as unknown as SessionFormatEvent
   const seq = sessionFormatCount(record['seq'], `released v2 row ${rowIndex} seq`)
-  return snapshotSessionFormatJson({
+  return {
     ...record,
     sourceEventSeqs: decodeSeqRanges(record['sourceEventSeqs'], seq),
-  }, `released v2 row ${rowIndex} provenance`) as SessionFormatEvent
+  } as unknown as SessionFormatEvent
 }
 
-function deriveInheritedEventCount(header: SessionFormatHeader, events: readonly SessionFormatEvent[]): number {
-  let cut: number | undefined
-  for (const event of events) {
-    if (event.type !== 'session/end-seed') continue
-    const data = jsonRecord(event.data, `session/end-seed ${event.seq} data`)
-    if (data['inherited'] === true) cut = event.seq
-  }
-  if (header.isSeeded && cut === undefined) {
-    throw new SessionFormatError('released v2 seeded Session lacks an inherited end-seed marker')
-  }
-  if (!header.isSeeded && cut !== undefined) {
-    throw new SessionFormatError('released v2 unseeded Session contains an inherited end-seed marker')
+function encodeHeader(
+  header: SessionFormatHeader,
+  inheritedEventCount: number,
+): SessionFormatJsonObject {
+  assertReleasedV2Header(header)
+  const cut = sessionFormatCount(inheritedEventCount, 'format v2 inherited event count')
+  if (!header.isSeeded && cut !== 0) {
+    throw new SessionFormatError('unseeded format v2 Session has inherited events')
   }
-  return cut ?? 0
-}
-
-function encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatArtifact {
-  assertReleasedV2PhysicalArtifact(artifact)
-  const header = artifact.header
-  const physicalHeader = snapshotSessionFormatJson({
+  return {
     type: 'session',
     version: 2,
     id: header.id,
@@ -152,17 +172,15 @@ function encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatAr
     ...(header.origin === undefined ? {} : { origin: header.origin }),
     delegationDepth: header.delegationDepth,
     ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }),
-  }, 'released v2 encoded header') as SessionFormatJsonObject
-  const rows = Object.freeze(artifact.events.map(event => encodeProvenance(event)))
-  return Object.freeze({ header: physicalHeader, rows })
+  }
 }
 
 function encodeProvenance(event: SessionFormatEvent): SessionFormatJsonObject {
   if (event.sourceEventSeqs === undefined) return event
-  return snapshotSessionFormatJson({
+  return {
     ...event,
     sourceEventSeqs: encodeSeqRanges(event.sourceEventSeqs as readonly number[]),
-  }, `released v2 event ${event.seq} provenance`) as SessionFormatJsonObject
+  }
 }
 
 function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly number[] {

+ 1 - 1
packages/session/session-format-v1-to-v2/src/index.ts

@@ -4,4 +4,4 @@ export { releasedV1SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0
 export * from './codec.ts'
 export * from './dispositions.ts'
 export * from './migration.ts'
-export * from './validation.ts'
+export { assertReleasedV2Header, restoreReleasedV2Artifact } from './validation.ts'

+ 496 - 184
packages/session/session-format-v1-to-v2/src/migration.ts

@@ -1,33 +1,41 @@
 import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm'
+import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm'
 import {
   SessionFormatUnsupportedMigrationError,
   defineSessionFormatMigration,
-  snapshotSessionFormatArtifact,
 } from '@deepseek-ai/dsh-session-format'
 import type {
-  SessionFormatArtifact,
   SessionFormatEvent,
+  SessionFormatEventRun,
   SessionFormatJsonObject,
   SessionFormatJsonValue,
+  SessionFormatHeader,
+  SessionFormatMigrationContext,
+  SessionFormatMigrationStage,
+  SessionFormatMigrationStageInput,
 } from '@deepseek-ai/dsh-session-format'
 import {
   RELEASED_V0_EVENT_DISPOSITIONS,
-  assertReleasedV1Artifact,
+  assertReleasedEventPayload,
   assertReleasedV1Header,
+  isReleasedAssistantChunkRun,
 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
-import { assertReleasedV2Artifact, assertReleasedV2Header } from './validation.ts'
+import { assertReleasedV2Header } from './validation.ts'
+
+const CHUNK_EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const
+const CHUNK_EVENT_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const
+const CHUNK_EVENT_KEYS: ReadonlySet<string> = new Set([...CHUNK_EVENT_REQUIRED, ...CHUNK_EVENT_OPTIONAL])
 
 interface AttemptGroup {
   readonly turn: number
   readonly step: number
-  readonly chunks: SessionFormatEvent[]
+  readonly spans: Array<{ firstSeq: number; eventCount: number }>
+  readonly stream: Array<{ record: AssistantStreamRecord; lastTime: number }>
+  accumulator?: AssistantStreamAccumulator
+  chunkCount: number
+  lastChunkSeq?: number
+  lastChunkTime?: number
   terminal: boolean
-  messageSeq?: number
-}
-
-interface StagedEvent {
-  readonly origin: number
-  readonly event: SessionFormatEvent
 }
 
 /** Adjacent migration that embeds released-v1 top-level Assistant chunks into v2 attempt events. */
@@ -39,164 +47,501 @@ export const sessionFormatV1ToV2 = defineSessionFormatMigration({
     assertReleasedV1Header(header)
     return { ...header, version: 2 }
   },
-  migrate(source) {
-    assertReleasedV1Artifact(source)
-    const unknown = source.events.find(event => RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined)
-    if (unknown !== undefined) {
-      throw refusal(`format v1 contains unknown event type ${JSON.stringify(unknown.type)} at seq ${unknown.seq}`)
-    }
-    const groups = collectAttemptGroups(source.events)
-    const groupByChunk = new Map<number, AttemptGroup>()
-    const groupByMessage = new Map<number, AttemptGroup>()
-    for (const group of groups) {
-      for (const chunk of group.chunks) groupByChunk.set(chunk.seq, group)
-      if (group.messageSeq !== undefined) groupByMessage.set(group.messageSeq, group)
-    }
-
-    const staged: StagedEvent[] = []
-    const oldToNew = new Map<number, number>()
-    for (const sourceEvent of source.events) {
-      const group = groupByChunk.get(sourceEvent.seq)
-      if (group !== undefined) {
-        if (group.messageSeq === undefined && sourceEvent.seq === group.chunks.at(-1)?.seq) {
-          stage(staged, oldToNew, sourceEvent.seq, attemptEvent(group))
-        }
-        continue
-      }
-      const messageGroup = groupByMessage.get(sourceEvent.seq)
-      if (messageGroup !== undefined) {
-        stage(staged, oldToNew, sourceEvent.seq, messageEvent(sourceEvent, messageGroup))
-        continue
-      }
-      const event = source.header.isSeeded
-        && sourceEvent.seq === source.inheritedEventCount
-        && sourceEvent.type === 'session/end-seed'
-        ? { ...sourceEvent, data: { inherited: true } }
-        : sourceEvent
-      stage(staged, oldToNew, sourceEvent.seq, event)
-    }
-
-    const inheritedEventCount = remapInheritedCut(source, groups, staged)
-    if (source.header.isSeeded && source.events[source.inheritedEventCount]?.type !== 'session/end-seed') {
-      const next = source.events[source.inheritedEventCount]
-      const previous = source.events[source.inheritedEventCount - 1]
-      staged.splice(inheritedEventCount, 0, {
-        origin: -1,
-        event: {
-          type: 'session/end-seed',
-          seq: inheritedEventCount,
-          time: next?.time ?? previous?.time ?? source.header.createdAt,
-          data: { inherited: true },
-        },
-      })
-      oldToNew.clear()
-      for (const [seq, candidate] of staged.entries()) {
-        if (candidate.origin >= 0) oldToNew.set(candidate.origin, seq)
-      }
-    }
-    for (const group of groups) {
-      for (const chunk of group.chunks) oldToNew.delete(chunk.seq)
-    }
-
-    const target = snapshotSessionFormatArtifact({
-      header: { ...source.header, version: 2 },
-      inheritedEventCount,
-      events: staged.map(({ event }, seq) => remapReferences(event, seq, oldToNew)),
-    }, 'released v1-to-v2 target')
-    assertReleasedV2Artifact(target)
-    return target
+  createStage(input) {
+    return input.sourceKind === 'decoded'
+      ? new DecodedReleasedV1ToV2Stage(input)
+      : new TransformedReleasedV1ToV2Stage(input)
   },
-  validateTarget: assertReleasedV2Artifact,
   validateTargetHeader: assertReleasedV2Header,
 })
 
-function collectAttemptGroups(events: readonly SessionFormatEvent[]): readonly AttemptGroup[] {
-  const groups: AttemptGroup[] = []
-  const current = new Map<string, AttemptGroup>()
-  for (const event of events) {
-    if (event.type === 'assistant/chunk') {
-      const data = record(event.data)
-      const turn = coordinate(data['turn'])
-      const step = coordinate(data['step'])
-      const key = `${turn}:${step}`
-      let group = current.get(key)
-      if (group === undefined || group.terminal) {
-        group = { turn, step, chunks: [], terminal: false }
-        groups.push(group)
-        current.set(key, group)
-      }
-      group.chunks.push(event)
-      const chunk = record(data['chunk'])
-      if (chunk['type'] === 'finish') group.terminal = true
-      continue
-    }
-    if (event.type !== 'assistant/message') {
-      closeAttemptAtBoundary(event, current)
-      continue
-    }
-    const data = record(event.data)
-    const turn = coordinate(data['turn'])
-    const step = coordinate(data['step'])
-    const sources = event.sourceEventSeqs
-    if (!Array.isArray(sources)) {
-      const unclaimed = groups.some(candidate => candidate.messageSeq === undefined
-        && candidate.turn === turn
-        && candidate.step === step)
-      if (unclaimed) throw refusal(`assistant/message ${event.seq} does not cite its complete v1 chunk attempt`)
-      groups.push({ turn, step, chunks: [], terminal: true, messageSeq: event.seq })
-      continue
-    }
-    if (sources.length === 0) {
-      // Released v1 uses an explicit empty list to state that this message
-      // owns no preceding chunks; an absent list cannot make that claim.
-      groups.push({ turn, step, chunks: [], terminal: true, messageSeq: event.seq })
-      continue
+class TransformedReleasedV1ToV2Stage implements SessionFormatMigrationStage {
+  private readonly state: ReleasedV1ToV2State
+
+  constructor(input: SessionFormatMigrationStageInput) {
+    assertReleasedV1Header(input.sourceHeader)
+    this.state = {
+      sourceHeader: input.sourceHeader,
+      sourceCut: input.sourceInheritedEventCount,
+      mapping: new Map(),
+      legacyTurns: legacyTurnState(),
+      pending: undefined,
+      targetSeq: 0,
+      targetCut: input.sourceHeader.isSeeded ? undefined : 0,
+      lastTime: input.sourceHeader.createdAt,
     }
-    const group = groups.find(candidate => candidate.messageSeq === undefined
-      && candidate.turn === turn
-      && candidate.step === step
-      && sameNumbers(candidate.chunks.map(chunk => chunk.seq), sources))
-    if (group === undefined) {
-      throw refusal(`assistant/message ${event.seq} chunk provenance is not one complete ordered attempt`)
+  }
+
+  transformEvent(
+    event: SessionFormatEvent,
+    context: SessionFormatMigrationContext,
+  ): void {
+    transformReleasedEvent(this.state, event, context)
+  }
+
+  transformRun(
+    run: SessionFormatEventRun,
+    context: SessionFormatMigrationContext,
+  ): void {
+    transformReleasedRun(this.state, run, context)
+  }
+
+  finish(context: SessionFormatMigrationContext): number {
+    return finishMigration(this.state, context)
+  }
+}
+
+class DecodedReleasedV1ToV2Stage extends TransformedReleasedV1ToV2Stage {
+  override transformEvent(
+    event: SessionFormatEvent,
+    context: SessionFormatMigrationContext,
+  ): void {
+    if (event.type !== 'assistant/chunk'
+      && RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) {
+      assertReleasedEventPayload(event, 1)
     }
-    group.messageSeq = event.seq
-    group.terminal = true
+    super.transformEvent(event, context)
   }
-  return groups
 }
 
-function closeAttemptAtBoundary(
+interface StreamingAttempt {
+  readonly group: AttemptGroup
+  readonly afterLastChunk: SessionFormatEvent[]
+}
+
+interface ReleasedV1ToV2State {
+  readonly sourceHeader: SessionFormatHeader
+  readonly sourceCut: number
+  readonly mapping: Map<number, number>
+  readonly legacyTurns: LegacyTurnState
+  pending: StreamingAttempt | undefined
+  targetSeq: number
+  targetCut: number | undefined
+  lastTime: number
+}
+
+function transformReleasedEvent(
+  state: ReleasedV1ToV2State,
   event: SessionFormatEvent,
-  current: ReadonlyMap<string, AttemptGroup>,
+  context: SessionFormatMigrationContext,
 ): void {
-  if (event.type === 'turn/end') {
-    const data = record(event.data)
-    const turn = coordinate(data['turn'])
-    for (const group of current.values()) {
-      if (group.turn === turn) group.terminal = true
-    }
+  if (event.type === 'assistant/chunk') assertChunkEnvelope(event)
+  if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined) {
+    throw refusal(`format v1 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`)
+  }
+  const interrupted = legacyInterruptedTurn(state.legacyTurns, event)
+  if (event.type === 'turn/start' && state.legacyTurns.openTurn !== null && interrupted === undefined) {
+    throw refusal(`turn/start ${JSON.stringify(record(event.data)['turn'])} does not close the prior turn`)
+  }
+  assertSourceDeliveryMarker(state, event)
+  observeLegacyTurn(state.legacyTurns, event)
+  state.lastTime = event.time
+  if (interrupted !== undefined) {
+    finishAttempt(state, context)
+    emitGenerated(state, event.seq, interrupted, context)
+  }
+  const legacyGoal = splitLegacyGoalChange(event)
+  if (legacyGoal !== undefined) {
+    emitGenerated(state, event.seq, legacyGoal.change, context)
+    emitSource(state, legacyGoal.message, context)
     return
   }
-  if (event.type !== 'step/end'
-    && event.type !== 'llm/retry'
-    && event.type !== 'llm/retry-started') return
+  if (event.type === 'assistant/chunk') {
+    transformChunk(state, event, context)
+    return
+  }
+  if (event.type === 'assistant/message') {
+    transformMessage(state, event, context)
+    return
+  }
+  if (closesAttempt(event)) {
+    finishAttempt(state, context)
+    emitSource(state, event, context)
+    return
+  }
+  if (state.pending !== undefined) {
+    state.pending.afterLastChunk.push(event)
+    return
+  }
+  emitSource(state, event, context)
+}
+
+function assertChunkEnvelope(event: SessionFormatEvent): void {
+  const unexpected = Object.keys(event).find(key => !CHUNK_EVENT_KEYS.has(key))
+  if (unexpected !== undefined) throw refusal(`assistant/chunk ${event.seq} has unexpected member ${unexpected}`)
+  const missing = CHUNK_EVENT_REQUIRED.find(key => !Object.hasOwn(event, key))
+  if (missing !== undefined) throw refusal(`assistant/chunk ${event.seq} lacks required member ${missing}`)
+  if (event.ignorable !== undefined && event.ignorable !== true) {
+    throw refusal(`assistant/chunk ${event.seq} ignorable must be true when present`)
+  }
+}
+
+function assertSourceDeliveryMarker(state: ReleasedV1ToV2State, event: SessionFormatEvent): void {
+  if (event.type !== 'session-log-deepseek/delivery-accepted') return
+  const data = record(event.data)
+  const inherited = state.sourceHeader.parentSession !== undefined && event.seq < state.sourceCut
+  if (data['sessionFormatVersion'] === 1 && !inherited && data['sessionId'] !== state.sourceHeader.id) {
+    throw refusal('current-generation delivery marker names the wrong Session')
+  }
+}
+
+function transformReleasedRun(
+  state: ReleasedV1ToV2State,
+  run: SessionFormatEventRun,
+  context: SessionFormatMigrationContext,
+): void {
+  if (!isReleasedAssistantChunkRun(run)) {
+    for (const event of run.expand()) transformReleasedEvent(state, event, context)
+    return
+  }
+  state.legacyTurns.previous = undefined
+  state.lastTime = run.lastTime
+  if (state.pending !== undefined
+    && (state.pending.group.terminal
+      || state.pending.group.turn !== run.turn
+      || state.pending.group.step !== run.step)) {
+    finishAttempt(state, context)
+  } else if (state.pending !== undefined) {
+    flushBuffered(state, state.pending, context)
+  }
+  state.pending ??= { group: attemptGroup(run.turn, run.step), afterLastChunk: [] }
+  assertAttemptRange(state, run.firstSeq, run.lastSeq)
+  flushAccumulator(state.pending.group)
+  appendStreamRecord(
+    state.pending.group,
+    run.stream as unknown as AssistantStreamRecord,
+    run.lastTime,
+  )
+  recordChunkSpan(state.pending.group, run.firstSeq, run.eventCount, run.lastTime)
+}
+
+function finishMigration(
+  state: ReleasedV1ToV2State,
+  context: SessionFormatMigrationContext,
+): number {
+  finishAttempt(state, context)
+  if (state.sourceHeader.isSeeded && state.targetCut === undefined) {
+    state.targetCut = state.targetSeq
+    context.emitEvent({
+      type: 'session/end-seed',
+      seq: state.targetSeq,
+      time: state.lastTime,
+      data: { inherited: true },
+    })
+    state.targetSeq += 1
+  }
+  return state.targetCut as number
+}
+
+function transformChunk(
+  state: ReleasedV1ToV2State,
+  event: SessionFormatEvent,
+  context: SessionFormatMigrationContext,
+): void {
   const data = record(event.data)
   const turn = coordinate(data['turn'])
   const step = coordinate(data['step'])
-  const group = current.get(`${turn}:${step}`)
-  if (group !== undefined) group.terminal = true
+  const chunk = record(data['chunk'])
+  if (state.pending !== undefined
+    && (state.pending.group.terminal
+      || state.pending.group.turn !== turn
+      || state.pending.group.step !== step)) {
+    finishAttempt(state, context)
+  } else if (state.pending !== undefined) {
+    flushBuffered(state, state.pending, context)
+  }
+  state.pending ??= { group: attemptGroup(turn, step), afterLastChunk: [] }
+  assertAttemptCut(state, state.pending.group, event.seq)
+  state.pending.group.accumulator ??= new AssistantStreamAccumulator()
+  state.pending.group.accumulator.push({
+    time: event.time,
+    chunk: data['chunk'] as Parameters<AssistantStreamAccumulator['push']>[0]['chunk'],
+  })
+  recordChunkSpan(state.pending.group, event.seq, 1, event.time)
+  if (chunk['type'] === 'finish') state.pending.group.terminal = true
 }
 
-function streamOf(group: AttemptGroup) {
-  const accumulator = new AssistantStreamAccumulator()
-  for (const event of group.chunks) {
-    const data = record(event.data)
-    accumulator.push({
+function transformMessage(
+  state: ReleasedV1ToV2State,
+  event: SessionFormatEvent,
+  context: SessionFormatMigrationContext,
+): void {
+  const data = record(event.data)
+  const turn = coordinate(data['turn'])
+  const step = coordinate(data['step'])
+  const sources = event.sourceEventSeqs
+  const pending = state.pending
+  if (pending !== undefined && (pending.group.turn !== turn || pending.group.step !== step)) {
+    finishAttempt(state, context)
+    emitSource(state, messageEvent(event, attemptGroup(turn, step)), context)
+    return
+  }
+  if (!Array.isArray(sources)) {
+    if (pending !== undefined) {
+      throw refusal(`assistant/message ${event.seq} does not cite its complete v1 chunk attempt`)
+    }
+    emitSource(state, messageEvent(event, attemptGroup(turn, step)), context)
+    return
+  }
+  if (sources.length === 0) {
+    finishAttempt(state, context)
+    emitSource(state, messageEvent(event, attemptGroup(turn, step)), context)
+    return
+  }
+  if (pending === undefined
+    || !matchesChunkSources(pending.group, sources)) {
+    throw refusal(`assistant/message ${event.seq} chunk provenance is not one complete ordered attempt`)
+  }
+  assertAttemptCut(state, pending.group, event.seq)
+  pending.group.terminal = true
+  flushBuffered(state, pending, context)
+  emitSource(state, messageEvent(event, pending.group), context)
+  state.pending = undefined
+}
+
+function finishAttempt(
+  state: ReleasedV1ToV2State,
+  context: SessionFormatMigrationContext,
+): void {
+  const pending = state.pending
+  if (pending === undefined) return
+  emitGenerated(state, pending.group.lastChunkSeq as number, attemptEvent(pending.group), context)
+  flushBuffered(state, pending, context)
+  state.pending = undefined
+}
+
+function flushBuffered(
+  state: ReleasedV1ToV2State,
+  pending: StreamingAttempt,
+  context: SessionFormatMigrationContext,
+): void {
+  for (const event of pending.afterLastChunk) emitSource(state, event, context)
+  pending.afterLastChunk.length = 0
+}
+
+function emitSource(
+  state: ReleasedV1ToV2State,
+  event: SessionFormatEvent,
+  context: SessionFormatMigrationContext,
+): void {
+  let source = event
+  if (state.sourceHeader.isSeeded
+    && event.seq === state.sourceCut
+    && event.type === 'session/end-seed') {
+    source = { ...event, data: { inherited: true } }
+  }
+  ensureTargetCut(state, event.seq, event.time, source.type, context)
+  state.mapping.set(event.seq, state.targetSeq)
+  context.emitEvent(remapReferences(source, state.targetSeq, state.mapping))
+  state.targetSeq += 1
+}
+
+function emitGenerated(
+  state: ReleasedV1ToV2State,
+  origin: number,
+  event: SessionFormatEvent,
+  context: SessionFormatMigrationContext,
+): void {
+  ensureTargetCut(state, origin, event.time, event.type, context)
+  context.emitEvent(remapReferences(event, state.targetSeq, state.mapping))
+  state.targetSeq += 1
+}
+
+function ensureTargetCut(
+  state: ReleasedV1ToV2State,
+  origin: number,
+  time: number,
+  type: string,
+  context: SessionFormatMigrationContext,
+): void {
+  if (!state.sourceHeader.isSeeded || state.targetCut !== undefined || origin < state.sourceCut) return
+  state.targetCut = state.targetSeq
+  if (origin === state.sourceCut && type === 'session/end-seed') return
+  context.emitEvent({
+    type: 'session/end-seed',
+    seq: state.targetSeq,
+    time,
+    data: { inherited: true },
+  })
+  state.targetSeq += 1
+}
+
+function assertAttemptCut(state: ReleasedV1ToV2State, group: AttemptGroup, member: number): void {
+  const first = group.spans[0]?.firstSeq ?? member
+  if ((first < state.sourceCut) !== (member < state.sourceCut)) {
+    throw refusal(`inherited Session cut ${state.sourceCut} splits one Assistant attempt`)
+  }
+}
+
+function assertAttemptRange(state: ReleasedV1ToV2State, first: number, last: number): void {
+  if ((first < state.sourceCut) !== (last < state.sourceCut)) {
+    throw refusal(`inherited Session cut ${state.sourceCut} splits one Assistant attempt`)
+  }
+}
+
+interface LegacyTurnState {
+  openTurn: number | null
+  openStep: number | null
+  previous: SessionFormatEvent | undefined
+}
+
+function legacyTurnState(): LegacyTurnState {
+  return { openTurn: null, openStep: null, previous: undefined }
+}
+
+function legacyInterruptedTurn(
+  state: LegacyTurnState,
+  event: SessionFormatEvent,
+): SessionFormatEvent | undefined {
+  if (event.type !== 'turn/start' || state.openTurn === null || state.openStep !== null
+    || coordinate(record(event.data)['turn']) !== state.openTurn + 1
+    || state.previous?.type !== 'agent/inbox/spliced') return undefined
+  const splice = record(state.previous.data)
+  if (splice['target'] !== 'next-turn' || !Array.isArray(splice['inserted']) || splice['inserted'].length === 0) {
+    return undefined
+  }
+  return {
+    type: 'turn/end',
+    seq: event.seq,
+    time: event.time,
+    data: { turn: state.openTurn, reason: { kind: 'interrupted' } },
+  }
+}
+
+function observeLegacyTurn(state: LegacyTurnState, event: SessionFormatEvent): void {
+  const data = record(event.data)
+  if (event.type === 'turn/start') {
+    state.openTurn = coordinate(data['turn'])
+    state.openStep = null
+  } else if (event.type === 'turn/end') {
+    state.openTurn = null
+    state.openStep = null
+  } else if (event.type === 'step/start') {
+    state.openStep = coordinate(data['step'])
+  } else if (event.type === 'step/end') {
+    state.openStep = null
+  }
+  state.previous = event
+}
+
+function splitLegacyGoalChange(event: SessionFormatEvent): {
+  readonly change: SessionFormatEvent
+  readonly message: SessionFormatEvent
+} | undefined {
+  if (event.type !== 'user/message') return undefined
+  const data = record(event.data)
+  const source = record(data['source'])
+  if (source['kind'] !== 'goal' || source['change'] === undefined) return undefined
+  return {
+    change: {
+      type: 'goal/change',
+      seq: event.seq,
       time: event.time,
-      chunk: data['chunk'] as Parameters<AssistantStreamAccumulator['push']>[0]['chunk'],
-    })
+      data: source['change'],
+    },
+    message: {
+      ...event,
+      data: { ...data, source: { kind: 'plugin', plugin: 'goal' } },
+    },
   }
-  return accumulator.snapshot() as unknown as SessionFormatJsonValue
+}
+
+function closesAttempt(event: SessionFormatEvent): boolean {
+  return event.type === 'turn/end'
+    || event.type === 'step/end'
+    || event.type === 'llm/retry'
+    || event.type === 'llm/retry-started'
+}
+
+function attemptGroup(turn: number, step: number): AttemptGroup {
+  return { turn, step, spans: [], stream: [], chunkCount: 0, terminal: false }
+}
+
+function recordChunkSpan(group: AttemptGroup, firstSeq: number, eventCount: number, lastTime: number): void {
+  const previous = group.spans.at(-1)
+  if (previous !== undefined && previous.firstSeq + previous.eventCount === firstSeq) {
+    previous.eventCount += eventCount
+  } else {
+    group.spans.push({ firstSeq, eventCount })
+  }
+  group.chunkCount += eventCount
+  group.lastChunkSeq = firstSeq + eventCount - 1
+  group.lastChunkTime = lastTime
+}
+
+function matchesChunkSources(group: AttemptGroup, sources: readonly unknown[]): boolean {
+  if (sources.length !== group.chunkCount) return false
+  let index = 0
+  for (const span of group.spans) {
+    for (let offset = 0; offset < span.eventCount; offset += 1) {
+      if (sources[index] !== span.firstSeq + offset) return false
+      index += 1
+    }
+  }
+  return true
+}
+
+function recordLastTime(record: AssistantStreamRecord): number {
+  if (record.type === 'chunk') return record.time
+  return record.dt.reduce((time, gap) => time + gap, record.time0)
+}
+
+function mutableRecord(record: AssistantStreamRecord): AssistantStreamRecord {
+  if (record.type === 'chunk') return record
+  if (record.type === 'tool-call-chunks') {
+    return { ...record, dt: [...record.dt], args: [...record.args] }
+  }
+  return { ...record, dt: [...record.dt], texts: [...record.texts] }
+}
+
+function appendStreamRecord(
+  group: AttemptGroup,
+  source: AssistantStreamRecord,
+  lastTime: number,
+  owned = true,
+): void {
+  const previous = group.stream.at(-1)
+  if (previous === undefined || source.type === 'chunk' || previous.record.type !== source.type) {
+    group.stream.push({ record: owned ? source : mutableRecord(source), lastTime })
+    return
+  }
+  const gap = source.time0 - previous.lastTime
+  if (previous.record.index !== source.index || !Number.isSafeInteger(gap)) {
+    group.stream.push({ record: owned ? source : mutableRecord(source), lastTime })
+    return
+  }
+  if (source.type === 'tool-call-chunks') {
+    const target = previous.record as Extract<AssistantStreamRecord, { readonly type: 'tool-call-chunks' }>
+    if (target.id !== source.id || target.name !== source.name) {
+      group.stream.push({ record: owned ? source : mutableRecord(source), lastTime })
+      return
+    }
+    ;(target.dt as number[]).push(gap)
+    for (const value of source.dt) (target.dt as number[]).push(value)
+    for (const value of source.args) (target.args as string[]).push(value)
+  } else {
+    const target = previous.record as Extract<AssistantStreamRecord, {
+      readonly type: 'text-chunks' | 'reasoning-chunks'
+    }>
+    ;(target.dt as number[]).push(gap)
+    for (const value of source.dt) (target.dt as number[]).push(value)
+    for (const value of source.texts) (target.texts as string[]).push(value)
+  }
+  previous.lastTime = lastTime
+}
+
+function flushAccumulator(group: AttemptGroup): void {
+  const accumulator = group.accumulator
+  if (accumulator === undefined) return
+  for (const record of accumulator.snapshot()) {
+    appendStreamRecord(group, record, recordLastTime(record), false)
+  }
+  delete group.accumulator
+}
+
+function streamOf(group: AttemptGroup) {
+  flushAccumulator(group)
+  return group.stream.map(({ record }) => record) as unknown as SessionFormatJsonValue
 }
 
 function messageEvent(source: SessionFormatEvent, group: AttemptGroup): SessionFormatEvent {
@@ -209,42 +554,14 @@ function messageEvent(source: SessionFormatEvent, group: AttemptGroup): SessionF
 }
 
 function attemptEvent(group: AttemptGroup): SessionFormatEvent {
-  const last = group.chunks.at(-1) as SessionFormatEvent
   return {
     type: 'assistant/attempt',
-    seq: last.seq,
-    time: last.time,
+    seq: group.lastChunkSeq as number,
+    time: group.lastChunkTime as number,
     data: { turn: group.turn, step: group.step, stream: streamOf(group) },
   }
 }
 
-function stage(
-  staged: StagedEvent[],
-  oldToNew: Map<number, number>,
-  origin: number,
-  event: SessionFormatEvent,
-): void {
-  oldToNew.set(origin, staged.length)
-  staged.push({ origin, event })
-}
-
-function remapInheritedCut(
-  source: SessionFormatArtifact,
-  groups: readonly AttemptGroup[],
-  staged: readonly StagedEvent[],
-): number {
-  const cut = source.inheritedEventCount
-  for (const group of groups) {
-    const members = group.messageSeq === undefined
-      ? group.chunks.map(chunk => chunk.seq)
-      : [...group.chunks.map(chunk => chunk.seq), group.messageSeq]
-    const before = members.some(seq => seq < cut)
-    const after = members.some(seq => seq >= cut)
-    if (before && after) throw refusal(`inherited Session cut ${cut} splits one Assistant attempt`)
-  }
-  return staged.filter(candidate => candidate.origin < cut).length
-}
-
 function remapReferences(
   source: SessionFormatEvent,
   targetSeq: number,
@@ -360,7 +677,6 @@ function mapOne(
   return mapped
 }
 
-/* assertReleasedV1Artifact validates these payload coordinates before migration. */
 function record(value: SessionFormatJsonValue | undefined): SessionFormatJsonObject {
   return value as SessionFormatJsonObject
 }
@@ -373,10 +689,6 @@ function coordinate(value: SessionFormatJsonValue | undefined): number {
   return value as number
 }
 
-function sameNumbers(left: readonly number[], right: readonly number[]): boolean {
-  return left.length === right.length && left.every((value, index) => value === right[index])
-}
-
 function refusal(message: string): SessionFormatUnsupportedMigrationError {
   return new SessionFormatUnsupportedMigrationError(message)
 }

+ 105 - 0
packages/session/session-format-v1-to-v2/src/testing/validation.ts

@@ -0,0 +1,105 @@
+import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
+import {
+  SessionFormatError,
+  SessionFormatUnsupportedMigrationError,
+  sessionFormatCount,
+  snapshotSessionFormatJson,
+} from '@deepseek-ai/dsh-session-format'
+import type {
+  SessionFormatArtifact,
+  SessionFormatEvent,
+  SessionFormatJsonValue,
+} from '@deepseek-ai/dsh-session-format'
+import {
+  assertReleasedPayloadSemantics,
+  assertReleasedSurfaceMetadata,
+} from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import { RELEASED_V2_EVENT_DISPOSITIONS, RELEASED_V2_EVENT_TYPES } from '../dispositions.ts'
+import {
+  assertReleasedV2Keys,
+  assertReleasedV2PhysicalArtifact,
+  releasedV2Record,
+  restoreReleasedV2Artifact,
+} from '../validation.ts'
+
+const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
+const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result'])
+
+/**
+ * Validate the frozen released-v2 writer image in tests.
+ * @param artifact - released-v2 logical artifact.
+ */
+export function assertReleasedV2Artifact(artifact: SessionFormatArtifact): void {
+  assertReleasedV2PhysicalArtifact(artifact)
+  for (const [index, event] of artifact.events.entries()) {
+    const disposition = RELEASED_V2_EVENT_DISPOSITIONS[event.type]
+    if (disposition === undefined) {
+      throw new SessionFormatUnsupportedMigrationError(
+        `format v2 contains unknown event type ${JSON.stringify(event.type)} at seq ${index}`,
+      )
+    }
+    if (SURFACE_TYPES.has(event.type)) {
+      assertReleasedSurfaceMetadata(event, index, event.type, 'forbid-assistant')
+    }
+    assertPayload(event, disposition)
+  }
+  restoreReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET)
+}
+
+export { assertReleasedV2PhysicalArtifact } from '../validation.ts'
+
+function assertPayload(
+  event: SessionFormatEvent,
+  disposition: (typeof RELEASED_V2_EVENT_DISPOSITIONS)[string],
+): void {
+  const data = releasedV2Record(event.data, `${event.type} ${event.seq} data`)
+  assertReleasedV2Keys(data, disposition.required, disposition.optional, `${event.type} ${event.seq} data`)
+  for (const key of disposition.opaque) {
+    if (Object.hasOwn(data, key)) snapshotSessionFormatJson(data[key], `${event.type} ${event.seq} opaque ${key}`)
+  }
+  if (event.type === 'assistant/attempt' || event.type === 'assistant/message') {
+    const turn = sessionFormatCount(data['turn'], `${event.type} ${event.seq} turn`)
+    const step = sessionFormatCount(data['step'], `${event.type} ${event.seq} step`)
+    const assembler = new BlockAssembler()
+    let timed: ReturnType<typeof expandAssistantStream>
+    try {
+      timed = expandAssistantStream(data['stream'] as never)
+      for (const member of timed) {
+        assertReleasedPayloadSemantics({
+          type: 'assistant/chunk',
+          seq: event.seq,
+          time: member.time,
+          data: { turn, step, chunk: member.chunk } as unknown as SessionFormatJsonValue,
+        }, 2)
+        assembler.push(member.chunk)
+      }
+    } catch (error: unknown) {
+      throw new SessionFormatError(`${event.type} ${event.seq} has an invalid embedded stream`, { cause: error })
+    }
+    if (event.type === 'assistant/attempt') return
+    assertReleasedPayloadSemantics(event, 2)
+    if (timed.length > 0) {
+      const message = releasedV2Record(data['message'], `assistant/message ${event.seq} message`)
+      const content = data['interrupted'] === true ? assembler.interruptedBlocks() : assembler.blocks()
+      if (!deepEqualJson(message['content'], content)) {
+        throw new SessionFormatError(`assistant/message ${event.seq} message content disagrees with its embedded stream`)
+      }
+      if (!deepEqualJson(data['usage'], assembler.usage)) {
+        throw new SessionFormatError(`assistant/message ${event.seq} usage disagrees with its embedded stream`)
+      }
+      const source = releasedV2Record(message['source'], `assistant/message ${event.seq} source`)
+      if (!deepEqualJson(source['replayState'], assembler.replayState)) {
+        throw new SessionFormatError(`assistant/message ${event.seq} replay state disagrees with its embedded stream`)
+      }
+    }
+    return
+  }
+  if (event.type === 'session/end-seed') {
+    if (data['inherited'] !== undefined && data['inherited'] !== true) {
+      throw new SessionFormatError(`session/end-seed ${event.seq} inherited must be true when present`)
+    }
+    return
+  }
+  assertReleasedPayloadSemantics(event, 2)
+}

+ 37 - 100
packages/session/session-format-v1-to-v2/src/validation.ts

@@ -1,26 +1,18 @@
 import { isAbsolute } from 'node:path'
-import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
-import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 import {
   SessionFormatError,
   SessionFormatUnsupportedMigrationError,
   sessionFormatCount,
   sessionFormatSafeInteger,
-  snapshotSessionFormatJson,
 } from '@deepseek-ai/dsh-session-format'
 import type {
   SessionFormatArtifact,
-  SessionFormatEvent,
   SessionFormatHeader,
   SessionFormatJsonObject,
   SessionFormatJsonValue,
 } from '@deepseek-ai/dsh-session-format'
-import {
-  assertReleasedArtifactRelationships,
-  assertReleasedPayloadSemantics,
-  assertReleasedSurfaceMetadata,
-} from '@deepseek-ai/dsh-session-format-v0-to-v1'
-import { RELEASED_V2_EVENT_DISPOSITIONS, RELEASED_V2_EVENT_TYPES } from './dispositions.ts'
+import { assertReleasedArtifactRelationships } from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import { RELEASED_V2_EVENT_DISPOSITIONS } from './dispositions.ts'
 
 const HEADER_REQUIRED = ['version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const
 const HEADER_OPTIONAL = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const
@@ -28,7 +20,6 @@ const EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const
 const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result'])
 const SURFACE_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const
 const LOG_OPTIONAL = ['ignorable'] as const
-const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
 const RELEASED_V2_RELATIONSHIP_EXTENSIONS = {
   stepEvents: new Set(['assistant/attempt']),
   preservedSourceTitleRequestText: true,
@@ -40,8 +31,8 @@ const RELEASED_V2_RELATIONSHIP_EXTENSIONS = {
  * @throws {SessionFormatError} when the header is not an exact released-v2 value.
  */
 export function assertReleasedV2Header(header: SessionFormatHeader): void {
-  const record = jsonRecord(header, 'format v2 header')
-  exactKeys(record, HEADER_REQUIRED, HEADER_OPTIONAL, 'format v2 header')
+  const record = releasedV2Record(header, 'format v2 header')
+  assertReleasedV2Keys(record, HEADER_REQUIRED, HEADER_OPTIONAL, 'format v2 header')
   if (record['version'] !== 2) throw new SessionFormatError('expected format v2 header')
   if (typeof record['id'] !== 'string') throw new SessionFormatError('format v2 header id must be a string')
   sessionFormatCount(record['createdAt'], 'format v2 header createdAt')
@@ -60,28 +51,9 @@ export function assertReleasedV2Header(header: SessionFormatHeader): void {
   }
 }
 
-/**
- * Validate the exact logical image emitted by the released v2 writer.
- * @param artifact - complete decoded released-v2 Session artifact.
- * @throws {SessionFormatError} when an envelope, payload, relationship, or inherited cut is invalid.
- * @throws {SessionFormatUnsupportedMigrationError} when the artifact contains an unknown event type.
- */
-export function assertReleasedV2Artifact(artifact: SessionFormatArtifact): void {
-  validateReleasedV2Artifact(artifact, 'target', RELEASED_V2_EVENT_TYPE_SET)
-}
-
-/**
- * Validate only the released-v2 physical header, event envelopes, and inherited cut.
- * Event vocabulary and payload semantics belong to target or installed-current restoration.
- * @param artifact - complete physical-codec output.
- */
-export function assertReleasedV2PhysicalArtifact(artifact: SessionFormatArtifact): void {
-  validateReleasedV2Artifact(artifact, 'physical')
-}
-
 function validateReleasedV2Artifact(
   artifact: SessionFormatArtifact,
-  mode: 'target' | 'current' | 'physical',
+  mode: 'current' | 'physical',
   knownEventTypes?: ReadonlySet<string>,
 ): void {
   assertReleasedV2Header(artifact.header)
@@ -90,15 +62,13 @@ function validateReleasedV2Artifact(
   if (!artifact.header.isSeeded && cut !== 0) throw new SessionFormatError('unseeded format v2 Session has inherited events')
   let lastInheritedMarker: number | undefined
   for (const [index, event] of artifact.events.entries()) {
-    const record = jsonRecord(event, `format v2 event ${index}`)
+    const record = releasedV2Record(event, `format v2 event ${index}`)
     const type = record['type']
     if (typeof type !== 'string') throw new SessionFormatError(`format v2 event ${index} type must be a string`)
     const disposition = RELEASED_V2_EVENT_DISPOSITIONS[type]
     const installed = knownEventTypes?.has(type) === true
-    const ignorableUnknown = disposition === undefined
-      && mode === 'current'
-      && record['ignorable'] === true
-    if (mode !== 'physical' && disposition === undefined && !installed && !ignorableUnknown) {
+    const ignorableUnknown = disposition === undefined && record['ignorable'] === true
+    if (mode === 'current' && disposition === undefined && !installed && !ignorableUnknown) {
       throw new SessionFormatUnsupportedMigrationError(
         `format v2 contains unknown event type ${JSON.stringify(type)} at seq ${index}`,
       )
@@ -107,16 +77,14 @@ function validateReleasedV2Artifact(
     const optional = mode === 'physical' || disposition === undefined
       ? SURFACE_OPTIONAL
       : surface ? SURFACE_OPTIONAL : LOG_OPTIONAL
-    exactKeys(record, EVENT_REQUIRED, optional, `format v2 event ${index}`)
+    assertReleasedV2Keys(record, EVENT_REQUIRED, optional, `format v2 event ${index}`)
     if (record['seq'] !== index) throw new SessionFormatError(`format v2 event ${index} is not dense`)
     sessionFormatSafeInteger(record['time'], `format v2 event ${index} time`)
     if (record['ignorable'] !== undefined && record['ignorable'] !== true) {
       throw new SessionFormatError(`format v2 event ${index} ignorable must be true when present`)
     }
-    if (mode === 'target' && surface) assertReleasedSurfaceMetadata(record, index, type, 'forbid-assistant')
-    if (mode === 'target' && disposition !== undefined) assertPayload(event, disposition)
     if (type === 'session/end-seed') {
-      const data = jsonRecord(event.data, `session/end-seed ${index} data`)
+      const data = releasedV2Record(event.data, `session/end-seed ${index} data`)
       if (data['inherited'] === true) lastInheritedMarker = index
     }
   }
@@ -126,74 +94,35 @@ function validateReleasedV2Artifact(
   if (!artifact.header.isSeeded && lastInheritedMarker !== undefined) {
     throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker')
   }
-  if (mode === 'target') {
+  if (mode === 'current') {
     assertReleasedArtifactRelationships(artifact, RELEASED_V2_RELATIONSHIP_EXTENSIONS)
   }
 }
 
-function assertPayload(
-  event: SessionFormatEvent,
-  disposition: (typeof RELEASED_V2_EVENT_DISPOSITIONS)[string],
-): void {
-  const data = jsonRecord(event.data, `${event.type} ${event.seq} data`)
-  exactKeys(data, disposition.required, disposition.optional, `${event.type} ${event.seq} data`)
-  for (const key of disposition.opaque) {
-    if (Object.hasOwn(data, key)) snapshotSessionFormatJson(data[key], `${event.type} ${event.seq} opaque ${key}`)
-  }
-  if (event.type === 'assistant/attempt' || event.type === 'assistant/message') {
-    const turn = sessionFormatCount(data['turn'], `${event.type} ${event.seq} turn`)
-    const step = sessionFormatCount(data['step'], `${event.type} ${event.seq} step`)
-    const assembler = new BlockAssembler()
-    let timed: ReturnType<typeof expandAssistantStream>
-    try {
-      timed = expandAssistantStream(data['stream'] as never)
-      for (const member of timed) {
-        assertReleasedPayloadSemantics({
-          type: 'assistant/chunk',
-          seq: event.seq,
-          time: member.time,
-          data: { turn, step, chunk: member.chunk } as unknown as SessionFormatJsonValue,
-        }, 2)
-        assembler.push(member.chunk)
-      }
-    } catch (error: unknown) {
-      throw new SessionFormatError(`${event.type} ${event.seq} has an invalid embedded stream`, { cause: error })
-    }
-    if (event.type === 'assistant/attempt') return
-    assertReleasedPayloadSemantics(event, 2)
-    if (timed.length > 0) {
-      const message = jsonRecord(data['message'], `assistant/message ${event.seq} message`)
-      const content = data['interrupted'] === true ? assembler.interruptedBlocks() : assembler.blocks()
-      if (!deepEqualJson(message['content'], content)) {
-        throw new SessionFormatError(`assistant/message ${event.seq} message content disagrees with its embedded stream`)
-      }
-      if (!deepEqualJson(data['usage'], assembler.usage)) {
-        throw new SessionFormatError(`assistant/message ${event.seq} usage disagrees with its embedded stream`)
-      }
-      const source = jsonRecord(message['source'], `assistant/message ${event.seq} source`)
-      if (!deepEqualJson(source['replayState'], assembler.replayState)) {
-        throw new SessionFormatError(`assistant/message ${event.seq} replay state disagrees with its embedded stream`)
-      }
-    }
-    return
-  }
-  if (event.type === 'session/end-seed') {
-    if (data['inherited'] !== undefined && data['inherited'] !== true) {
-      throw new SessionFormatError(`session/end-seed ${event.seq} inherited must be true when present`)
-    }
-    return
-  }
-  assertReleasedPayloadSemantics(event, 2)
-}
-
-function jsonRecord(value: SessionFormatJsonValue | undefined, label: string): SessionFormatJsonObject {
+/**
+ * Require one released-v2 value to be a JSON object.
+ * @param value - value to narrow.
+ * @param label - diagnostic subject.
+ * @returns the narrowed object.
+ */
+export function releasedV2Record(
+  value: SessionFormatJsonValue | undefined,
+  label: string,
+): SessionFormatJsonObject {
   if (typeof value !== 'object' || value === null || Array.isArray(value)) {
     throw new SessionFormatError(`${label} must be an object`)
   }
   return value as SessionFormatJsonObject
 }
 
-function exactKeys(
+/**
+ * Require one released-v2 object to contain exactly the admitted keys.
+ * @param value - object to inspect.
+ * @param required - keys that must be present.
+ * @param optional - additional keys that may be present.
+ * @param label - diagnostic subject.
+ */
+export function assertReleasedV2Keys(
   value: SessionFormatJsonObject,
   required: readonly string[],
   optional: readonly string[],
@@ -219,3 +148,11 @@ export function restoreReleasedV2Artifact(
   validateReleasedV2Artifact(artifact, 'current', knownEventTypes)
   return artifact
 }
+
+/**
+ * Validate the released-v2 physical envelope without interpreting event vocabulary.
+ * @param artifact - released-v2 physical artifact.
+ */
+export function assertReleasedV2PhysicalArtifact(artifact: SessionFormatArtifact): void {
+  validateReleasedV2Artifact(artifact, 'physical')
+}

+ 79 - 28
packages/session/session-format-v1-to-v2/tests/codec.spec.ts

@@ -1,8 +1,11 @@
 import { describe, expect, it } from 'vitest'
+import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
 import type {
   SessionFormatArtifact,
+  SessionFormatArtifactDecoder,
   SessionFormatEvent,
   SessionFormatJsonObject,
+  SessionFormatRecovery,
 } from '@deepseek-ai/dsh-session-format'
 import { decodeSeqRanges as decodeCurrentSeqRanges } from '@deepseek-ai/dsh-session'
 import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
@@ -21,6 +24,10 @@ const fullPhysicalHeader = {
 
 const textBlock = { type: 'text', text: 'text' } as const
 
+function throwUnknown(value: unknown): never {
+  throw value
+}
+
 function feedback(seq: number): SessionFormatEvent {
   return { type: 'feedback/record', seq, time: seq + 1, data: { text: `feedback-${seq}` } }
 }
@@ -50,6 +57,27 @@ function artifact(
   }
 }
 
+function decodeV2(
+  header: unknown,
+  rows: readonly unknown[],
+  recovery: SessionFormatRecovery = 'strict',
+): SessionFormatArtifact {
+  const decoder: SessionFormatArtifactDecoder = releasedV2SessionFormatCodec.createDecoder(header, recovery)
+  const context = new SessionFormatEventCollector()
+  for (const row of rows) decoder.decodeRow(row, context)
+  return { header: decoder.header, inheritedEventCount: decoder.finish(context), events: context.values }
+}
+
+function encodeV2(source: SessionFormatArtifact): {
+  readonly header: SessionFormatJsonObject
+  readonly rows: readonly SessionFormatJsonObject[]
+} {
+  return {
+    header: releasedV2SessionFormatCodec.encodeHeader(source.header, source.inheritedEventCount),
+    rows: source.events.map(event => releasedV2SessionFormatCodec.encodeEvent(event)),
+  }
+}
+
 describe('releasedV2SessionFormatCodec headers', () => {
   it('round-trips the minimal and complete optional header images', () => {
     expect(releasedV2SessionFormatCodec.version).toBe(2)
@@ -68,7 +96,7 @@ describe('releasedV2SessionFormatCodec headers', () => {
       agentPreset: 'default',
     })
 
-    const encodedMinimal = releasedV2SessionFormatCodec.encodeArtifact(artifact([]))
+    const encodedMinimal = encodeV2(artifact([]))
     expect(encodedMinimal).toStrictEqual({ header: minimalPhysicalHeader, rows: [] })
     const complete = artifact([], {
       header: {
@@ -83,8 +111,12 @@ describe('releasedV2SessionFormatCodec headers', () => {
         agentPreset: 'default',
       },
     })
-    expect(releasedV2SessionFormatCodec.encodeArtifact(complete).header)
+    expect(encodeV2(complete).header)
       .toStrictEqual(fullPhysicalHeader)
+    expect(() => releasedV2SessionFormatCodec.encodeHeader(
+      artifact([]).header,
+      1,
+    )).toThrow(/unseeded.*inherited events/)
   })
 
   it.each([
@@ -117,14 +149,14 @@ describe('releasedV2SessionFormatCodec rows', () => {
       userMessage(2, [0, 1]),
       userMessage(3, [0, 1, 2]),
     ])
-    const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
+    const encoded = encodeV2(source)
     expect(encoded.rows.map(row => row['sourceEventSeqs'])).toStrictEqual([
       undefined,
       [0],
       [0, 1],
       [[0, 2]],
     ])
-    expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
+    expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source)
   })
 
   it('keeps non-monotonic provenance scalar-only for the current backend reader', () => {
@@ -134,12 +166,12 @@ describe('releasedV2SessionFormatCodec rows', () => {
       userMessage(6, sourceEventSeqs),
     ])
 
-    const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
+    const encoded = encodeV2(source)
     const stored = encoded.rows[6]?.['sourceEventSeqs']
 
     expect(stored).toStrictEqual(sourceEventSeqs)
     expect(decodeCurrentSeqRanges(stored, 6)).toStrictEqual(sourceEventSeqs)
-    expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
+    expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source)
   })
 
   it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
@@ -149,9 +181,9 @@ describe('releasedV2SessionFormatCodec rows', () => {
       { type: 'turn/start', seq: 2, time: 3, data: { turn: 1, postReleaseMember: true } },
     ])
 
-    const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
+    const encoded = encodeV2(source)
 
-    expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
+    expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source)
   })
 
   it('expands mixed stored ranges and preserves non-provenance rows', () => {
@@ -159,10 +191,10 @@ describe('releasedV2SessionFormatCodec rows', () => {
       ...userMessage(5),
       sourceEventSeqs: [[0, 2], 4],
     }]
-    const decoded = releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, rows)
+    const decoded = decodeV2(minimalPhysicalHeader, rows)
     expect(decoded.events[0]).toStrictEqual(feedback(0))
     expect(decoded.events[5]?.sourceEventSeqs).toStrictEqual([0, 1, 2, 4])
-    const descending = releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [
+    const descending = decodeV2(minimalPhysicalHeader, [
       feedback(0), feedback(1), { ...userMessage(2), sourceEventSeqs: [1, 0] },
     ])
     expect(descending.events[2]?.sourceEventSeqs).toStrictEqual([1, 0])
@@ -185,42 +217,61 @@ describe('releasedV2SessionFormatCodec rows', () => {
     const rows = [feedback(0), feedback(1), feedback(2), feedback(3), {
       ...userMessage(4), sourceEventSeqs,
     }]
-    expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, rows)).toThrow(message)
+    expect(() => decodeV2(minimalPhysicalHeader, rows)).toThrow(message)
   })
 
   it('contains ordinary and non-SessionFormatError row failures in a recoverable tail', () => {
-    const explosive = new Proxy({}, { ownKeys: () => { throw new Error('proxy failure') } })
-    expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact(
+    const explosive = new Proxy(feedback(1), { ownKeys: () => { throw new Error('proxy failure') } })
+    const primitiveFailure = new Proxy(feedback(1), {
+      ownKeys: () => throwUnknown('primitive proxy failure'),
+    })
+    expect(decodeV2(
       minimalPhysicalHeader,
       [feedback(0), explosive],
+      'recoverable',
     ).events).toStrictEqual([feedback(0)])
-    expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact(
+    expect(decodeV2(
       minimalPhysicalHeader,
       [feedback(0), null],
+      'recoverable',
+    ).events).toStrictEqual([feedback(0)])
+    expect(decodeV2(
+      minimalPhysicalHeader,
+      [feedback(0), primitiveFailure],
+      'recoverable',
     ).events).toStrictEqual([feedback(0)])
-    expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact(
+    expect(decodeV2(
       minimalPhysicalHeader,
       [null, [], feedback(0)],
+      'recoverable',
     ).events).toStrictEqual([])
   })
 
   it('refuses malformed strict rows, strict gaps, and terminal recoverable tails', () => {
-    expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [null]))
+    expect(() => decodeV2(minimalPhysicalHeader, [null]))
       .toThrow(/row 0/)
-    expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [feedback(1)]))
+    expect(() => decodeV2(minimalPhysicalHeader, [feedback(1)]))
       .toThrow(/seq gap/)
-    expect(() => releasedV2SessionFormatCodec.decodeRecoverableArtifact(minimalPhysicalHeader, [{
+    expect(() => decodeV2(minimalPhysicalHeader, [{
       type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } },
-    }])).toThrow(/seq gap/)
-    expect(() => releasedV2SessionFormatCodec.decodeRecoverableArtifact(minimalPhysicalHeader, [null, {
+    }], 'recoverable')).toThrow(/seq gap/)
+    expect(() => decodeV2(minimalPhysicalHeader, [null, {
       type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason: { kind: 'completed' } },
-    }])).toThrow(/row 0/)
+    }], 'recoverable')).toThrow(/row 0/)
+    expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), extra: true }]))
+      .toThrow(/unexpected field extra/)
+    expect(() => decodeV2(minimalPhysicalHeader, [{ type: 'feedback/record', seq: 0, time: 1 }]))
+      .toThrow(/lacks required field data/)
+    expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), type: 1 }]))
+      .toThrow(/type must be a string/)
+    expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), ignorable: false }]))
+      .toThrow(/ignorable must be true/)
   })
 
   it('keeps the prefix before a recoverable non-terminal seq gap', () => {
-    expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact(minimalPhysicalHeader, [
+    expect(decodeV2(minimalPhysicalHeader, [
       feedback(0), feedback(3), feedback(1),
-    ]).events).toStrictEqual([feedback(0)])
+    ], 'recoverable').events).toStrictEqual([feedback(0)])
   })
 
   it('derives the last tagged seed marker and rejects lineage disagreements', () => {
@@ -230,13 +281,13 @@ describe('releasedV2SessionFormatCodec rows', () => {
       { type: 'session/end-seed', seq: 1, time: 2, data: {} },
       { type: 'session/end-seed', seq: 2, time: 3, data: { inherited: true } },
     ]
-    expect(releasedV2SessionFormatCodec.decodeArtifact(seededHeader, markers).inheritedEventCount).toBe(2)
-    expect(releasedV2SessionFormatCodec.decodeArtifact(seededHeader, [markers[0]]).inheritedEventCount).toBe(0)
-    expect(() => releasedV2SessionFormatCodec.decodeArtifact(seededHeader, []))
+    expect(decodeV2(seededHeader, markers).inheritedEventCount).toBe(2)
+    expect(decodeV2(seededHeader, [markers[0]]).inheritedEventCount).toBe(0)
+    expect(() => decodeV2(seededHeader, []))
       .toThrow(/lacks an inherited end-seed marker/)
-    expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [markers[0]]))
+    expect(() => decodeV2(minimalPhysicalHeader, [markers[0]]))
       .toThrow(/unseeded Session contains an inherited end-seed marker/)
-    expect(() => releasedV2SessionFormatCodec.decodeArtifact(seededHeader, [{
+    expect(() => decodeV2(seededHeader, [{
       type: 'session/end-seed', seq: 0, time: 1, data: null,
     }])).toThrow(/must be an object/)
   })

+ 509 - 22
packages/session/session-format-v1-to-v2/tests/migration.spec.ts

@@ -1,6 +1,23 @@
 import { describe, expect, it } from 'vitest'
-import { sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
-import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
+import {
+  assertReleasedV2Header,
+  releasedV2SessionFormatCodec,
+  sessionFormatV1ToV2,
+} from '@deepseek-ai/dsh-session-format-v1-to-v2'
+import { assertReleasedV2Artifact } from '../src/testing/validation.ts'
+import {
+  releasedV0SessionFormatCodec,
+  releasedV1SessionFormatCodec,
+  sessionFormatV0ToV1,
+} from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import type {
+  SessionFormatArtifact,
+  SessionFormatEvent,
+  SessionFormatEventRun,
+  SessionFormatHeader,
+  SessionFormatJsonObject,
+} from '@deepseek-ai/dsh-session-format'
+import { createSessionFormatCatalog, SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
 
 const message = {
   id: 'assistant-1',
@@ -20,6 +37,94 @@ function event(type: string, seq: number, time: number, data: SessionFormatEvent
   return { type, seq, time, data }
 }
 
+const catalog = createSessionFormatCatalog({
+  currentVersion: 2,
+  codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec],
+  currentEncoder: releasedV2SessionFormatCodec,
+  migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2],
+  restoreCurrent(artifact) {
+    assertReleasedV2Artifact(artifact)
+    return artifact
+  },
+  restoreTransformedCurrent(artifact) {
+    assertReleasedV2Artifact(artifact)
+    return artifact
+  },
+  restoreCurrentHeader(header) {
+    assertReleasedV2Header(header)
+    return header
+  },
+})
+
+function physicalV1Header(header: SessionFormatHeader, inheritedEventCount: number) {
+  return {
+    type: 'session',
+    version: 1,
+    id: header.id,
+    createdAt: header.createdAt,
+    ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
+    ...(header.parentSession === undefined ? {} : { parentSession: header.parentSession }),
+    ...(header.isSeeded ? { seedLength: inheritedEventCount } : {}),
+    ...(header.origin === undefined ? {} : { origin: header.origin }),
+    delegationDepth: header.delegationDepth,
+    ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }),
+  }
+}
+
+function migrateV1ToV2(source: SessionFormatArtifact): SessionFormatArtifact {
+  const restore = catalog.createRestore(
+    physicalV1Header(source.header, source.inheritedEventCount),
+    { recovery: 'strict', validation: 'current' },
+  )
+  for (const row of source.events) restore.decodeRow(row)
+  return restore.finish()
+}
+
+function stageHarness(options: {
+  readonly id?: string
+  readonly sourceCut?: number
+  readonly seeded?: boolean
+  readonly sourceKind?: 'decoded' | 'transformed'
+} = {}) {
+  const sourceHeader: SessionFormatHeader = {
+    version: 1,
+    id: options.id ?? 'stage',
+    createdAt: 1,
+    ...(options.seeded === true ? { parentSession: 'parent' } : {}),
+    isSeeded: options.seeded === true,
+    delegationDepth: 0,
+  }
+  const stage = sessionFormatV1ToV2.createStage({
+    sourceHeader,
+    targetHeader: sessionFormatV1ToV2.migrateHeader(sourceHeader),
+    sourceInheritedEventCount: options.sourceCut ?? 0,
+    sourceKind: options.sourceKind ?? 'decoded',
+  })
+  return { stage, output: new SessionFormatEventCollector() }
+}
+
+function packedRun(options: {
+  readonly firstSeq: number
+  readonly eventCount?: number
+  readonly turn?: number
+  readonly step?: number
+  readonly lastTime: number
+  readonly stream: SessionFormatJsonObject
+}): SessionFormatEventRun {
+  const eventCount = options.eventCount ?? 1
+  return {
+    runType: 'released-assistant-chunks',
+    firstSeq: options.firstSeq,
+    eventCount,
+    turn: options.turn ?? 1,
+    step: options.step ?? 1,
+    lastSeq: options.firstSeq + eventCount - 1,
+    lastTime: options.lastTime,
+    stream: options.stream,
+    *expand() {},
+  } as SessionFormatEventRun
+}
+
 describe('sessionFormatV1ToV2', () => {
   it('migrates one exact released-v1 header without reading events', () => {
     const header = {
@@ -33,6 +138,346 @@ describe('sessionFormatV1ToV2', () => {
     expect(() => sessionFormatV1ToV2.migrateHeader({ ...header, version: 0 })).toThrow(/v1 header/)
   })
 
+  it('validates a directly decoded v1 source before transforming it', () => {
+    const header = {
+      version: 1, id: 'source-validation', createdAt: 1, isSeeded: false, delegationDepth: 0,
+    }
+    expect(() => migrateV1ToV2({
+      header,
+      inheritedEventCount: 0,
+      events: [event('external/post-v1', 0, 1, null)],
+    })).toThrow(/unknown event type/)
+    expect(() => migrateV1ToV2({
+      header,
+      inheritedEventCount: 0,
+      events: [{
+        type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
+      }],
+    })).toThrow(/unexpected member/)
+  })
+
+  it.each([
+    ['unexpected member', { ...event('assistant/chunk', 0, 1, {}), extra: true }, /unexpected member extra/],
+    ['missing member', { type: 'assistant/chunk', seq: 0, time: 1 }, /lacks required member data/],
+    ['invalid ignorable marker', { ...event('assistant/chunk', 0, 1, {}), ignorable: false }, /ignorable must be true/],
+  ])('refuses an Assistant chunk envelope with an %s', (_name, candidate, expected) => {
+    const { stage, output } = stageHarness({ sourceKind: 'transformed' })
+    expect(() => { stage.transformEvent(candidate as SessionFormatEvent, output) }).toThrow(expected)
+  })
+
+  it('checks own-generation delivery markers but accepts inherited markers', () => {
+    const marker = (sessionId: string): SessionFormatEvent => event(
+      'session-log-deepseek/delivery-accepted',
+      1,
+      2,
+      { sessionId, throughSeq: 0, sessionFormatVersion: 1 },
+    )
+    const own = stageHarness({ id: 'child' })
+    expect(() => { own.stage.transformEvent(marker('other'), own.output) }).toThrow(/wrong Session/)
+
+    const inherited = stageHarness({ id: 'child', seeded: true, sourceCut: 2 })
+    inherited.stage.transformEvent(marker('parent'), inherited.output)
+    expect(inherited.output.values).toEqual([{ ...marker('parent'), seq: 0 }])
+  })
+
+  it('expands generic runs and refuses a packed run split by the inherited cut', () => {
+    const generic = stageHarness({ sourceKind: 'transformed' })
+    const retained = event('feedback/record', 0, 1, { text: 'retained' })
+    generic.stage.transformRun({
+      runType: 'test-run', firstSeq: 0, eventCount: 1, *expand() { yield retained },
+    }, generic.output)
+    expect(generic.output.values).toEqual([retained])
+
+    const split = stageHarness({ seeded: true, sourceCut: 1 })
+    expect(() => { split.stage.transformRun(packedRun({
+      firstSeq: 0,
+      eventCount: 2,
+      lastTime: 2,
+      stream: { type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] },
+    }), split.output) }).toThrow(/splits one Assistant attempt/)
+  })
+
+  it('coalesces adjacent packed text and tool-call runs without expanding them', () => {
+    const text = stageHarness()
+    text.stage.transformRun(packedRun({
+      firstSeq: 0, lastTime: 1,
+      stream: { type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] },
+    }), text.output)
+    text.stage.transformRun(packedRun({
+      firstSeq: 1, eventCount: 2, lastTime: 4,
+      stream: { type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['b', 'c'] },
+    }), text.output)
+    text.stage.finish(text.output)
+    expect(text.output.values[0]?.data).toMatchObject({
+      stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [2, 1], texts: ['a', 'b', 'c'] }],
+    })
+
+    const tool = stageHarness()
+    tool.stage.transformEvent(event('assistant/chunk', 0, 1, {
+      turn: 1, step: 1,
+      chunk: { type: 'tool-call-delta', index: 0, id: 'call', name: 'read', argumentsDelta: '{' },
+    }), tool.output)
+    tool.stage.transformRun(packedRun({
+      firstSeq: 1, eventCount: 2, lastTime: 3,
+      stream: {
+        type: 'tool-call-chunks', time0: 2, index: 0,
+        id: 'call', name: 'read', dt: [1], args: ['}', '!'],
+      },
+    }), tool.output)
+    tool.stage.finish(tool.output)
+    expect(tool.output.values[0]?.data).toMatchObject({
+      stream: [{
+        type: 'tool-call-chunks', time0: 1, index: 0,
+        id: 'call', name: 'read', dt: [1, 1], args: ['{', '}', '!'],
+      }],
+    })
+  })
+
+  it('keeps incompatible packed records and attempt groups separate', () => {
+    const mismatched = [
+      { index: 1, id: 'call', name: 'read', time0: 2 },
+      { index: 0, id: 'other', name: 'read', time0: 2 },
+      { index: 0, id: 'call', name: 'write', time0: 2 },
+      { index: 0, id: 'call', name: 'read', time0: Number.MAX_SAFE_INTEGER },
+    ] as const
+    for (const [index, next] of mismatched.entries()) {
+      const current = stageHarness()
+      current.stage.transformRun(packedRun({
+        firstSeq: 0,
+        lastTime: index === mismatched.length - 1 ? Number.MIN_SAFE_INTEGER : 1,
+        stream: {
+          type: 'tool-call-chunks', time0: index === mismatched.length - 1 ? Number.MIN_SAFE_INTEGER : 1,
+          index: 0, id: 'call', name: 'read', dt: [], args: ['a'],
+        },
+      }), current.output)
+      current.stage.transformRun(packedRun({
+        firstSeq: 1,
+        lastTime: next.time0,
+        stream: {
+          type: 'tool-call-chunks', time0: next.time0,
+          index: next.index, id: next.id, name: next.name, dt: [], args: ['b'],
+        },
+      }), current.output)
+      current.stage.finish(current.output)
+      expect((current.output.values[0]?.data as { stream: unknown[] }).stream).toHaveLength(2)
+    }
+
+    const groups = stageHarness()
+    groups.stage.transformRun(packedRun({
+      firstSeq: 0, lastTime: 1,
+      stream: { type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] },
+    }), groups.output)
+    groups.stage.transformRun(packedRun({
+      firstSeq: 1, step: 2, lastTime: 2,
+      stream: { type: 'text-chunks', time0: 2, index: 0, dt: [], texts: ['b'] },
+    }), groups.output)
+    groups.stage.finish(groups.output)
+    expect(groups.output.values).toHaveLength(2)
+  })
+
+  it('copies incompatible accumulator records behind an owned packed prefix', () => {
+    const text = stageHarness()
+    text.stage.transformRun(packedRun({
+      firstSeq: 0, lastTime: 1,
+      stream: { type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['packed'] },
+    }), text.output)
+    text.stage.transformEvent(event('assistant/chunk', 1, 2, {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'a' },
+    }), text.output)
+    text.stage.transformEvent(event('assistant/chunk', 2, 3, {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'b' },
+    }), text.output)
+    text.stage.transformRun(packedRun({
+      firstSeq: 3, lastTime: 4,
+      stream: { type: 'reasoning-chunks', time0: 4, index: 0, dt: [], texts: ['flush'] },
+    }), text.output)
+    text.stage.finish(text.output)
+    expect((text.output.values[0]?.data as { stream: unknown[] }).stream).toHaveLength(3)
+
+    const tool = stageHarness()
+    tool.stage.transformRun(packedRun({
+      firstSeq: 0, lastTime: 1,
+      stream: { type: 'tool-call-chunks', time0: 1, index: 0, id: 'call', dt: [], args: ['a'] },
+    }), tool.output)
+    tool.stage.transformEvent(event('assistant/chunk', 1, 2, {
+      turn: 1, step: 1,
+      chunk: { type: 'tool-call-delta', index: 0, id: 'other', argumentsDelta: 'b' },
+    }), tool.output)
+    tool.stage.transformRun(packedRun({
+      firstSeq: 2, lastTime: 3,
+      stream: { type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['flush'] },
+    }), tool.output)
+    tool.stage.finish(tool.output)
+    expect((tool.output.values[0]?.data as { stream: unknown[] }).stream).toHaveLength(3)
+  })
+
+  it('separates a terminal packed successor and a message from another attempt', () => {
+    const terminal = stageHarness()
+    terminal.stage.transformEvent(event('assistant/chunk', 0, 1, {
+      turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } },
+    }), terminal.output)
+    terminal.stage.transformRun(packedRun({
+      firstSeq: 1, lastTime: 2,
+      stream: { type: 'text-chunks', time0: 2, index: 0, dt: [], texts: ['next'] },
+    }), terminal.output)
+    terminal.stage.finish(terminal.output)
+    expect(terminal.output.values).toHaveLength(2)
+
+    const messageMismatch = stageHarness()
+    messageMismatch.stage.transformEvent(event('assistant/chunk', 0, 1, {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial' },
+    }), messageMismatch.output)
+    messageMismatch.stage.transformEvent({
+      ...event('assistant/message', 1, 2, { turn: 2, step: 1, message }),
+      surfaceOp: 'append',
+    }, messageMismatch.output)
+    expect(messageMismatch.output.values).toHaveLength(2)
+  })
+
+  it('settles incremental attempts and rejects ambiguous streamed input', () => {
+    const header = {
+      version: 1, id: 'streaming', createdAt: 1, isSeeded: false, delegationDepth: 0,
+    }
+    const chunk = (seq: number, value: string | undefined, turn = 1): SessionFormatEvent => event(
+      'assistant/chunk',
+      seq,
+      seq + 1,
+      {
+        turn,
+        step: 1,
+        chunk: value === undefined
+          ? { type: 'finish', reason: { kind: 'stop' } }
+          : { type: 'text-delta', index: 0, text: value },
+      },
+    )
+    const assistant = (
+      seq: number,
+      turn: number,
+      sourceEventSeqs?: readonly number[],
+    ): SessionFormatEvent => ({
+      ...event('assistant/message', seq, seq + 1, { turn, step: 1, message }),
+      ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
+      surfaceOp: 'append',
+    })
+
+    const restarted = migrateV1ToV2({
+      header,
+      inheritedEventCount: 0,
+      events: [
+        event('turn/start', 0, 1, { turn: 1 }),
+        event('step/start', 1, 2, { turn: 1, step: 1 }),
+        chunk(2, 'first'),
+        chunk(3, undefined),
+        chunk(4, 'second'),
+        event('step/end', 5, 6, { turn: 1, step: 1 }),
+        event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }),
+      ],
+    })
+    expect(restarted.events.filter(candidate => candidate.type === 'assistant/attempt')).toHaveLength(2)
+
+    expect(() => migrateV1ToV2({
+      header,
+      inheritedEventCount: 0,
+      events: [chunk(0, 'partial'), assistant(1, 1)],
+    })).toThrow(/does not cite/)
+
+    expect(() => migrateV1ToV2({
+      header,
+      inheritedEventCount: 0,
+      events: [chunk(0, 'partial'), assistant(1, 1, [1])],
+    })).toThrow(/complete ordered attempt/)
+    expect(() => migrateV1ToV2({
+      header,
+      inheritedEventCount: 0,
+      events: [event('external/unknown', 0, 1, null)],
+    })).toThrow(/unknown event type/)
+
+    const seededHeader = { ...header, parentSession: 'parent', isSeeded: true }
+    expect(migrateV1ToV2({ header: seededHeader, inheritedEventCount: 0, events: [] }).events).toEqual([{
+      type: 'session/end-seed', seq: 0, time: 1, data: { inherited: true },
+    }])
+    expect(migrateV1ToV2({
+      header: seededHeader,
+      inheritedEventCount: 0,
+      events: [event('turn/start', 0, 2, { turn: 1 })],
+    }).events[0]).toEqual({
+      type: 'session/end-seed', seq: 0, time: 2, data: { inherited: true },
+    })
+
+    expect(() => migrateV1ToV2({
+      header: seededHeader,
+      inheritedEventCount: 1,
+      events: [chunk(0, 'partial'), assistant(1, 1, [0])],
+    })).toThrow(/splits one Assistant attempt/)
+  })
+
+  it('splits a legacy goal mutation from its preserved model-visible message', () => {
+    const change = {
+      kind: 'goal/change', version: 1, operation: 'create',
+      goal: { id: 'goal-1', revision: 1, objective: 'finish', phase: 'active', maxGoalRounds: 2 },
+      roundsStarted: 0, createdAt: 1, updatedAt: 1,
+    }
+    const payload = {
+      goal: change.goal,
+      roundsStarted: change.roundsStarted,
+      createdAt: change.createdAt,
+      updatedAt: change.updatedAt,
+    }
+    const source: SessionFormatArtifact = {
+      header: { version: 1, id: 'legacy-goal', createdAt: 1, isSeeded: false, delegationDepth: 0 },
+      inheritedEventCount: 0,
+      events: [
+        {
+          ...event('user/message', 0, 1, {
+            id: 'goal-message', role: 'user',
+            content: [{ type: 'text', text: `<goal_state>${JSON.stringify(payload)}</goal_state>` }],
+            source: { kind: 'goal', goalId: 'goal-1', revision: 1, round: 0, change },
+          }),
+          surfaceOp: 'append',
+        },
+      ],
+    }
+
+    expect(migrateV1ToV2(source).events).toMatchObject([
+      { type: 'goal/change', seq: 0, data: change },
+      { type: 'user/message', seq: 1, data: { source: { kind: 'plugin', plugin: 'goal' } } },
+    ])
+  })
+
+  it('closes the prior turn for the bounded legacy next-turn resume pattern', () => {
+    const source: SessionFormatArtifact = {
+      header: { version: 1, id: 'legacy-turn', createdAt: 1, isSeeded: false, delegationDepth: 0 },
+      inheritedEventCount: 0,
+      events: [
+        event('turn/start', 0, 1, { turn: 1 }),
+        event('step/start', 1, 2, { turn: 1, step: 1 }),
+        event('step/end', 2, 3, { turn: 1, step: 1 }),
+        event('agent/inbox/spliced', 3, 4, {
+          target: 'next-turn', start: 0, inserted: [userMessage],
+        }),
+        event('turn/start', 4, 5, { turn: 2 }),
+        event('turn/end', 5, 6, { turn: 2, reason: { kind: 'completed' } }),
+      ],
+    }
+
+    const migrated = migrateV1ToV2(source)
+    expect(migrated.events.map(candidate => candidate.type)).toEqual([
+      'turn/start', 'step/start', 'step/end', 'agent/inbox/spliced',
+      'turn/end', 'turn/start', 'turn/end',
+    ])
+    expect(migrated.events[4]).toMatchObject({
+      data: { turn: 1, reason: { kind: 'interrupted' } },
+    })
+
+    const invalid = {
+      ...source,
+      events: source.events.map(candidate => candidate.seq === 3
+        ? event('agent/inbox/spliced', 3, 4, { target: 'next-turn', start: 0, inserted: [] })
+        : candidate),
+    }
+    expect(() => migrateV1ToV2(invalid)).toThrow(/turn\/start 2/)
+  })
+
   it('embeds an interleaved successful stream and densely remaps survivors', () => {
     const source: SessionFormatArtifact = {
       header: {
@@ -73,7 +518,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    expect(sessionFormatV1ToV2.migrate(source)).toStrictEqual({
+    expect(migrateV1ToV2(source)).toStrictEqual({
       header: { ...source.header, version: 2 },
       inheritedEventCount: 0,
       events: [
@@ -133,7 +578,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    expect(sessionFormatV1ToV2.migrate(source)).toStrictEqual({
+    expect(migrateV1ToV2(source)).toStrictEqual({
       header: { ...source.header, version: 2 },
       inheritedEventCount: 0,
       events: [
@@ -203,7 +648,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    const migrated = sessionFormatV1ToV2.migrate(source)
+    const migrated = migrateV1ToV2(source)
     expect(migrated.events.filter(event => event.type.startsWith('assistant/'))).toStrictEqual([
       event('assistant/attempt', 3, 110, {
         turn: 1,
@@ -260,7 +705,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    const migrated = sessionFormatV1ToV2.migrate(source)
+    const migrated = migrateV1ToV2(source)
     expect(migrated.inheritedEventCount).toBe(5)
     expect(migrated.events[5]).toStrictEqual({
       type: 'session/end-seed',
@@ -284,7 +729,7 @@ describe('sessionFormatV1ToV2', () => {
       events: [],
     }
 
-    expect(sessionFormatV1ToV2.migrate(source)).toStrictEqual({
+    expect(migrateV1ToV2(source)).toStrictEqual({
       header: { ...source.header, version: 2 },
       inheritedEventCount: 0,
       events: [{
@@ -305,7 +750,7 @@ describe('sessionFormatV1ToV2', () => {
       inheritedEventCount: 1,
       events: [event('feedback/record', 0, 9, { text: 'inherited' })],
     }
-    expect(sessionFormatV1ToV2.migrate(source)).toMatchObject({
+    expect(migrateV1ToV2(source)).toMatchObject({
       inheritedEventCount: 1,
       events: [
         { type: 'feedback/record', seq: 0 },
@@ -351,7 +796,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(/message content disagrees with its embedded stream/)
+    expect(() => migrateV1ToV2(source)).toThrow(/message content disagrees with its embedded stream/)
   })
 
   it('refuses an undeclared reference into the last consumed chunk of a failed attempt', () => {
@@ -391,7 +836,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(
+    expect(() => migrateV1ToV2(source)).toThrow(
       /command\/done 7 sourceEventSeq targets consumed assistant\/chunk 3/,
     )
   })
@@ -412,7 +857,7 @@ describe('sessionFormatV1ToV2', () => {
       }],
     }
 
-    expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(
+    expect(() => migrateV1ToV2(source)).toThrow(
       /format v1 contains unknown event type "external\/info" at seq 0/,
     )
   })
@@ -438,7 +883,7 @@ describe('sessionFormatV1ToV2', () => {
           event('turn/end', 4, 5, { turn: 1, reason: { kind: 'completed' } }),
         ],
       }
-      const migrated = sessionFormatV1ToV2.migrate(source)
+      const migrated = migrateV1ToV2(source)
       expect(migrated.events[2]).toMatchObject({
         type: 'assistant/message', data: { stream: [] }, surfaceOp: 'append',
       })
@@ -470,9 +915,51 @@ describe('sessionFormatV1ToV2', () => {
         event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }),
       ],
     })
-    expect(() => sessionFormatV1ToV2.migrate(build(undefined))).toThrow(/does not cite/)
-    expect(() => sessionFormatV1ToV2.migrate(build([2]))).toThrow(/complete ordered attempt/)
-    expect(() => sessionFormatV1ToV2.migrate(build([3, 2]))).toThrow(/complete ordered attempt/)
+    expect(() => migrateV1ToV2(build(undefined))).toThrow(/does not cite/)
+    expect(() => migrateV1ToV2(build([2]))).toThrow(/complete ordered attempt/)
+    expect(() => migrateV1ToV2(build([3, 2]))).toThrow(/complete ordered attempt/)
+  })
+
+  it.each([
+    ['a message after its attempt step has closed', [
+      event('turn/start', 0, 1, { turn: 1 }),
+      event('step/start', 1, 2, { turn: 1, step: 1 }),
+      event('assistant/chunk', 2, 3, {
+        turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' },
+      }),
+      event('assistant/chunk', 3, 4, {
+        turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } },
+      }),
+      event('step/end', 4, 5, { turn: 1, step: 1 }),
+      { ...event('assistant/message', 5, 6, { turn: 1, step: 1, message }), surfaceOp: 'append' },
+      event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }),
+    ]],
+    ['a message that cites a different pending attempt', [
+      event('turn/start', 0, 1, { turn: 1 }),
+      event('step/start', 1, 2, { turn: 1, step: 1 }),
+      event('assistant/chunk', 2, 3, {
+        turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' },
+      }),
+      event('assistant/chunk', 3, 4, {
+        turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } },
+      }),
+      {
+        ...event('assistant/message', 4, 5, { turn: 1, step: 2, message }),
+        sourceEventSeqs: [2, 3],
+        surfaceOp: 'append',
+      },
+      event('step/end', 5, 6, { turn: 1, step: 1 }),
+      event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }),
+    ]],
+  ] as const)('rejects %s during complete target validation', (_name, events) => {
+    expect(() => migrateV1ToV2({
+      header: {
+        version: 1, id: 'v1-final-validation', createdAt: 1,
+        isSeeded: false, delegationDepth: 0,
+      },
+      inheritedEventCount: 0,
+      events,
+    })).toThrow(/does not match an open turn and step/)
   })
 
   it('refuses a lineage cut between members of one interleaved stream', () => {
@@ -501,7 +988,7 @@ describe('sessionFormatV1ToV2', () => {
         event('turn/end', 7, 8, { turn: 1, reason: { kind: 'completed' } }),
       ],
     }
-    expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(/cut 3 splits one Assistant attempt/)
+    expect(() => migrateV1ToV2(source)).toThrow(/cut 3 splits one Assistant attempt/)
   })
 
   it('refuses a lineage cut between a complete stream and its committed message', () => {
@@ -530,7 +1017,7 @@ describe('sessionFormatV1ToV2', () => {
         event('turn/end', 7, 8, { turn: 1, reason: { kind: 'completed' } }),
       ],
     }
-    expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(/cut 4 splits one Assistant attempt/)
+    expect(() => migrateV1ToV2(source)).toThrow(/cut 4 splits one Assistant attempt/)
   })
 
   it('keeps referenced-session generation provenance frozen', () => {
@@ -565,7 +1052,7 @@ describe('sessionFormatV1ToV2', () => {
       events: [{ ...event('user/message', 0, 1, reference), surfaceOp: 'append' }],
     }
 
-    const migrated = sessionFormatV1ToV2.migrate(source)
+    const migrated = migrateV1ToV2(source)
     expect(migrated.events[0]?.data).toStrictEqual(reference)
   })
 
@@ -634,7 +1121,7 @@ describe('sessionFormatV1ToV2', () => {
         event('turn/end', 15, 16, { turn: 1, reason: { kind: 'completed' } }),
       ],
     }
-    const migrated = sessionFormatV1ToV2.migrate(source)
+    const migrated = migrateV1ToV2(source)
     expect(migrated.events.find(event => event.type === 'compaction/prune')?.data).toMatchObject({
       shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2],
     })
@@ -687,7 +1174,7 @@ describe('sessionFormatV1ToV2', () => {
       ],
     }
 
-    const migrated = sessionFormatV1ToV2.migrate(source)
+    const migrated = migrateV1ToV2(source)
     const titleRequest = migrated.events.find(event => event.type === 'session/title-llm-request')
 
     expect(titleRequest?.data).toMatchObject({
@@ -728,7 +1215,7 @@ describe('sessionFormatV1ToV2', () => {
         event('turn/end', 10, 11, { turn: 2, reason: { kind: 'completed' } }),
       ],
     }
-    expect(sessionFormatV1ToV2.migrate(source).events[3]?.data).toMatchObject({
+    expect(migrateV1ToV2(source).events[3]?.data).toMatchObject({
       shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1],
     })
   })
@@ -758,7 +1245,7 @@ describe('sessionFormatV1ToV2', () => {
         event('turn/end', 9, 10, { turn: 2, reason: { kind: 'error', error: failure } }),
       ],
     }
-    expect(sessionFormatV1ToV2.migrate(source).events.filter(event => event.type === 'assistant/attempt'))
+    expect(migrateV1ToV2(source).events.filter(event => event.type === 'assistant/attempt'))
       .toHaveLength(2)
   })
 })

+ 27 - 1
packages/session/session-format-v1-to-v2/tests/validation.spec.ts

@@ -8,10 +8,10 @@ import type {
 } from '@deepseek-ai/dsh-session-format'
 import {
   RELEASED_V2_EVENT_TYPES,
-  assertReleasedV2Artifact,
   assertReleasedV2Header,
   restoreReleasedV2Artifact,
 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
+import { assertReleasedV2Artifact } from '../src/testing/validation.ts'
 
 const textBlock = { type: 'text', text: 'hello' } as const
 const usage = { inputTokens: 3, outputTokens: 2 } as const
@@ -208,6 +208,22 @@ describe('released v2 event envelopes and payloads', () => {
     expect(() => { assertReleasedV2Artifact(value) }).toThrow(message)
   })
 
+  it.each([
+    ['inherited count beyond events', artifact([], { inheritedEventCount: 1 }), /exceeds its events/],
+    ['unseeded inherited count', artifact([
+      event('feedback/record', 0, { text: 'x' }),
+    ], { inheritedEventCount: 1 }), /unseeded.*inherited events/],
+    ['non-string type', artifact([
+      { type: 1, seq: 0, time: 1, data: {} } as unknown as SessionFormatEvent,
+    ]), /type must be a string/],
+    ['non-dense seq', artifact([event('feedback/record', 1, { text: 'x' })]), /not dense/],
+    ['false ignorable marker', artifact([
+      event('feedback/record', 0, { text: 'x' }, { ignorable: false }),
+    ]), /ignorable must be true/],
+  ])('rejects production artifact drift before payload restoration: %s', (_name, value, message) => {
+    expect(() => { restoreReleasedV2Artifact(value, new Set(RELEASED_V2_EVENT_TYPES)) }).toThrow(message)
+  })
+
   it('validates present and absent opaque payload members before relationship checks', () => {
     for (const includeMeta of [false, true]) {
       const value = artifact([
@@ -298,6 +314,16 @@ describe('released v2 seed and surface relationships', () => {
     expect(() => { assertReleasedV2Artifact(value) }).toThrow(message)
   })
 
+  it('rejects production seed lineage disagreement', () => {
+    const known = new Set(RELEASED_V2_EVENT_TYPES)
+    expect(() => { restoreReleasedV2Artifact(artifact([], {
+      header: { version: 2, id: 'seeded', createdAt: 1, isSeeded: true, delegationDepth: 0 },
+    }), known) }).toThrow(/seeded header disagrees/)
+    expect(() => { restoreReleasedV2Artifact(artifact([
+      event('session/end-seed', 0, { inherited: true }),
+    ]), known) }).toThrow(/unseeded.*inherited end-seed/)
+  })
+
   it('accepts append and exact replacement surface operations', () => {
     expect(() => { assertReleasedV2Artifact(artifact([
       event('user/message', 0, userData('one'), { surfaceOp: 'append' }),

+ 65 - 81
packages/test-support/llm-replay/src/index.ts

@@ -48,15 +48,10 @@ interface ParsedSessionFixture {
   readonly createdAt: number
   readonly inheritedEventCount: SessionLogOffsetType
   readonly events: SessionEvent[]
-  readonly artifact: ReturnType<typeof sessionFormatCatalog.migrate>
+  readonly artifact: ReturnType<ReturnType<typeof sessionFormatCatalog.createRestore>['finish']>
   readonly sourceHeader: Readonly<Record<string, unknown>>
 }
 
-interface FixtureJsonLine {
-  readonly lineNumber: number
-  readonly value: Record<string, unknown>
-}
-
 /**
  * One recorded model call. `throw` may replay prefix chunks before failing;
  * `hang` models cancellation. Derived chunk entries come from ordinary model
@@ -210,7 +205,13 @@ export function parseSessionLog(text: string): SessionEvent[] {
 
 /** Parse, complete, decode, and migrate one projected snapshot artifact without writing its source. */
 function parseSessionFixture(text: string): ParsedSessionFixture {
-  const parsed: FixtureJsonLine[] = []
+  let headerLineNumber: number | undefined
+  let sourceHeader: Record<string, unknown> | undefined
+  let restore: ReturnType<typeof sessionFormatCatalog.createRestore> | undefined
+  const rowLines: number[] = []
+  const eventLines: number[] = []
+  let bodyKind: 'complete' | 'projected' | undefined
+  let nextSeq = 0
   for (const [index, line] of text.split(/\r?\n/).entries()) {
     if (line.trim().length === 0) continue
     let value: unknown
@@ -222,19 +223,22 @@ function parseSessionFixture(text: string): ParsedSessionFixture {
     if (value === null || typeof value !== 'object' || Array.isArray(value)) {
       throw new Error(`session snapshot line ${index + 1} must be a JSON object`)
     }
-    parsed.push({ lineNumber: index + 1, value: value as Record<string, unknown> })
-  }
-  const headerLine = parsed[0]
-  if (headerLine === undefined) throw new Error('session snapshot must start with a session header')
-
-  const header = normalizeProjectedHeader(headerLine.value)
-  const rows: Record<string, unknown>[] = []
-  const rowLines: number[] = []
-  const eventLines: number[] = []
-  let bodyKind: 'complete' | 'projected' | undefined
-  let nextSeq = 0
-  for (const source of parsed.slice(1)) {
-    const record = normalizeProjectedRow(source.value)
+    const lineNumber = index + 1
+    const recordValue = value as Record<string, unknown>
+    if (restore === undefined) {
+      headerLineNumber = lineNumber
+      sourceHeader = recordValue
+      try {
+        restore = sessionFormatCatalog.createRestore(normalizeProjectedHeader(recordValue), {
+          recovery: 'strict',
+          validation: 'current',
+        })
+      } catch (error: unknown) {
+        throw fixtureFormatError(error, lineNumber, [], [])
+      }
+      continue
+    }
+    const record = normalizeProjectedRow(recordValue)
     const packed = PACKED_CHUNK_ROW_TYPES.has(record.type as string)
     const seqKey = packed ? 'seq0' : 'seq'
     const timeKey = packed ? 'time0' : 'time'
@@ -242,12 +246,12 @@ function parseSessionFixture(text: string): ParsedSessionFixture {
     const hasTime = Object.hasOwn(record, timeKey)
     if (hasSeq !== hasTime) {
       throw new Error(
-        `session snapshot line ${source.lineNumber} must contain both ${seqKey} and ${timeKey}, or neither`,
+        `session snapshot line ${lineNumber} must contain both ${seqKey} and ${timeKey}, or neither`,
       )
     }
     const currentKind = hasSeq ? 'complete' : 'projected'
     if (bodyKind !== undefined && currentKind !== bodyKind) {
-      throw new Error(`session snapshot line ${source.lineNumber} cannot mix projected and complete body rows`)
+      throw new Error(`session snapshot line ${lineNumber} cannot mix projected and complete body rows`)
     }
     bodyKind = currentKind
     if (currentKind === 'projected') {
@@ -255,36 +259,28 @@ function parseSessionFixture(text: string): ParsedSessionFixture {
       record[timeKey] = 0
     }
     const cardinality = physicalRowCardinality(record)
-    rows.push(record)
-    rowLines.push(source.lineNumber)
-    eventLines.push(...Array.from({ length: cardinality }, () => source.lineNumber))
+    rowLines.push(lineNumber)
+    eventLines.push(...Array.from({ length: cardinality }, () => lineNumber))
     nextSeq += cardinality
+    try {
+      restore.decodeRow(record)
+    } catch (error: unknown) {
+      throw fixtureFormatError(error, headerLineNumber as number, rowLines, eventLines, rowLines.length - 1)
+    }
   }
-
-  let decoded: ReturnType<typeof sessionFormatCatalog.decodeArtifact>
-  try {
-    decoded = sessionFormatCatalog.decodeArtifact(header, rows)
-  } catch (error: unknown) {
-    const physicalRow = locateUnlabelledPhysicalFailure(error, header, rows)
-    throw fixtureFormatError(
-      error,
-      headerLine.lineNumber,
-      rowLines,
-      eventLines,
-      physicalRow,
-    )
+  if (restore === undefined || sourceHeader === undefined || headerLineNumber === undefined) {
+    throw new Error('session snapshot must start with a session header')
   }
   try {
-    const current = sessionFormatCatalog.migrate(decoded)
-    return parsedSessionFixture(current, headerLine.value)
+    return parsedSessionFixture(restore.finish(), sourceHeader)
   } catch (error: unknown) {
-    throw fixtureFormatError(error, headerLine.lineNumber, rowLines, eventLines)
+    throw fixtureFormatError(error, headerLineNumber, rowLines, eventLines)
   }
 }
 
 /** Materialize the common replay view from a migrated artifact. */
 function parsedSessionFixture(
-  artifact: ReturnType<typeof sessionFormatCatalog.decodeArtifact>,
+  artifact: ParsedSessionFixture['artifact'],
   sourceHeader: Readonly<Record<string, unknown>>,
 ): ParsedSessionFixture {
   return {
@@ -410,17 +406,16 @@ function migrateWrappedEventGroup(
     time: 0,
     data: { text: `comparison prefix ${String(seq)}` },
   }))
-  const migrated = sessionFormatCatalog.migrate({
-    header: {
-      version: 1,
-      id: first.sessionId,
-      createdAt: 0,
-      isSeeded: false,
-      delegationDepth: 0,
-    },
-    inheritedEventCount: 0,
-    events: [...prefix, ...entries.map(entry => normalizeProjectedRow(entry.event))] as never,
-  }).events.slice(prefix.length) as readonly Readonly<Record<string, unknown>>[]
+  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
@@ -498,11 +493,18 @@ export function prepareSessionEventNotificationsForComparison(text: string): str
 
 /** Encode one migrated fixture while retaining a projected cwd token. */
 function encodeCurrentSessionSnapshotFixture(text: string, parsed: ParsedSessionFixture): string {
-  const encoded = sessionFormatCatalog.encodeCurrent(parsed.artifact)
-  const header = { ...encoded.header }
+  const header = {
+    ...sessionFormatCatalog.encodeCurrentHeader(
+      parsed.artifact.header,
+      parsed.artifact.inheritedEventCount,
+    ),
+  }
   const sourceCwd = parsed.sourceHeader['cwd']
   if (typeof sourceCwd === 'string' && /^\{\{cwd\}\}(?:\/|$)/.test(sourceCwd)) header['cwd'] = sourceCwd
-  const output = [header, ...encoded.rows].map(record => JSON.stringify(record)).join('\n')
+  const output = [
+    JSON.stringify(header),
+    ...parsed.artifact.events.map(event => JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event))),
+  ].join('\n')
   return text.endsWith('\n') ? `${output}\n` : output
 }
 
@@ -564,13 +566,15 @@ function fixtureFormatError(
   const locationDetail = error instanceof Error && error.cause instanceof Error
     ? error.cause.message
     : detail
-  const row = /released (?:Session|(?:text|reasoning|tool-call)-chunks) row (\d+)/.exec(locationDetail)
+  const storedRow = /^released Session row (\d+)/.exec(locationDetail)
   const event = /Session event (\d+)/.exec(locationDetail)
     ?? / at seq (\d+)/.exec(locationDetail)
-    ?? /^[^ ]+ (\d+) /.exec(locationDetail)
-  const line = physicalRow === undefined && row === null
-    ? event === null ? headerLine : eventLines[Number(event[1])] ?? headerLine
-    : rowLines[physicalRow ?? Number(row?.[1])] ?? headerLine
+    ?? /inherited Session cut (\d+)/.exec(locationDetail)
+  let line: number
+  if (physicalRow !== undefined) line = rowLines[physicalRow] as number
+  else if (storedRow !== null) line = rowLines[Number(storedRow[1])] ?? headerLine
+  else if (event === null) line = headerLine
+  else line = eventLines[Number(event[1])] ?? headerLine
   const message = `session snapshot line ${line}: ${detail}`
   if (error instanceof SessionFormatUnsupportedMigrationError) {
     return new SessionFormatUnsupportedMigrationError(message, { cause: error })
@@ -578,26 +582,6 @@ function fixtureFormatError(
   return new Error(message, { cause: error })
 }
 
-/** Locate range-decoder failures whose frozen diagnostic predates physical-row labels. */
-function locateUnlabelledPhysicalFailure(
-  error: unknown,
-  header: Readonly<Record<string, unknown>>,
-  rows: readonly Readonly<Record<string, unknown>>[],
-): number | undefined {
-  const detail = error instanceof Error ? error.message : String(error)
-  if (!detail.startsWith('sourceEventSeqs ')) return undefined
-  const diagnosticHeader = Object.hasOwn(header, 'seedLength') ? { ...header, seedLength: 0 } : header
-  for (let index = 0; index < rows.length; index += 1) {
-    try {
-      sessionFormatCatalog.decodeArtifact(diagnosticHeader, rows.slice(0, index + 1))
-    } catch (candidate: unknown) {
-      const candidateDetail = candidate instanceof Error ? candidate.message : String(candidate)
-      if (candidateDetail === detail) return index
-    }
-  }
-  return undefined
-}
-
 /**
  * Read replay identity, ordering, and fork-seed facts from the JSONL header.
  *

+ 55 - 31
packages/test-support/llm-replay/tests/llm-replay.spec.ts

@@ -235,7 +235,7 @@ describe('Session format package parity', () => {
 })
 
 describe('fixture format diagnostics', () => {
-  it('attaches the header line to a non-Error catalog failure', async () => {
+  it('attaches the header line to a restore-construction failure', async () => {
     vi.resetModules()
     vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => {
       const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
@@ -243,7 +243,7 @@ describe('fixture format diagnostics', () => {
         ...actual,
         sessionFormatCatalog: {
           ...actual.sessionFormatCatalog,
-          decodeArtifact(): never {
+          createRestore(): never {
             const failure: unknown = 'decoder exploded'
             throw failure
           },
@@ -261,7 +261,7 @@ describe('fixture format diagnostics', () => {
     }
   })
 
-  it('falls back to the header when a source-range diagnostic has no matching physical prefix', async () => {
+  it('attaches the header line to a restore-finalization failure', async () => {
     vi.resetModules()
     vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => {
       const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
@@ -269,8 +269,14 @@ describe('fixture format diagnostics', () => {
         ...actual,
         sessionFormatCatalog: {
           ...actual.sessionFormatCatalog,
-          decodeArtifact(): never {
-            throw new Error('sourceEventSeqs synthetic unmatched failure')
+          createRestore() {
+            return {
+              header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
+              decodeRow() {},
+              finish(): never {
+                throw new Error('Session event 99 restore finalization failed')
+              },
+            }
           },
         },
       }
@@ -279,75 +285,89 @@ describe('fixture format diagnostics', () => {
       const replay = await import('../src/index.ts')
 
       expect(() => replay.parseSessionLog(sessionJsonl([])))
-        .toThrow('session snapshot line 1: sourceEventSeqs synthetic unmatched failure')
+        .toThrow('session snapshot line 1: Session event 99 restore finalization failed')
     } finally {
       vi.doUnmock('@deepseek-ai/dsh-session-format-catalog')
       vi.resetModules()
     }
   })
 
-  it('maps a matching non-Error source-range prefix failure to its physical row', async () => {
+  it('falls back to the header when a source-range diagnostic has no matching physical prefix', async () => {
     vi.resetModules()
     vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => {
       const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
-      let callCount = 0
       return {
         ...actual,
         sessionFormatCatalog: {
           ...actual.sessionFormatCatalog,
-          decodeArtifact(): never {
-            callCount += 1
-            if (callCount === 1) throw new Error('sourceEventSeqs synthetic prefix failure')
-            const failure: unknown = callCount === 2
-              ? 'sourceEventSeqs different prefix failure'
-              : 'sourceEventSeqs synthetic prefix failure'
-            throw failure
+          createRestore() {
+            return {
+              header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
+              decodeRow() {},
+              finish(): never {
+                throw new Error('sourceEventSeqs synthetic unmatched failure')
+              },
+            }
           },
         },
       }
     })
     try {
       const replay = await import('../src/index.ts')
-      const events: SessionEvent[] = [
-        { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } },
-        { type: 'permission/preset', seq: SessionSeq(1), time: 0, data: { preset: 'auto' } },
-      ]
 
-      expect(() => replay.parseSessionLog(sessionJsonl(events)))
-        .toThrow('session snapshot line 3: sourceEventSeqs synthetic prefix failure')
+      expect(() => replay.parseSessionLog(sessionJsonl([])))
+        .toThrow('session snapshot line 1: sourceEventSeqs synthetic unmatched failure')
     } finally {
       vi.doUnmock('@deepseek-ai/dsh-session-format-catalog')
       vi.resetModules()
     }
   })
 
-  it('falls back to the header for an out-of-range physical-row diagnostic', async () => {
+  it('maps a non-Error row failure to its physical row', async () => {
     vi.resetModules()
     vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => {
       const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
+      let row = 0
       return {
         ...actual,
         sessionFormatCatalog: {
           ...actual.sessionFormatCatalog,
-          decodeArtifact(): never {
-            throw new Error('released Session row 99 is malformed')
+          createRestore() {
+            return {
+              header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
+              decodeRow(): void {
+                row += 1
+                if (row === 2) throw 'row decoder exploded'
+              },
+              finish(): never { throw new Error('unexpected finish') },
+            }
           },
         },
       }
     })
     try {
       const replay = await import('../src/index.ts')
-      const event: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }
+      const events: SessionEvent[] = [
+        { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } },
+        { type: 'permission/preset', seq: SessionSeq(1), time: 0, data: { preset: 'auto' } },
+      ]
 
-      expect(() => replay.parseSessionLog(sessionJsonl([event])))
-        .toThrow('session snapshot line 1: released Session row 99 is malformed')
+      expect(() => replay.parseSessionLog(sessionJsonl(events)))
+        .toThrow('session snapshot line 3: row decoder exploded')
     } finally {
       vi.doUnmock('@deepseek-ai/dsh-session-format-catalog')
       vi.resetModules()
     }
   })
 
-  it('falls back to the header for an out-of-range logical-event diagnostic', async () => {
+  it.each([
+    ['physical row', 'released Session row 0 is malformed', 2],
+    ['logical event', 'Session event 0 is malformed', 2],
+    ['event seq', 'format v1 contains an invalid event at seq 0', 2],
+    ['inherited cut', 'inherited Session cut 0 splits one Assistant attempt', 2],
+    ['out-of-range physical row', 'released Session row 99 is malformed', 1],
+    ['out-of-range logical event', 'Session event 99 is malformed', 1],
+  ])('maps a %s finalization diagnostic to its source line', async (_label, message, line) => {
     vi.resetModules()
     vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => {
       const actual = await importOriginal<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
@@ -355,8 +375,12 @@ describe('fixture format diagnostics', () => {
         ...actual,
         sessionFormatCatalog: {
           ...actual.sessionFormatCatalog,
-          migrate(): never {
-            throw new Error('Session event 99 is malformed')
+          createRestore() {
+            return {
+              header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 },
+              decodeRow() {},
+              finish(): never { throw new Error(message) },
+            }
           },
         },
       }
@@ -366,7 +390,7 @@ describe('fixture format diagnostics', () => {
       const event: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }
 
       expect(() => replay.parseSessionLog(sessionJsonl([event])))
-        .toThrow('session snapshot line 1: Session event 99 is malformed')
+        .toThrow(`session snapshot line ${line}: ${message}`)
     } finally {
       vi.doUnmock('@deepseek-ai/dsh-session-format-catalog')
       vi.resetModules()

+ 2 - 1
scripts/gen-session-format-catalog.spec.ts

@@ -103,8 +103,9 @@ describe('session format catalog generator', () => {
     expect(declarations.map(item => [item.from, item.to])).toEqual([[0, 1], [1, 2]])
     expect(output).toContain("from '@deepseek-ai/dsh-session-format-v0-to-v1'")
     expect(output).toContain('currentVersion: 2')
-    expect(output).toContain('encodeCurrentArtifact: artifact => releasedV2SessionFormatCodec.encodeArtifact(artifact)')
+    expect(output).toContain('currentEncoder: releasedV2SessionFormatCodec')
     expect(output).toContain('restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)')
+    expect(output).toContain('restoreTransformedCurrent(artifact)')
     expect(output).toContain('assertReleasedV2Header(header)')
     expect(output).toContain('validateInstalledCurrentSessionHeader(header)')
     expect(output).toContain("from '@deepseek-ai/dsh-session'")

+ 4 - 1
scripts/gen-session-format-catalog.ts

@@ -191,13 +191,16 @@ export function renderSessionFormatCatalog(
     'export const sessionFormatCatalog = createSessionFormatCatalog({',
     `  currentVersion: ${currentVersion},`,
     `  codecs: [${codecs.join(', ')}],`,
-    `  encodeCurrentArtifact: artifact => ${currentCodec}.encodeArtifact(artifact),`,
+    `  currentEncoder: ${currentCodec},`,
     `  migrations: [${declarations.map(item => item.migration).join(', ')}],`,
     '  restoreCurrent(artifact) {',
     `    const restored = ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`,
     '    validateInstalledCurrentSessionArtifact(restored)',
     '    return restored',
     '  },',
+    '  restoreTransformedCurrent(artifact) {',
+    `    return ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`,
+    '  },',
     '  restoreCurrentHeader(header) {',
     `    ${headerValidator}(header)`,
     '    validateInstalledCurrentSessionHeader(header)',

+ 88 - 6
scripts/session-fixture-layout.ts

@@ -58,6 +58,51 @@ function validationHeader(value: unknown): unknown {
   return header
 }
 
+function validationRow(source: Readonly<Record<string, unknown>>): Record<string, unknown> {
+  if (source.type !== 'request/header') return { ...source }
+  const data = source.data
+  if (data === null || typeof data !== 'object' || Array.isArray(data)) return { ...source }
+  const header = (data as Record<string, unknown>).header
+  if (header === null || typeof header !== 'object' || Array.isArray(header)) return { ...source }
+  if ((header as Record<string, unknown>).tools !== '{{tools}}') return { ...source }
+  return {
+    ...source,
+    data: {
+      ...data,
+      header: { ...header, tools: [] },
+    },
+  }
+}
+
+function restoreRequestHeaderTokens(
+  events: readonly SessionEvent[],
+  rows: readonly Readonly<Record<string, unknown>>[],
+): SessionEvent[] {
+  const sources = rows.filter(row => row.type === 'request/header')
+  let sourceIndex = 0
+  return events.map((event) => {
+    if (event.type !== 'request/header') return event
+    const source = sources[sourceIndex]
+    sourceIndex += 1
+    const sourceData = source?.data
+    const sourceHeader = sourceData !== null && typeof sourceData === 'object' && !Array.isArray(sourceData)
+      ? (sourceData as Record<string, unknown>).header
+      : undefined
+    if (sourceHeader === null || typeof sourceHeader !== 'object' || Array.isArray(sourceHeader)
+      || (sourceHeader as Record<string, unknown>).tools !== '{{tools}}') return event
+    return {
+      ...event,
+      data: {
+        ...event.data,
+        header: {
+          ...(event.data as unknown as { header: Record<string, unknown> }).header,
+          tools: '{{tools}}',
+        },
+      },
+    } as SessionEvent
+  })
+}
+
 function renderFixture(headerLine: string, events: readonly SessionEvent[]): string {
   return [
     headerLine,
@@ -95,6 +140,7 @@ function parseFixtureObjectLine(line: string, lineNumber: number): Record<string
 function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[] {
   const rows: Record<string, unknown>[] = []
   const rowLines: number[] = []
+  const eventLines: number[] = []
   let nextSeq: SessionLogOffsetType = SessionLogOffset(0)
   let headerSkipped = false
   for (const [index, line] of content.split(/\r?\n/).entries()) {
@@ -113,7 +159,9 @@ function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[]
     if (!Object.hasOwn(record, timeKey)) record[timeKey] = 0
     rows.push(record)
     rowLines.push(index + 1)
-    nextSeq = SessionLogOffset(nextSeq + projectedRowCardinality(record))
+    const cardinality = projectedRowCardinality(record)
+    for (let offset = 0; offset < cardinality; offset += 1) eventLines.push(index + 1)
+    nextSeq = SessionLogOffset(nextSeq + cardinality)
   }
   // Versionless protocol fixtures and current projected snapshots use scalar
   // event rows. Current snapshots may contain owner-restored scrub tokens such
@@ -143,18 +191,52 @@ function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[]
       }
     })
   }
+  let restore: ReturnType<typeof sessionFormatCatalog.createRestore>
+  try {
+    restore = sessionFormatCatalog.createRestore(validationHeader(headerValue), {
+      recovery: 'strict',
+      validation: 'current',
+    })
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error)
+    throw new Error(`session snapshot line 1: ${detail}`, { cause: error })
+  }
+  for (const [index, row] of rows.entries()) {
+    try {
+      restore.decodeRow(validationRow(row))
+    } catch (error) {
+      const detail = error instanceof Error ? error.message : String(error)
+      throw new Error(`session snapshot line ${rowLines[index] ?? 1}: ${detail}`, { cause: error })
+    }
+  }
   try {
-    return [
-      ...sessionFormatCatalog.decodeArtifact(validationHeader(headerValue), rows).events,
-    ] as unknown as SessionEvent[]
+    return restoreRequestHeaderTokens(
+      [...restore.finish().events] as unknown as SessionEvent[],
+      rows,
+    )
   } catch (error) {
     const detail = error instanceof Error ? error.message : String(error)
-    const storedRow = /\brow (\d+)\b/.exec(detail)
-    const line = storedRow === null ? 1 : rowLines[Number(storedRow[1])] ?? 1
+    const line = fixtureDiagnosticLine(error, rowLines, eventLines)
     throw new Error(`session snapshot line ${line}: ${detail}`, { cause: error })
   }
 }
 
+function fixtureDiagnosticLine(
+  error: unknown,
+  rowLines: readonly number[],
+  eventLines: readonly number[],
+): number {
+  const detail = error instanceof Error && error.cause instanceof Error
+    ? error.cause.message
+    : error instanceof Error ? error.message : String(error)
+  const physicalRow = /^released Session row (\d+)/.exec(detail)
+  if (physicalRow !== null) return rowLines[Number(physicalRow[1])] ?? 1
+  const event = /Session event (\d+)/.exec(detail)
+    ?? / at seq (\d+)/.exec(detail)
+    ?? /inherited Session cut (\d+)/.exec(detail)
+  return event === null ? 1 : eventLines[Number(event[1])] ?? 1
+}
+
 function withoutEnvelope(events: readonly SessionEvent[]): Array<Omit<SessionEvent, 'seq' | 'time'>> {
   return events.map((event) => {
     const { seq: _seq, time: _time, ...projected } = event