Procházet zdrojové kódy

Merge V3 preset-reference migration into system-prompt representation

Compose the root’s exact legacy code-to-ptc preset conversion with the existing structural system-head migration. Keep dense source events, audited payload admission, local reference mapping, and native V3 extension behavior unchanged. Preserve the historical preset migration fixture byte-for-byte and test unsupported sparse/opaque sources explicitly.
Tianyi Cui před 2 týdny
rodič
revize
95400aa52c

+ 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: ec450c4a39f58551f667ee2012f12c47fd270968
-2026-08-31-released-session-format-migrations.zh.md: 23c677b60ba9d080dd1fcfbb0bfb6a9dc124e72f
+2026-08-31-released-session-format-migrations.md: dc651350330ddb9a78b31bbd07a1f52ac758adfa
+2026-08-31-released-session-format-migrations.zh.md: 3bbcca1b0ca6d764cce23b75ee02af68e7d080c6

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

@@ -70,6 +70,8 @@ V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored
 
 V2→V3 owns the [system-prompt structural conversion](2026-09-02-system-prompt-as-surface-node.md), a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. The edge preserves source event order and request meaning while inserting system-head events and remapping local sequence references and the inherited cut. Unknown events are not unconditionally safe to retain when cardinality changes; the edge refuses events it cannot transform safely. 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.
 
 All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.

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

@@ -70,6 +70,8 @@ V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记
 
 V2→V3 负责[系统提示词结构转换](2026-09-02-system-prompt-as-surface-node.zh.md),以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。该迁移边在插入系统头节点事件、重映射本地序号引用与继承截点的同时,保留源事件顺序和请求含义。事件数量变化时,未知事件并非无条件可以安全保留;迁移边拒绝无法安全转换的事件。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
 
+预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。
+
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
 
 所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。

+ 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: ee612ba2d69c332960977b5a18a92589fb561b8d
-README.zh.md: 4e3c623e269cec67459e9e54a03d477d93850f9d
+README.md: 9ae2530aa0da4a33a272568c47497f433d088b31
+README.zh.md: 59077e75b08eb02ef7df1952fb88d09019a00a85

+ 2 - 1
packages/session/session-format-v2-to-v3/README.md

@@ -35,7 +35,7 @@ Use the [catalog](../session-format-catalog/README.md) for restoration. Direct i
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-Session metadata changes only its version to 3. The stage inserts an empty `system/message` immediately after the first `step/start`, then replaces that protected head before each changed `request/header` prompt, including clears. It removes `header.system` from every request header without moving any source event. Metadata-only logs gain no head. V3 encoding and restoration accept empty system heads and reject retired `header.system`.
+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 inserts an empty `system/message` immediately after the first `step/start`, then replaces that protected head before each changed `request/header` prompt, including clears. It removes `header.system` from every request header without moving any source event. Metadata-only logs gain no head. V3 encoding and restoration accept empty system heads and reject retired `header.system`.
 
 The stage maps `tool/code-dispatch-start` and `tool/code-dispatch` to `tool/ptc-dispatch-start` and `tool/ptc-dispatch`. It replaces the exact `tools-code-mode` plugin attribution with `tools-ptc` in user messages, inbox insertions, and title-request messages, without rewriting IDs, tool arguments, or content. Native V3 rejects required predecessor PTC tags, including after recoverable row corruption; ignorable predecessor tags remain opaque and cannot satisfy current PTC relationships. Reserved V3 PTC tags in V2 source input are refused even when ignorable.
 
@@ -84,6 +84,7 @@ 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.
 - **Chronology-preserving inputs** — a surface event before the first step, or a changed prompt outside an open step, is refused with `SessionFormatUnsupportedMigrationError`; moving events or inventing out-of-step system messages would violate reconstruction.
 - **Audited migration vocabulary** — V2 events, including log-only Assistant attempts, and the installed message-feedback additions are classified explicitly. Agent relay attribution and file attachment metadata are preserved without interpreting their identifiers or byte counts as sequence references. Unknown events, even ignorable ones, and unknown message-source or content kinds are refused during migration because sequence dependencies cannot be inferred. Native equal-version reads retain ordinary ignorable-event admission and request-header extensions; retired `header.system` and required predecessor PTC tags are prohibited.

+ 2 - 1
packages/session/session-format-v2-to-v3/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-library"
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-Session 元数据仅将版本改为 3。阶段在首个 `step/start` 后立即插入空 `system/message`,随后在每次提示发生变化的 `request/header` 前替换受保护的头节点,包括清空提示。每个请求头的 `header.system` 均被移除,不移动任何源事件。仅含元数据的日志不添加头节点。V3 编码和恢复接受空系统头节点,并拒绝已退役的 `header.system`。
+头部版本变为 3。`header.agentPreset` 和每条 `agent-preset/selected.data.agentPreset` 中精确匹配的旧预设标识 `code` 变为 `ptc`,包括继承的选择事件。其他预设标识以及缺失的头部预设保持不变;选择事件缺少字符串预设标识时会被拒绝。阶段在首个 `step/start` 后立即插入空 `system/message`,随后在每次提示发生变化的 `request/header` 前替换受保护的头节点,包括清空提示。每个请求头的 `header.system` 均被移除,不移动任何源事件。仅含元数据的日志不添加头节点。V3 编码和恢复接受空系统头节点,并拒绝已退役的 `header.system`。
 
 阶段将 `tool/code-dispatch-start` 和 `tool/code-dispatch` 映射为 `tool/ptc-dispatch-start` 和 `tool/ptc-dispatch`。它在用户消息、收件箱插入消息与标题请求消息中,将精确匹配的 `tools-code-mode` 插件归属替换为 `tools-ptc`,不改写 ID、工具参数或内容。原生 V3 拒绝必需的前代 PTC 标签,包括出现在可恢复行损坏之后的标签;可忽略的前代标签保持不透明,不能满足当前 PTC 关系。V2 源输入中的 V3 保留 PTC 标签即使可忽略也会被拒绝。
 
