diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index b0d7211e2b..ee96b84c2b 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/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-v3-to-v4/README.md -README.md: 703a7be2c57f728f84ba5bb36ab2e16cb1a90e0c -README.zh.md: 709b5ecd476a19e2c8971db7546371d328fb0811 +README.md: fdb6c580f91dc89a73555289c6ddaa911afac414 +README.zh.md: eb25fea52b923915c8e73e2f93e9f57c8afa847f diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 703a7be2c5..fdb6c580f9 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -180,7 +180,7 @@ No delivery payload or event type is rewritten. Higher-version migrations own an V3 physical decoding and header validation run before the stage. The stage checks dense sequences, source cuts, canonical tool-result wrappers, named plugin sources, delivery ownership, and supplied catalog evidence as specified above. It does not run the complete released V3 semantic restorer or copy the V2→V3 event/content allowlist. Complete restoration additionally applies the V4 target rules below; physical parsing, stage conversion, and target restoration are distinct checks. -Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. Unknown required events are invalid V3 input; unknown ignorable names become `plugin:` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied. +Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. The stage rejects unknown required V3 event types before interpreting their payloads or consulting the V4 vocabulary, including names that V4 recognizes. Unknown ignorable names become `plugin:` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied. Only the canonical V3 `tool/result` wrapper has a preserving conversion. A retired `tool-result` block in another interpreted position is refused by target admission rather than retained as an invalid V4 block. Native V4 applies the same tag refusal before recoverable suffix suppression. The check covers only these positions: diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 709b5ecd47..eb25fea52b 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -180,7 +180,7 @@ Stage 只把最终继承截点之后的父目录记录作为候选。每个 inhe Stage 之前先执行 V3 物理解码与 header 校验。Stage 按上述规范检查连续序号、源截点、canonical 工具结果 wrapper、命名的 plugin 来源、delivery 归属及传入的目录证据。它不运行完整的已发布 V3 语义恢复器,也不复制 V2→V3 的事件/内容允许列表。完整恢复还会应用下述 V4 目标规则;物理解析、Stage 转换与目标恢复是不同的检查。 -只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。未知必需事件不是有效的 V3 输入;未知可忽略事件名称变为 `plugin:`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。 +只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。Stage 在解释载荷或查询 V4 词汇之前拒绝 V3 未知必需事件,包括 V4 已认识的名称。未知可忽略事件名称变为 `plugin:`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。 只有 canonical V3 `tool/result` wrapper 具有保留信息的转换。其他被解释位置中的已退役 `tool-result` block 由目标接纳拒绝,不作为无效 V4 block 保留。原生 V4 在 recoverable 后缀抑制前应用同样的标签拒绝。检查只覆盖以下位置: diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index ff5d362b74..b0898381de 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -6,7 +6,7 @@ import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3 import { mapEventMessages, rewriteV3MessageSource } from './sources.ts' import { liftToolResult } from './tool-role.ts' import { migrateV3EventContent } from './content.ts' -import { namespaceV3OpaqueEvent } from './extension-identities.ts' +import { namespaceV3OpaqueEvent, RELEASED_V3_EVENT_TYPES } from './extension-identities.ts' import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts' import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts' @@ -74,6 +74,11 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } const opaque = namespaceV3OpaqueEvent(event) if (opaque !== event) { context.emitEvent(opaque); return } + if (!RELEASED_V3_EVENT_TYPES.has(event.type)) { + throw new SessionFormatUnsupportedMigrationError( + `format v3 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`, + ) + } const rewritten = mapEventMessages(event, (message) => { const source = message['source'] if (!isSessionFormatJsonObject(source)) return message diff --git a/packages/session/session-format-v3-to-v4/tests/content.spec.ts b/packages/session/session-format-v3-to-v4/tests/content.spec.ts index 12c2f30804..ecc3b6348e 100644 --- a/packages/session/session-format-v3-to-v4/tests/content.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/content.spec.ts @@ -191,7 +191,7 @@ describe('unknown V3 event identities', () => { } }) - it('keeps required unknown event identity for the source reader to reject', () => { + it('leaves required unknown event identities for the migration stage to reject', () => { const unknown = event('future', {}) expect(namespaceV3OpaqueEvent(unknown)).toBe(unknown) const known = event('turn/start', { turn: 1 }) diff --git a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts index 0fe648b98f..0c4b64d959 100644 --- a/packages/session/session-format-v3-to-v4/tests/migration.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/migration.spec.ts @@ -40,6 +40,39 @@ function restoreHistorical(events: readonly SessionFormatEvent[], sourceHeader: } describe('V3 to V4 source preservation', () => { + it('rejects required V3 developer events while preserving ignorable events and native V4 admission', () => { + const rows: SessionFormatEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'developer/message', seq: 2, time: 3, surfaceOp: 'append', data: { + turn: 1, step: 1, message: { id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, content: [] }, + } }, + { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const before = structuredClone(rows) + expect(() => migrate(rows)).toThrow('format v3 contains unknown event type "developer/message" at seq 2') + expect(() => restore(rows)).toThrow('format v3 contains unknown event type "developer/message" at seq 2') + expect(() => restoreHistorical(rows, header)).toThrow('unknown event type') + const ignorable = rows.map(row => row.type === 'developer/message' ? { ...row, ignorable: true } : row) + expect(restore(ignorable).events).toEqual(ignorable.map(row => + row.type === 'developer/message' ? { ...row, type: 'plugin:developer/message' } : row)) + const native = sessionFormatCatalog.createRestore({ type: 'session', ...header, version: 4 }, { + recovery: 'strict', validation: 'current', + }) + for (const row of rows) native.decodeRow(row) + expect(native.finish().events).toEqual(rows) + expect(rows).toEqual(before) + }) + + it('rejects generic required V3 extensions before target vocabulary admission', () => { + const required = { ...fact, type: 'external/required' } + expect(() => migrate([required])).toThrow('format v3 contains unknown event type "external/required" at seq 0') + expect(() => restore([required])).toThrow('format v3 contains unknown event type "external/required" at seq 0') + const ignorable = { ...required, ignorable: true } + expect(restore([ignorable]).events).toEqual([{ ...ignorable, type: 'plugin:external/required' }]) + }) + it('changes only the header version and retains event objects, payloads, timestamps, and coordinates', () => { const rows = [fact, delivery(3)] const before = JSON.stringify({ header, rows }) diff --git a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts index 348e9dd692..5ccf2bb41e 100644 --- a/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/migration-refusal.spec.ts @@ -302,6 +302,22 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres await expectOnlyGenerations([path]) }) + it.each(['developer/message', 'external/required'])('refuses invalid required V3 %s before publishing', async (type) => { + const row = { type, surfaceOp: 'append', data: { + turn: 1, step: 1, message: { + id: 'invalid-v3', role: 'developer', source: { kind: 'tool-registry' }, content: [], + }, + } } + const path = await store(3, compression, [...releasedPrefix, row]) + const original = await observe(path) + const ctx = await mount(compression) + await expectRefusal(ctx, access, path, + `format v3 contains unknown event type ${JSON.stringify(type)} at seq ${releasedPrefix.length}` + + '; source v3 artifact remains unchanged (raw log: ' + path + ')') + expect(await observe(path)).toEqual(original) + await expectOnlyGenerations([path]) + }) + it.each([5, 99])('retains a V3 watermark claiming V%s unchanged through native reopening', async (sessionFormatVersion) => { const marker = { type: 'session-log-deepseek/delivery-accepted', data: { sessionId: id, throughSeq: 0, sessionFormatVersion,