Просмотр исходного кода

fix(session): migrate legacy code preset references to ptc

Yichen Jiang 2 дней назад
Родитель
Сommit
ff33f79eb1

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: 9f749fe9acc3dc4ca530034791c71513a5e5d2d1
-2026-08-31-released-session-format-migrations.zh.md: 5818419c589d67f8169a06d54ee1b07b5443bfd6
+2026-08-31-released-session-format-migrations.md: ee4e06b569ce449d015a6ceb5c710efc8b2b3a0e
+2026-08-31-released-session-format-migrations.zh.md: d5418655361831f7dd443ab2f424a6221e910a5d

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -68,7 +68,9 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
 
 V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored in V2 must not become an active V3 upload watermark merely because the header changes. Other non-current marker generations retain their ignored meaning. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
 
-V2→V3 provides an identity body stage and a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. Identity preserves accepted logical events and the inherited cut; the header version and successor filename change. Its admission retains installed ordinary event additions and unknown ignorable events, while unknown required events refuse. Unchanged sequence numbers, references, payloads, and ordering make that preservation safe for the identity edge; structural extensions must reassess it rather than inherit an unconditional opaque-event promise. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
+V2→V3 owns bounded PTC conversions and a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [V2→V3 library](../../../../packages/session/session-format-v2-to-v3/README.md) defines the exact transformed fields and admission rules. Session, message, and call ids, sequence references, ordering, and inherited cuts remain stable. Structural extensions must reassess opaque-event admission rather than inherit an unconditional preservation promise. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
+
+Preset renames cover the creation header and every selection event because the latest selection controls resume while earlier selections control historical forks. Rewriting only the last selection loses that distinction. The released `code` id denotes the legacy built-in preset; migration is independent of the installed roster so the same bytes produce the same result on every host. Native V3 custom ids remain available without a global runtime alias.
 
 A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. V2→V3 validates and derives its cut from markers; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
 

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -68,7 +68,9 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
 
 V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记不能仅因 header 变化就成为有效的 V3 上传水位。其他非当前代际标记仍保持被忽略的含义。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
 
-V2→V3 提供恒等正文 Stage,以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。恒等保留可接受的逻辑事件和继承截点;header 版本与后继文件名会变化。其准入保留已安装的普通事件新增项和未知可忽略事件,同时拒绝未知必需事件。序号、引用、payload 与顺序不变,使恒等迁移边可以安全保留这些数据;结构性扩展必须重新评估,而非继承无条件保留不透明事件的承诺。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
+V2→V3 负责限定范围的 PTC 转换,以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[V2→V3 库](../../../../packages/session/session-format-v2-to-v3/README.zh.md)定义精确转换字段与准入规则。Session、消息和调用标识、序列引用、顺序与继承截点保持稳定。结构性扩展必须重新评估不透明事件的准入,而非继承无条件保留的承诺。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
+
+预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。
 
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
 

+ 73 - 0
apps/web/tests/preset-migration.snapshot.ts

