From 9e5f8a1e44d6b60bd31c20cc2e5c03b0ac6b59fd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:53:18 +0800 Subject: [PATCH] test(session-format): cover V3 durable admission branches Exercise malformed source envelopes, feedback metadata, file attachments, native system ownership, protected head replacement, started versus repaired tools, dangling coordinate mappings and successful compaction ranges. The focused package suite now has 71 cases with exact V3 source coverage at 100% statements, branches, functions and lines. Remove unreachable rechecks after owned payload validation instead of adding artificial tests or coverage ignores. Unseeded inherited markers already fail at arrival; message arrays and IDs are validated before observation. Bind the unchanged frozen decoder run callback without an unreachable wrapper. --- .../session-format-v2-to-v3/README.i18n.yaml | 4 +- .../session/session-format-v2-to-v3/README.md | 2 +- .../session-format-v2-to-v3/README.zh.md | 2 +- .../session-format-v2-to-v3/src/codec.ts | 2 +- .../session-format-v2-to-v3/src/migration.ts | 10 +- .../session-format-v2-to-v3/src/payload.ts | 11 +- .../tests/admission.spec.ts | 136 ++++++++++++++++++ 7 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 packages/session/session-format-v2-to-v3/tests/admission.spec.ts 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] }) + }) +})