diff --git a/packages/session/session-format-v2-to-v3/README.i18n.yaml b/packages/session/session-format-v2-to-v3/README.i18n.yaml index 38dc5766db..83315f3786 100644 --- a/packages/session/session-format-v2-to-v3/README.i18n.yaml +++ b/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: 63a422a9bab02edae78a8c993b62862ca8fb22e7 -README.zh.md: cf497f9efb17ada37bae8abaa2c9dd350b537cdb +README.md: 159fc9af63fd9461b1fd7ab9c819e99ec45c22b4 +README.zh.md: 4b3f1375c0ced35ee5ee947e53e8721fbeb71dab diff --git a/packages/session/session-format-v2-to-v3/README.md b/packages/session/session-format-v2-to-v3/README.md index 63a422a9ba..159fc9af63 100644 --- a/packages/session/session-format-v2-to-v3/README.md +++ b/packages/session/session-format-v2-to-v3/README.md @@ -47,7 +47,7 @@ Session metadata changes only its version to 3. The stage inserts an empty `syst The [stage](src/migration.ts) emits synchronously, retains coordinate mappings, message identity sets, and current prompt/lifecycle state, and expands compact runs incrementally. It derives the target inherited cut from the last inherited end-seed marker, including when an upstream stage cannot supply a cut before EOF. The [reference mapper](src/references.ts) remaps local envelope provenance/replacement ranges, command source references, compaction ranges/lists, and title message lists. Delivery watermarks, session-reference capture coordinates, workflow counters, embedded model input, and message IDs retain their original meaning. V2 delivery markers claiming V3 acceptance are rejected. -The [validator](src/validation.ts) checks system payloads, open-step ownership, and protected-head operations independently. Native V3 also admits in-history system appends, non-head replacements, and compaction of non-head system nodes. It reuses frozen ordinary relationship validation through a private view, preserving original events and IDs in the result; generated repair-ID suffixes remain historical identities, not current sequence coordinates. The [codec](src/codec.ts) shares frozen V2 physical envelope/provenance encoding. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas. +The [validator](src/validation.ts) checks system payloads, open-step ownership, and protected-head operations independently. Native V3 also admits in-history system appends, non-head replacements, and compaction of non-head system nodes. It reuses frozen ordinary relationship validation through a private view, preserving original events and IDs in the result; generated repair-ID suffixes remain historical identities, not current sequence coordinates. The [codec](src/codec.ts) shares frozen V2 physical envelope/provenance encoding. [Admission tests](tests/admission.spec.ts) cover malformed durable payloads, repair identities, protected-head violations, and compaction reference remapping. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas. diff --git a/packages/session/session-format-v2-to-v3/README.zh.md b/packages/session/session-format-v2-to-v3/README.zh.md index cf497f9efb..4b3f1375c0 100644 --- a/packages/session/session-format-v2-to-v3/README.zh.md +++ b/packages/session/session-format-v2-to-v3/README.zh.md @@ -47,7 +47,7 @@ Session 元数据仅将版本改为 3。阶段在首个 `step/start` 后立即 [阶段](src/migration.ts)同步输出,保留坐标映射、消息身份集合与当前提示、生命周期状态,并增量展开紧凑事件段。它从最后一个继承 end-seed 标记推导目标继承切点,包括上游阶段在 EOF 前无法提供切点的情况。[引用映射器](src/references.ts)重映射本地信封溯源与替换范围、命令源引用、压缩范围与列表,以及标题消息列表。投递水位、会话引用捕获坐标、工作流计数器、嵌入的模型输入与消息 ID 保留原有含义。声称已获 V3 接收的 V2 投递标记会被拒绝。 -[校验器](src/validation.ts)独立检查系统载荷、开放步骤归属与受保护的头节点操作。原生 V3 也接受历史内系统消息追加、非头节点替换,以及非头系统节点的压缩。它通过私有视图复用冻结的普通关系校验,在结果中保留原始事件与 ID;生成的修复 ID 后缀仍是历史身份,而非当前序列坐标。[编解码器](src/codec.ts)共享冻结的 V2 物理信封与溯源编码。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。 +[校验器](src/validation.ts)独立检查系统载荷、开放步骤归属与受保护的头节点操作。原生 V3 也接受历史内系统消息追加、非头节点替换,以及非头系统节点的压缩。它通过私有视图复用冻结的普通关系校验,在结果中保留原始事件与 ID;生成的修复 ID 后缀仍是历史身份,而非当前序列坐标。[编解码器](src/codec.ts)共享冻结的 V2 物理信封与溯源编码。[准入测试](tests/admission.spec.ts)覆盖畸形持久化载荷、修复身份、受保护头节点违规与压缩引用重映射。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。 diff --git a/packages/session/session-format-v2-to-v3/src/codec.ts b/packages/session/session-format-v2-to-v3/src/codec.ts index c6de30d261..a9f8709e21 100644 --- a/packages/session/session-format-v2-to-v3/src/codec.ts +++ b/packages/session/session-format-v2-to-v3/src/codec.ts @@ -27,7 +27,7 @@ export const releasedV3SessionFormatCodec = Object.freeze({ if (event.type === 'system/message' || event.type === 'request/header') assertEvent(event, 3) context.emitEvent(event) }, - emitRun: run => context.emitRun(run), + emitRun: context.emitRun.bind(context), }) }, } diff --git a/packages/session/session-format-v2-to-v3/src/migration.ts b/packages/session/session-format-v2-to-v3/src/migration.ts index c4e065dc15..e0bad890c4 100644 --- a/packages/session/session-format-v2-to-v3/src/migration.ts +++ b/packages/session/session-format-v2-to-v3/src/migration.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { SessionFormatError, SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, sessionFormatCount } from '@deepseek-ai/dsh-session-format' -import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatJsonObject, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format' import { assertReleasedV2Header } from '@deepseek-ai/dsh-session-format-v1-to-v2' import { assertEvent, record, SURFACE_TYPES } from './payload.ts' import { remapEvent } from './references.ts' @@ -85,7 +85,6 @@ class ReleasedV2ToV3Stage implements SessionFormatMigrationStage { if (this.input.sourceInheritedEventCount !== undefined && this.input.sourceInheritedEventCount !== cut) { throw new SessionFormatError('format v2 inherited end-seed marker disagrees with its source cut') } - if (!this.input.sourceHeader.isSeeded && this.targetCut !== 0) throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker') if (this.lastForeignDeliverySeq !== undefined && (this.input.sourceHeader.parentSession === undefined || this.lastForeignDeliverySeq >= cut)) { throw new SessionFormatError('current-generation delivery marker names the wrong Session') @@ -99,10 +98,9 @@ class ReleasedV2ToV3Stage implements SessionFormatMigrationStage { : event.type === 'assistant/message' || event.type === 'tool/result' ? [record(data['message'], 'message')] : event.type === 'agent/inbox/spliced' ? data['inserted'] : event.type === 'session/title-llm-request' ? data['messages'] : [] - if (!Array.isArray(messages)) return - for (const message of messages) { - const id = record(message, 'message')['id'] - if (typeof id !== 'string') continue + // assertEvent validates every owned message before identity observation. + for (const message of messages as readonly SessionFormatJsonObject[]) { + const id = message['id'] as string if (this.generatedIds.has(id)) throw new SessionFormatUnsupportedMigrationError('source message id collides with a generated system message id') this.originalIds.add(id) } diff --git a/packages/session/session-format-v2-to-v3/src/payload.ts b/packages/session/session-format-v2-to-v3/src/payload.ts index 69c292bfd2..9357df4a68 100644 --- a/packages/session/session-format-v2-to-v3/src/payload.ts +++ b/packages/session/session-format-v2-to-v3/src/payload.ts @@ -68,8 +68,9 @@ export function assertEvent(event: SessionFormatEvent, version: 2 | 3): void { assertV3StructuralRow(event) return } - if (disposition === undefined) throw new SessionFormatError('missing event disposition') - keys(data, disposition.required, disposition.optional, event.type + ' data') + // Non-inventory system and feedback events have returned above. + const admitted = disposition as NonNullable + keys(data, admitted.required, admitted.optional, event.type + ' data') // Assistant attempts are introduced by V2; the V0 helper has no case for them. if (event.type !== 'assistant/attempt') assertReleasedPayloadSemantics(event, version) if (event.type === 'assistant/message' || event.type === 'assistant/attempt') { @@ -95,7 +96,7 @@ export function assertEvent(event: SessionFormatEvent, version: 2 | 3): void { } if (event.type === 'agent/inbox/spliced' || event.type === 'session/title-llm-request') { const messages = data[event.type === 'agent/inbox/spliced' ? 'inserted' : 'messages'] - if (Array.isArray(messages)) for (const message of messages) assertSource(record(message, 'message')) + for (const message of messages as readonly SessionFormatJsonObject[]) assertSource(message) } } @@ -127,8 +128,8 @@ function assertSource(message: SessionFormatJsonObject): void { } function assertContentKinds(content: SessionFormatJsonValue | undefined): void { - if (!Array.isArray(content)) throw new SessionFormatError('message content must be an array') - for (const value of content) { + // The frozen payload validator already checks content arrays, including nested tool results. + for (const value of content as readonly SessionFormatJsonValue[]) { const block = record(value, 'message content') switch (block['type']) { case 'text': diff --git a/packages/session/session-format-v2-to-v3/tests/admission.spec.ts b/packages/session/session-format-v2-to-v3/tests/admission.spec.ts new file mode 100644 index 0000000000..0c98c0f28a --- /dev/null +++ b/packages/session/session-format-v2-to-v3/tests/admission.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format' +import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format' +import { assertReleasedV3Header, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts' +import { assertEvent } from '../src/payload.ts' +import { remapEvent } from '../src/references.ts' + +const header = { version: 2, id: 'admission', createdAt: 1, isSeeded: false, delegationDepth: 0 } +const user = { id: 'user', role: 'user', content: [{ type: 'text', text: 'input' }], source: { kind: 'user' } } +const system = { turn: 1, step: 1, message: { ...user, role: 'system', source: { kind: 'plugin', plugin: 'context' } } } +function event(type: string, data: SessionFormatJsonValue, extra: SessionFormatJsonObject = {}): SessionFormatEvent { + return { type, seq: 0, time: 1, data, ...extra } +} +function migrate(input: readonly SessionFormatEvent[], source = header, cut: number | undefined = 0) { + const target = sessionFormatV2ToV3.migrateHeader(source) + const stage = sessionFormatV2ToV3.createStage({ sourceHeader: source, targetHeader: target, sourceInheritedEventCount: cut, sourceKind: 'decoded' }) + const collector = new SessionFormatEventCollector() + for (const [seq, value] of input.entries()) stage.transformEvent({ ...value, seq }, collector) + return { header: target, inheritedEventCount: stage.finish(collector), events: collector.values } +} +const opening = [event('turn/start', { turn: 1 }), event('step/start', { turn: 1, step: 1 })] +function native(input: readonly SessionFormatEvent[]) { + const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: input.map((value, seq) => ({ ...value, seq })) } + return restoreReleasedV3Artifact(artifact, new Set()) +} + +describe('durable V3 admission failures', () => { + it.each([ + event('step/start', null), + event('step/start', { turn: 1 }), + event('step/start', { turn: 1, step: 1 }, { ignorable: false }), + event('user/message', user), + event('session/end-seed', { inherited: false }), + ])('rejects malformed source records before emitting %j', (bad) => { + expect(() => migrate([bad])).toThrow() + }) + + it('retains admitted ignorable markers and does not insert another head on later steps', () => { + const output = migrate([...opening, event('feedback/record', { text: 'audit' }, { ignorable: true }), event('step/end', { turn: 1, step: 1 }), event('step/start', { turn: 1, step: 2 })]) + expect(output.events.filter(value => value.type === 'system/message')).toHaveLength(1) + expect(output.events[3]?.['ignorable']).toBe(true) + expect(() => restoreReleasedV3Artifact(output, new Set())).not.toThrow() + }) + + it('rejects non-dense source stage input rather than generating ambiguous identities', () => { + const stage = sessionFormatV2ToV3.createStage({ sourceHeader: header, targetHeader: { ...header, version: 3 }, sourceInheritedEventCount: 0, sourceKind: 'decoded' }) + expect(() => stage.transformEvent({ ...opening[0]!, seq: 1 }, new SessionFormatEventCollector())).toThrow(/dense/) + }) + + it('checks native metadata versions before applying released header validation', () => { + expect(() => assertReleasedV3Header(header)).toThrow(/format v3 header/) + expect(releasedV3SessionFormatCodec.decodeHeader({ type: 'session', ...header, version: 3 })).toEqual({ ...header, version: 3 }) + }) + + it('retains native source extensions through payload validation without classifying their references', () => { + const extension = event('user/message', { ...user, source: { kind: 'custom-source', localRef: 77 } }, { surfaceOp: 'append' }) + expect(() => assertEvent(extension, 3)).not.toThrow() + expect(() => assertEvent(extension, 2)).toThrow(/unclassified/) + }) + + it.each([0, -1, 1.5])('rejects invalid system step coordinates %s', (step) => { + expect(() => releasedV3SessionFormatCodec.encodeEvent(event('system/message', { ...system, step }, { surfaceOp: 'append' }))).toThrow() + }) + + it('refuses appending system context after a history with no protected head', () => { + expect(() => native([...opening, event('user/message', user, { surfaceOp: 'append' }), event('system/message', system, { surfaceOp: 'append' })])).toThrow(/protected first/) + }) + + it('rejects ordinary replacements that consume the protected head', () => { + expect(() => native([...opening, event('system/message', system, { surfaceOp: 'append' }), event('user/message', user, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] })])).toThrow(/protected/) + }) + + it.each([ + { attachmentId: '', name: 'file', bytes: 1 }, + { attachmentId: 1, name: 'file', bytes: 1 }, + { attachmentId: 'sha256:abc', name: false, bytes: 1 }, + ])('rejects malformed audited file metadata %j', (attachment) => { + expect(() => migrate([...opening, event('user/message', { ...user, content: [{ type: 'file', attachment }] }, { surfaceOp: 'append' })])).toThrow(/file attachment/) + }) +}) + +describe('feedback payload admission', () => { + const item = { messageId: 'assistant', rating: 'negative', version: 'opaque', createdAt: 1, updatedAt: 2, note: 'reason' } + it('preserves negative ratings, notes, and deletions', () => { + const put = event('feedback/message-put', { sessionId: header.id, item }) + const deleted = event('feedback/message-delete', { sessionId: header.id, messageId: item.messageId }) + expect(migrate([put, deleted]).events.map(value => value.data)).toEqual([put.data, deleted.data]) + }) + it.each([ + event('feedback/message-delete', { sessionId: 1, messageId: 'id' }), + event('feedback/message-delete', { sessionId: 'session', messageId: 1 }), + event('feedback/message-put', { sessionId: 'session', item: { ...item, messageId: false } }), + event('feedback/message-put', { sessionId: 'session', item: { ...item, version: 0 } }), + event('feedback/message-put', { sessionId: 'session', item: { ...item, rating: 'neutral' } }), + event('feedback/message-put', { sessionId: 'session', item: { ...item, note: 12 } }), + ])('rejects malformed feedback %j', (bad) => { + expect(() => migrate([bad])).toThrow(/feedback/) + }) +}) + +describe('tool result restoration', () => { + const call = { type: 'tool-call', id: 'call', name: 'tool', arguments: '{}' } + const assistant = event('assistant/message', { turn: 1, step: 1, stream: [], message: { id: 'assistant', role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' }, content: [call] } }, { surfaceOp: 'append' }) + const started = event('tool/call', { turn: 1, step: 1, callId: 'call', name: 'tool', arguments: '{}' }) + const result = { turn: 1, step: 1, message: { id: 'result', role: 'user', source: { kind: 'tool', callId: 'call' }, content: [{ type: 'tool-result', toolCallId: 'call', content: [{ type: 'text', text: 'result' }] }] } } + it.each([undefined, { name: 'ToolError', code: 'TOOL_ERROR' }])('restores started tool results without repair identity projection %j', (error) => { + const output = migrate([...opening, assistant, started, event('tool/result', { ...result, ...(error === undefined ? {} : { error }) }, { surfaceOp: 'append' })]) + expect(restoreReleasedV3Artifact(output, new Set())).toBe(output) + }) + it('rejects untagged repair identities in native restoration instead of projecting arbitrary IDs', () => { + const repair = event('tool/result', { ...result, error: { name: 'ToolNotStartedError', code: 'TOOL_NOT_STARTED' } }, { surfaceOp: 'append' }) + expect(() => native([...opening, assistant, repair])).toThrow(/exact TOOL_NOT_STARTED/) + }) +}) + +describe('coordinate remapping', () => { + it('rejects dangling mappings and malformed reference arrays at the mapper boundary', () => { + const source = { ...event('command/done', { sourceEventSeq: 1 }), seq: 2 } + expect(() => remapEvent(source, 3, [])).toThrow(/earlier/) + expect(() => remapEvent({ ...source, data: { sourceEventSeq: 2 } }, 3, [0, 1, 2])).toThrow(/earlier/) + expect(() => remapEvent({ ...source, sourceEventSeqs: null }, 3, [0, 1])).toThrow(/array/) + }) + + it('leaves command completions without a source unchanged', () => { + const output = migrate([event('command/run', { commandId: 'cmd', name: 'test', source: { kind: 'user' } }), event('command/done', { commandId: 'cmd', kind: 'error', text: 'failed' })]) + expect(restoreReleasedV3Artifact(output, new Set())).toBe(output) + }) + + it('remaps a successful compaction summary and preserves its token accounting', () => { + const range = { start: 2, end: 2 } + const summary = event('compaction/summary', { compactionId: 'compact', summary: [{ type: 'text', text: 'summary' }], shadowedRange: range, shadowedSeqs: [2], shadowedTokenCount: 123, provider: 'mock', model: 'mock' }) + const output = migrate([...opening, event('user/message', user, { surfaceOp: 'append' }), event('compaction/start', { compactionId: 'compact', turn: 1 }), summary, event('compaction/end', { compactionId: 'compact', turn: 1 })]) + expect(restoreReleasedV3Artifact(output, new Set())).toBe(output) + expect(output.events.find(value => value.type === 'compaction/summary')?.data).toEqual({ ...summary.data as SessionFormatJsonObject, shadowedRange: { start: 3, end: 3 }, shadowedSeqs: [3] }) + }) +})