@@ -0,0 +1,73 @@
+/** Cold V2 restoration mounts the shipped PTC preset and publishes only a V3 successor. */
+
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
+import { describe, expect, it } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { generationLogPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts'
+import { scanZstdFrames } from '../../../packages/session/session-persistence-jsonl/src/zstd.ts'
+import type {} from '@deepseek-ai/dsh-agent-presets'
+import type {} from '@deepseek-ai/dsh-api-session-controller'
+import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot'
+import { launchWebScaffold, webSnapshotMode } from './scaffold.ts'
+
+const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url))
+
+describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => {
+  it.each([false, true])('resumes code as PTC (selection events=%s)', async (withSelections) => {
+    const scaffold = await launchWebScaffold()
+    try {
+      const id = SessionId('preset-migration')
+      const fixture = await readFile(fixturePath, 'utf8')
+      const [fixtureHeader, ...fixtureEvents] = fixture.trimEnd().split('\n')
+        .map((line): Record<string, unknown> => JSON.parse(line) as Record<string, unknown>)
+      const events = withSelections ? fixtureEvents : fixtureEvents.filter(event => event['type'] !== 'agent-preset/selected')
+      const header = { ...fixtureHeader, id, cwd: scaffold.workspaceCwd }
+      const rows: Record<string, unknown>[] = events.map((event, seq) => ({ ...event, seq, time: seq + 2 }))
+      const source = Buffer.concat([header, ...rows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n'))))
+      const predecessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, id, 2, 'zstd')
+      const successor = join(dirname(predecessor), 'session.v3.jsonl.zstd')
+      await mkdir(dirname(predecessor), { recursive: true })
+      await writeFile(predecessor, source)
+
+      const reader = await scaffold.ctx.sessionPersistence.open(id, 'read')
+      try {
+        expect(reader.header.agentPreset).toBe('ptc')
+        await reader.read()
+      } finally {
+        await reader.close()
+      }
+      await expect(readFile(successor)).rejects.toMatchObject({ code: 'ENOENT' })
+
+      const resolved = await scaffold.ctx.sessionController.resolveAgent(id)
+      if ('error' in resolved) throw resolved.error
+      expect(scaffold.ctx.agentPresets.composedPreset(resolved.agent.ctx)).toBe('ptc')
+      expect(resolved.agent.session.header.agentPreset).toBe('ptc')
+      expect(resolved.agent.session.snapshotEvents()
+        .filter(event => event.type === 'agent-preset/selected')
+        .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : [])
+
+      const publishedBytes = await readFile(successor)
+      const published = Buffer.concat(scanZstdFrames(publishedBytes).frames
+        .map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8')
+      const expected = [
+        { ...header, version: 3, agentPreset: 'ptc' },
+        ...rows.map(row => row['type'] === 'agent-preset/selected'
+          && (row['data'] as { agentPreset: string }).agentPreset === 'code'
+          ? { ...row, data: { agentPreset: 'ptc' } }
+          : row),
+        // Agent activation closes its restored prefix with a fresh seed marker.
+        { type: 'session/end-seed', seq: rows.length, time: 0, data: {} },
+      ].map(row => JSON.stringify(row)).join('\n') + '\n'
+      const context = { sessionIds: [id], cwd: scaffold.workspaceCwd }
+      expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context))
+      expect(await readFile(predecessor)).toEqual(source)
+      expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort())
+        .toEqual(['session.v2.jsonl.zstd', 'session.v3.jsonl.zstd'])
+    } finally {
+      await scaffold.close()
+    }
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -28,6 +28,7 @@
     "tests/startup-rpc-budget.e2e.ts",
     "tests/minimal-preset.snapshot.ts",
     "tests/message-feedback-protocol.snapshot.ts",
+    "tests/preset-migration.snapshot.ts",
     "tests/live-interactions.e2e.ts",
     "tests/question-composer.e2e.ts",
     "tests/approval-composer.e2e.ts",

+ 43 - 0
packages/session/session-format-catalog/tests/preset-migration.spec.ts

@@ -0,0 +1,43 @@
+/** Every historical entry generation migrates all preset selections before projection or fork. */
+
+import { describe, expect, it } from 'vitest'
+import { sessionFormatCatalog } from '../src/index.ts'
+
+describe('catalog preset migration', () => {
+  it.each([0, 1, 2])('migrates a seeded v%i header and every inherited/local selection', (version) => {
+    const header = {
+      type: 'session', version, id: 'code', createdAt: 1, parentSession: 'parent', delegationDepth: 0,
+      agentPreset: 'code', ...(version === 2 ? { isSeeded: true } : { seedLength: 2 }),
+    }
+    const selections = ['code', 'standard', 'code', 'minimal', 'code']
+    const rows = selections.map((agentPreset, index) => ({
+      type: 'agent-preset/selected', seq: index + (version === 2 && index >= 2 ? 1 : 0),
+      time: index + 1, data: { agentPreset },
+    }))
+    const source = version === 2
+      ? [...rows.slice(0, 2), { type: 'session/end-seed', seq: 2, time: 2, data: { inherited: true } }, ...rows.slice(2)]
+      : rows
+    const before = JSON.stringify({ header, source })
+    const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
+    for (const row of source) restore.decodeRow(row)
+    const artifact = restore.finish()
+    expect(artifact.header).toMatchObject({ version: 3, id: 'code', agentPreset: 'ptc', isSeeded: true })
+    expect(artifact.inheritedEventCount).toBe(2)
+    expect(artifact.events.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5])
+    expect(artifact.events.filter(event => event.type === 'agent-preset/selected').map(event => event.data))
+      .toEqual(['ptc', 'standard', 'ptc', 'minimal', 'ptc'].map(agentPreset => ({ agentPreset })))
+    expect(artifact.events[2]).toMatchObject({ type: 'session/end-seed', data: { inherited: true } })
+    expect(JSON.stringify({ header, source })).toBe(before)
+  })
+
+  it('does not reinterpret a native v3 custom preset named code', () => {
+    const header = {
+      type: 'session', version: 3, id: 'native', createdAt: 1, isSeeded: false,
+      delegationDepth: 0, agentPreset: 'code',
+    }
+    const row = { type: 'agent-preset/selected', seq: 0, time: 1, data: { agentPreset: 'code' } }
+    const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
+    restore.decodeRow(row)
+    expect(restore.finish()).toMatchObject({ header: { agentPreset: 'code' }, events: [row] })
+  })
+})

+ 2 - 2
packages/session/session-format-v2-to-v3/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-format-v2-to-v3/README.md
-README.md: 8af117b18d2b6ad9ec3f791b4dfaa53408706cbb
-README.zh.md: 963b91e47542a8f30cb5d3a4df397ccd608cf8aa
+README.md: a9a94ab88b38ec8890298cbed92601cee9735251
+README.zh.md: ba417d4ba078e2e9d7fe8177ebbf33d44dde0307

+ 4 - 3
packages/session/session-format-v2-to-v3/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This library restores released-v2 Session records as v3 by translating durable PTC event names and plugin-source labels. It preserves every historical id, event order, sequence reference, timestamp, and inherited cut. Persistence consumes it through the static Session format catalog. It does not publish or modify durable files.
+This library restores released-v2 Session records as v3 by translating durable PTC preset references, event names, and plugin-source labels. It preserves every historical Session, message, and call id, event order, sequence reference, timestamp, and inherited cut. Persistence consumes it through the static Session format catalog. It does not publish or modify durable files.
 
 ## Table of Contents
 
@@ -35,7 +35,7 @@ Use the [catalog](../session-format-catalog/README.md) for restoration. Direct i
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-The header version becomes 3; all other header fields remain unchanged. The stage synchronously maps `tool/code-dispatch-start` and `tool/code-dispatch` to `tool/ptc-dispatch-start` and `tool/ptc-dispatch`. It maps the exact `tools-code-mode` plugin label to `tools-ptc` only in plugin-kind sources at `user/message.data.source`, `agent/inbox/spliced.data.inserted[].source`, and `session/title-llm-request.data.messages[].source`. Every other value remains unchanged, including ids containing `:code:`, message content, tool arguments, and opaque payloads.
+The header version becomes 3. The exact legacy preset id `code` becomes `ptc` in `header.agentPreset` and every `agent-preset/selected.data.agentPreset`, including inherited selections. Other preset ids and absent header presets remain unchanged; a selection without a string preset id is refused. The stage synchronously maps `tool/code-dispatch-start` and `tool/code-dispatch` to `tool/ptc-dispatch-start` and `tool/ptc-dispatch`. It maps the exact `tools-code-mode` plugin label to `tools-ptc` only in plugin-kind sources at `user/message.data.source`, `agent/inbox/spliced.data.inserted[].source`, and `session/title-llm-request.data.messages[].source`. Every other value remains unchanged, including ids containing `:code:`, message content, tool arguments, and opaque payloads.
 
 V3 validation accepts current PTC tags, not required legacy aliases. Unknown events marked `ignorable` retain their admission policy, except that source-v2 `tool/ptc-dispatch` and `tool/ptc-dispatch-start` events are rejected even when ignorable: these names are reserved in v3, so migration cannot reinterpret an opaque extension as a PTC lifecycle event. Physical record encoding remains the released-v2 encoding; only the header version and the named logical fields change.
 
@@ -84,8 +84,9 @@ The stage does not change message content or model configuration.
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- **Historical preset ownership** — `code` in released V0/V1/V2 preset references denotes the legacy built-in preset. Those logs cannot distinguish a custom preset with the same id; native V3 references are not reinterpreted. This library does not migrate `settings.yaml`.
 - **No file publication** — persistence owns immutable successor publication; this package never overwrites released generations.
-- **Bounded conversion only** — only the named event tags and plugin-source slots are transformed; arbitrary strings, unknown payloads, and historical ids are not rewritten.
+- **Bounded conversion only** — only the named preset references, event tags, and plugin-source slots are transformed; arbitrary strings, unknown payloads, and historical ids are not rewritten.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 4 - 3
packages/session/session-format-v2-to-v3/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-本库通过转换持久化 PTC 事件名称和插件来源标签,将已发布的 v2 Session 记录恢复为 v3。它保留每个历史标识、事件顺序、序列引用、时间戳和继承切点。持久化通过静态 Session 格式目录使用本库。本库不发布或修改持久化文件。
+本库通过转换持久化 PTC 预设引用、事件名称和插件来源标签,将已发布的 v2 Session 记录恢复为 v3。它保留每个历史 Session、消息和调用标识、事件顺序、序列引用、时间戳和继承切点。持久化通过静态 Session 格式目录使用本库。本库不发布或修改持久化文件。
 
 ## 目录
 
@@ -35,7 +35,7 @@ kind: "package-library"
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-头部版本变为 3,其余头部字段保持不变。阶段同步将 `tool/code-dispatch-start` 和 `tool/code-dispatch` 映射为 `tool/ptc-dispatch-start` 和 `tool/ptc-dispatch`。它仅在 `user/message.data.source`、`agent/inbox/spliced.data.inserted[].source` 和 `session/title-llm-request.data.messages[].source` 的 plugin 类型来源中,将精确匹配的 `tools-code-mode` 插件标签映射为 `tools-ptc`。其他所有值保持不变,包括含 `:code:` 的标识、消息内容、工具参数和不透明载荷。
+头部版本变为 3。`header.agentPreset` 和每条 `agent-preset/selected.data.agentPreset` 中精确匹配的旧预设标识 `code` 变为 `ptc`,包括继承的选择事件。其他预设标识以及缺失的头部预设保持不变;选择事件缺少字符串预设标识时会被拒绝。阶段同步将 `tool/code-dispatch-start` 和 `tool/code-dispatch` 映射为 `tool/ptc-dispatch-start` 和 `tool/ptc-dispatch`。它仅在 `user/message.data.source`、`agent/inbox/spliced.data.inserted[].source` 和 `session/title-llm-request.data.messages[].source` 的 plugin 类型来源中,将精确匹配的 `tools-code-mode` 插件标签映射为 `tools-ptc`。其他所有值保持不变,包括含 `:code:` 的标识、消息内容、工具参数和不透明载荷。
 
 V3 校验接受当前 PTC 标签,不接受必需的旧别名。标记为 `ignorable` 的未知事件保留其准入规则,但源 v2 中的 `tool/ptc-dispatch` 和 `tool/ptc-dispatch-start` 事件即使可忽略也会被拒绝:这些名称在 v3 中保留,迁移不能将不透明扩展重新解释为 PTC 生命周期事件。物理记录编码仍为已发布的 v2 编码;仅头部版本和上述逻辑字段发生变化。
 
@@ -84,8 +84,9 @@ V3 校验接受当前 PTC 标签,不接受必需的旧别名。标记为 `igno
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- **历史预设归属** — 已发布 V0/V1/V2 的预设引用中,`code` 表示旧内置预设。这些日志无法区分同名的自定义预设;原生 V3 引用不会被重新解释。本库不迁移 `settings.yaml`。
 - **不发布文件** — 持久化负责不可变后继代的发布;本包绝不覆盖已发布代。
-- **仅限定范围的转换** — 只转换指定的事件标签和插件来源位置;不改写任意字符串、未知载荷和历史标识。
+- **仅限定范围的转换** — 只转换指定的预设引用、事件标签和插件来源位置;不改写任意字符串、未知载荷和历史标识。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 12 - 3
packages/session/session-format-v2-to-v3/src/migration.ts

@@ -1,4 +1,4 @@
-/** Adjacent PTC event and plugin-attribution migration with stable historical identities. */
+/** Adjacent PTC preset, event, and plugin-attribution migration with stable historical identities. */
 
 import { SessionFormatError, SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
 import type {
@@ -13,7 +13,8 @@ import { assertReleasedV2Header } from '@deepseek-ai/dsh-session-format-v1-to-v2
 import { assertReleasedV3Header } from './validation.ts'
 
 /**
- * Rename released-v2 PTC dispatch events and owned plugin attribution without changing ids or content.
+ * Rename released-v2 PTC preset references, dispatch events, and owned plugin attribution.
+ * Preserve historical Session, message, and call ids and model content.
  * Refuse source delivery markers claiming v3 acceptance.
  */
 export const sessionFormatV2ToV3 = defineSessionFormatMigration({
@@ -22,7 +23,7 @@ export const sessionFormatV2ToV3 = defineSessionFormatMigration({
   toVersion: 3,
   migrateHeader(header) {
     assertReleasedV2Header(header)
-    return { ...header, version: 3 }
+    return { ...header, version: 3, ...(header.agentPreset === 'code' ? { agentPreset: 'ptc' } : {}) }
   },
   createStage(input) {
     return new ReleasedV2ToV3Stage(input)
@@ -79,6 +80,14 @@ class ReleasedV2ToV3Stage implements SessionFormatMigrationStage {
 
 function renamePtcEvent(event: SessionFormatEvent): SessionFormatEvent {
   switch (event.type) {
+    case 'agent-preset/selected': {
+      if (!isSessionFormatJsonObject(event.data) || typeof event.data['agentPreset'] !== 'string') {
+        throw new SessionFormatError('format v2 agent-preset/selected requires a string agentPreset')
+      }
+      return event.data['agentPreset'] === 'code'
+        ? { ...event, data: { ...event.data, agentPreset: 'ptc' } }
+        : event
+    }
     case 'tool/ptc-dispatch-start':
     case 'tool/ptc-dispatch':
       throw new SessionFormatUnsupportedMigrationError(

+ 65 - 0
packages/session/session-format-v2-to-v3/tests/preset-migration.spec.ts

@@ -0,0 +1,65 @@
+/** Released preset references change without rewriting opaque ids or extension data. */
+
+import { describe, expect, it } from 'vitest'
+import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
+import { sessionFormatV2ToV3 } from '../src/index.ts'
+
+const header = Object.freeze({
+  version: 2, id: 'code', createdAt: 1, isSeeded: false, delegationDepth: 0,
+})
+
+function migrate(event: SessionFormatEvent): SessionFormatEvent | undefined {
+  const stage = sessionFormatV2ToV3.createStage({
+    sourceHeader: header, targetHeader: sessionFormatV2ToV3.migrateHeader(header),
+    sourceInheritedEventCount: 0, sourceKind: 'decoded',
+  })
+  const collector = new SessionFormatEventCollector()
+  stage.transformEvent(event, collector)
+  expect(stage.finish(collector)).toBe(0)
+  return collector.values[0]
+}
+
+describe('released code preset references', () => {
+  it.each([undefined, 'code', 'ptc', 'standard', 'minimal', 'cordis', 'code-custom', 'Code'])(
+    'migrates only the exact legacy header preset (%s)', (agentPreset) => {
+      const source = Object.freeze({ ...header, ...(agentPreset === undefined ? {} : { agentPreset }) })
+      expect(sessionFormatV2ToV3.migrateHeader(source)).toEqual({
+        ...source, version: 3, ...(agentPreset === 'code' ? { agentPreset: 'ptc' } : {}),
+      })
+      expect(source.version).toBe(2)
+      expect(source.agentPreset).toBe(agentPreset)
+    },
+  )
+
+  it.each(['code', 'ptc', 'standard', 'minimal', 'cordis', 'code-custom', 'Code'])(
+    'preserves selection metadata and extension fields (%s)', (agentPreset) => {
+      const event = Object.freeze({
+        type: 'agent-preset/selected', seq: 19, time: -7, ignorable: true,
+        data: Object.freeze({ agentPreset, extension: Object.freeze({ agentPreset: 'code' }) }),
+        extension: 'code',
+      })
+      const output = migrate(event)
+      expect(output).toEqual({
+        ...event, data: { ...event.data, agentPreset: agentPreset === 'code' ? 'ptc' : agentPreset },
+      })
+      expect(event.data.agentPreset).toBe(agentPreset)
+      if (agentPreset !== 'code') expect(output).toBe(event)
+    },
+  )
+
+  it.each<SessionFormatJsonValue>([null, [], {}, { agentPreset: 1 }])(
+    'refuses a malformed durable selection (%j)', (data) => {
+      expect(() => migrate({ type: 'agent-preset/selected', seq: 0, time: 1, data }))
+        .toThrow(/requires a string agentPreset/)
+    },
+  )
+
+  it('keeps preset-looking values in unrelated payloads opaque', () => {
+    const event = Object.freeze({
+      type: 'external/selected', seq: 0, time: 1, ignorable: true,
+      data: Object.freeze({ agentPreset: 'code', code: 'code' }),
+    })
+    expect(migrate(event)).toBe(event)
+  })
+})

+ 3 - 2
scripts/session-snapshot-corpus.corpus.ts

@@ -25,6 +25,7 @@ const profiles = ['acp', 'sdk', 'session', 'web'] as const
 const snapshotAdapters = [
   'apps/web/tests/message-feedback-protocol.snapshot.ts',
   'apps/web/tests/minimal-preset.snapshot.ts',
+  'apps/web/tests/preset-migration.snapshot.ts',
   'snapshots/acp/acp.snapshot.ts',
   'snapshots/sdk/sdk.snapshot.ts',
   'snapshots/session/headless.snapshot.ts',
@@ -201,7 +202,7 @@ it('keeps a current-writer majority plus bounded declared historical migration c
   })))
 
   expect(assertSnapshotCorpusPolicy(inventory)).toMatchObject({
-    retainedRoles: 8,
-    retainedScenarios: 6,
+    retainedRoles: 9,
+    retainedScenarios: 7,
   })
 })

+ 8 - 0
snapshots/web/preset-migration/session.v2.jsonl

@@ -0,0 +1,8 @@
+{"type":"session","version":2,"id":"{{session:1}}","createdAt":1,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"code"}
+{"type":"agent-preset/selected","data":{"agentPreset":"code"}}
+{"type":"agent-preset/selected","data":{"agentPreset":"standard"}}
+{"type":"agent-preset/selected","data":{"agentPreset":"code"}}
+{"type":"session/end-seed","data":{}}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"feedback/record","data":{"text":"Keep code as literal feedback."}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 10 - 0
snapshots/web/preset-migration/snapshot.yml

@@ -0,0 +1,10 @@
+version: 1
+scenario: preset-migration
+profile: web
+composition: web-default
+recording: authored
+sessionFormat:
+  version: 2
+  coverage: [adjacent-migration]
+header:
+  class: web-default

+ 1 - 0
tsconfig.host.json

@@ -19,6 +19,7 @@
     "apps/web/tests/startup-rpc-budget.e2e.ts",
     "apps/web/tests/minimal-preset.snapshot.ts",
     "apps/web/tests/message-feedback-protocol.snapshot.ts",
+    "apps/web/tests/preset-migration.snapshot.ts",
     "apps/web/tests/live-interactions.e2e.ts",
     "apps/web/tests/question-composer.e2e.ts",
     "apps/web/tests/approval-composer.e2e.ts",