@@ -84,6 +84,7 @@ Session 元数据仅将版本改为 3。阶段在首个 `step/start` 后立即
 
 <a id="known-limitations-and-deferred-work"></a>
 
+- **历史预设归属** — 已发布 V0/V1/V2 的预设引用中,`code` 表示旧内置预设。这些日志无法区分同名的自定义预设;原生 V3 引用不会被重新解释。本库不迁移 `settings.yaml`。
 - **不发布文件** — 持久化负责不可变后继代的发布;本包绝不覆盖已发布代。
 - **保持时序的输入** — 首个步骤前出现表面事件,或在开放步骤之外更改提示时,以 `SessionFormatUnsupportedMigrationError` 拒绝;移动事件或虚构步骤外的系统消息会破坏重建。
 - **经过审计的迁移词汇** — 显式分类 V2 事件(包括仅日志 Assistant 尝试)与已安装的消息反馈扩展。Agent 中继归属与文件附件元数据保持不变,其标识符与字节计数不被解释为序列引用。迁移拒绝未知事件(即使标为可忽略)及未知消息来源或内容种类,因为无法推断其序列依赖。原生同版本读取保留普通可忽略事件的准入规则及请求头扩展,禁止已退役的 `header.system` 与必需的前代 PTC 标签。

+ 5 - 2
packages/session/session-format-v2-to-v3/src/migration.ts

@@ -8,14 +8,14 @@ import { assertEvent, record, SURFACE_TYPES } from './payload.ts'
 import { remapEvent } from './references.ts'
 import { assertReleasedV3Header } from './validation.ts'
 
-/** Adjacent structural migration; refuses unclassified payloads and prompts that cannot retain source chronology. */
+/** Promote system prompts and rename legacy PTC presets; refuse unclassified payloads and chronology changes. */
 export const sessionFormatV2ToV3 = defineSessionFormatMigration({
   name: '@deepseek-ai/dsh-session-format-v2-to-v3',
   fromVersion: 2,
   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) },
   validateTargetHeader: assertReleasedV3Header,
@@ -139,6 +139,9 @@ class ReleasedV2ToV3Stage implements SessionFormatMigrationStage {
 /** Source admission precedes renaming, so these payloads have exact audited fields. */
 function renamePtcEvent(event: SessionFormatEvent): SessionFormatEvent {
   switch (event.type) {
+    case 'agent-preset/selected':
+      return (event.data as SessionFormatJsonObject)['agentPreset'] === 'code'
+        ? { ...event, data: { ...event.data as SessionFormatJsonObject, agentPreset: 'ptc' } } : event
     case 'tool/code-dispatch-start':
       return { ...event, type: 'tool/ptc-dispatch-start' }
     case 'tool/code-dispatch':

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

@@ -0,0 +1,76 @@
+/** Released preset references change after strict source admission without rewriting opaque ids. */
+
+import { describe, expect, it } from 'vitest'
+import { SessionFormatError, SessionFormatEventCollector, SessionFormatUnsupportedMigrationError } 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 admitted selection metadata (%s)', (agentPreset) => {
+      const event = Object.freeze({
+        type: 'agent-preset/selected', seq: 0, time: -7, ignorable: true,
+        data: Object.freeze({ agentPreset }),
+      })
+      const output = migrate(event)
+      expect(output).toEqual({
+        ...event, data: { ...event.data, agentPreset: agentPreset === 'code' ? 'ptc' : agentPreset },
+      })
+      expect(event.data.agentPreset).toBe(agentPreset)
+    },
+  )
+
+  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(SessionFormatError)
+    },
+  )
+
+  it.each([
+    { extension: 'code', data: { agentPreset: 'code' } },
+    { data: { agentPreset: 'code', extension: { agentPreset: 'code' } } },
+  ])('refuses unaudited selection fields (%j)', (fields) => {
+    expect(() => migrate({ type: 'agent-preset/selected', seq: 0, time: 1, ...fields }))
+      .toThrow(/unexpected field extension/)
+  })
+
+  it('refuses sparse source sequences', () => {
+    expect(() => migrate({ type: 'agent-preset/selected', seq: 19, time: 1, data: { agentPreset: 'code' } }))
+      .toThrow(/source events must be dense/)
+  })
+
+  it('refuses unclassified preset-looking events even when ignorable', () => {
+    const event = Object.freeze({
+      type: 'external/selected', seq: 0, time: 1, ignorable: true,
+      data: Object.freeze({ agentPreset: 'code', code: 'code' }),
+    })
+    expect(() => migrate(event)).toThrow(SessionFormatUnsupportedMigrationError)
+  })
+})

+ 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",