mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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)覆盖畸形持久化载荷、修复身份、受保护头节点违规与压缩引用重映射。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<typeof disposition>
|
||||
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':
|
||||
|
||||
@@ -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] })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user