diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 47a7869b1c..ea3b86dbb5 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -242,6 +242,7 @@ function galleryFixture(imageUrl: string): string { ...(call.meta === undefined ? {} : { meta: call.meta }), }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) } + session.append('step/end', { turn: 1, step: 1 }) session.append('step/start', { turn: 1, step: 2 }) session.append('assistant/message', { stream: [], diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 4babf591db..c247f68e74 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -201,4 +201,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e13d92cf15..571abfc5de 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -869,19 +869,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise sessionFormatCatalog.encodeCurrentEvent(event)) + const header = sessionFormatCatalog.encodeCurrentHeader({ + ...session.header, + delegationDepth: session.header.delegationDepth ?? 0, + }, session.inheritedEventCount) return [ - JSON.stringify(encoded.header), - ...encoded.rows.map(record => JSON.stringify(record)), + JSON.stringify(header), + ...encodedEvents.map(record => JSON.stringify(record)), '', ].join('\n') } diff --git a/packages/session/session-format-catalog/src/generated.ts b/packages/session/session-format-catalog/src/generated.ts index 888d07d812..be2281dd2b 100644 --- a/packages/session/session-format-catalog/src/generated.ts +++ b/packages/session/session-format-catalog/src/generated.ts @@ -13,13 +13,16 @@ import { assertReleasedV2Header, releasedV2SessionFormatCodec, restoreReleasedV2 export const sessionFormatCatalog = createSessionFormatCatalog({ currentVersion: 2, codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec], - encodeCurrentArtifact: artifact => releasedV2SessionFormatCodec.encodeArtifact(artifact), + currentEncoder: releasedV2SessionFormatCodec, migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2], restoreCurrent(artifact) { const restored = restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES) validateInstalledCurrentSessionArtifact(restored) return restored }, + restoreTransformedCurrent(artifact) { + return restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES) + }, restoreCurrentHeader(header) { assertReleasedV2Header(header) validateInstalledCurrentSessionHeader(header) diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index f990b011cb..7b4544222c 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -27,10 +27,11 @@ describe('first-party Session format catalog', () => { }) const v1Header = { ...header, version: 1 } - const current = sessionFormatCatalog.decodeArtifact(v1Header, [ - { type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }, - ]) - expect(sessionFormatCatalog.migrate(current)).toMatchObject({ + const restore = sessionFormatCatalog.createRestore(v1Header, { + recovery: 'strict', validation: 'current', + }) + restore.decodeRow({ type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } }) + expect(restore.finish()).toMatchObject({ header: { version: 2, id: 'catalog' }, }) }) @@ -39,21 +40,38 @@ describe('first-party Session format catalog', () => { const header = { type: 'session', version: 2, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0, } - const extended = sessionFormatCatalog.decodeArtifact(header, [{ + const restore = (rows: readonly unknown[]) => { + const current = sessionFormatCatalog.createRestore(header, { + recovery: 'strict', validation: 'current', + }) + for (const row of rows) current.decodeRow(row) + return current.finish() + } + const extended = restore([{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true }, }]) - expect(sessionFormatCatalog.migrate(extended).events).toEqual(extended.events) - - const unknownRequired = sessionFormatCatalog.decodeArtifact(header, [{ - type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future', + expect(extended.events).toEqual([{ + type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true }, }]) - expect(() => sessionFormatCatalog.migrate(unknownRequired)).toThrow(/unknown event type/) - const extension = sessionFormatCatalog.decodeArtifact(header, [{ + expect(() => restore([{ + type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future', + }])).toThrow(/unknown event type/) + + const extension = restore([{ type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true, }]) - expect(sessionFormatCatalog.migrate(extension).events).toEqual([{ + expect(extension.events).toEqual([{ type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true, }]) }) + + it('validates complete relationships after streaming migration', () => { + const stream = sessionFormatCatalog.createRestore({ + type: 'session', version: 1, id: 'invalid-stream', createdAt: 1, delegationDepth: 0, + }, { recovery: 'strict', validation: 'current' }) + stream.decodeRow({ type: 'step/start', seq: 0, time: 2, data: { turn: 1, step: 1 } }) + + expect(() => stream.finish()).toThrow(/open turn/) + }) }) diff --git a/packages/session/session-format-v0-to-v1/src/codec.ts b/packages/session/session-format-v0-to-v1/src/codec.ts index 13e09b91c4..ea85401424 100644 --- a/packages/session/session-format-v0-to-v1/src/codec.ts +++ b/packages/session/session-format-v0-to-v1/src/codec.ts @@ -1,32 +1,46 @@ import { SessionFormatError, - isSessionFormatJsonObject, sessionFormatCount, sessionFormatSafeInteger, - snapshotSessionFormatArtifact, snapshotSessionFormatJson, } from '@deepseek-ai/dsh-session-format' import type { - EncodedSessionFormatArtifact, - SessionFormatArtifact, + SessionFormatArtifactDecoder, SessionFormatCodec, - SessionFormatEncodeOptions, SessionFormatEvent, + SessionFormatEventRun, SessionFormatHeader, SessionFormatJsonObject, SessionFormatJsonValue, + SessionFormatMigrationContext, + SessionFormatRecovery, } from '@deepseek-ai/dsh-session-format' -import { - assertReleasedSessionFormatHeader, - assertReleasedV0SourceArtifact, - assertReleasedV1PhysicalArtifact, -} from './validation.ts' +import { assertReleasedSessionFormatHeader } from './validation.ts' import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts' const PHYSICAL_HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'delegationDepth'] as const const PHYSICAL_HEADER_OPTIONAL = ['cwd', 'parentSession', 'seedLength', 'origin', 'agentPreset'] as const const PACKED_TAGS = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']) +/** A released packed Assistant row retained until v1-to-v2 embeds its compact stream. */ +export interface ReleasedAssistantChunkRun extends SessionFormatEventRun { + readonly runType: 'released-assistant-chunks' + readonly turn: number + readonly step: number + readonly lastSeq: number + readonly lastTime: number + readonly stream: SessionFormatJsonObject +} + +/** + * Test whether a compact migration item is a released packed Assistant row. + * @param run - compact migration item to classify. + * @returns whether the item carries the released Assistant chunk representation. + */ +export function isReleasedAssistantChunkRun(run: SessionFormatEventRun): run is ReleasedAssistantChunkRun { + return run.runType === 'released-assistant-chunks' +} + /** Frozen physical JSON codec for the released v0 layout. */ export const releasedV0SessionFormatCodec = createReleasedCodec(0) @@ -37,40 +51,88 @@ function createReleasedCodec(version: 0 | 1) { return Object.freeze({ version, decodeHeader: (value: unknown) => decodeHeader(value, version), - decodeArtifact(headerValue: unknown, rowValues: readonly unknown[]) { + createDecoder(headerValue: unknown, recovery: SessionFormatRecovery) { const physical = decodePhysicalHeader(headerValue, version) - const artifact = snapshotSessionFormatArtifact({ + const scanner = scanRows(recovery === 'recoverable') + return { header: physical.header, - inheritedEventCount: physical.inheritedEventCount, - events: scanRows(rowValues, false).events, - }, `released v${version} artifact`) - if (version === 0) assertReleasedV0SourceArtifact(artifact) - else assertReleasedV1PhysicalArtifact(artifact) - return artifact + headerInheritedEventCount: physical.inheritedEventCount, + decodeRow: (rowValue, context) => { scanner.decodeRow(rowValue, context) }, + finish(_context) { + scanner.finish(physical.inheritedEventCount) + return physical.inheritedEventCount + }, + } satisfies SessionFormatArtifactDecoder }, - decodeRecoverableArtifact(headerValue: unknown, rowValues: readonly unknown[]) { - const physical = decodePhysicalHeader(headerValue, version) - const recovered = scanRows(rowValues, true) - const artifact = snapshotSessionFormatArtifact({ - header: physical.header, - inheritedEventCount: physical.inheritedEventCount, - events: recovered.events, - }, `released v${version} recoverable artifact`) - if (version === 0) assertReleasedV0SourceArtifact(artifact) - else assertReleasedV1PhysicalArtifact(artifact) - return artifact + } satisfies SessionFormatCodec) +} + +function scanRows( + recoverable: boolean, +): { + decodeRow( + rowValue: unknown, + context: SessionFormatMigrationContext, + ): void + finish(inheritedEventCount: number): void +} { + let rowIndex = 0 + let eventCount = 0 + let issue: SessionFormatError | undefined + return { + decodeRow(rowValue, context) { + const currentRow = rowIndex + rowIndex += 1 + let packed = false + let decoded: SessionFormatEvent | ReleasedAssistantChunkRun + try { + const record = releasedV0Record(rowValue, `released Session row ${currentRow}`) + const type = record['type'] + if (typeof type === 'string' && PACKED_TAGS.has(type)) { + packed = true + decoded = decodePackedRun(record, type, currentRow) + } else { + decoded = decodeEvent(record, currentRow) + } + } catch (error: unknown) { + const current = error instanceof SessionFormatError + ? error + : new SessionFormatError(`released Session row ${currentRow} is malformed`, { cause: error }) + if (!recoverable) throw current + issue ??= current + return + } + if (issue !== undefined) { + if (!packed && (decoded as SessionFormatEvent).type === 'turn/end') throw issue + return + } + const seq = packed + ? (decoded as ReleasedAssistantChunkRun).firstSeq + : (decoded as SessionFormatEvent).seq + if (seq !== eventCount) { + const gap = new SessionFormatError( + `released Session row ${currentRow} has seq gap (expected ${eventCount}, got ${seq})`, + ) + if (!recoverable) throw gap + issue = gap + if (!packed && (decoded as SessionFormatEvent).type === 'turn/end') throw gap + return + } + if (packed) { + const run = decoded as ReleasedAssistantChunkRun + eventCount += run.eventCount + context.emitRun(run) + } else { + eventCount += 1 + context.emitEvent(decoded as SessionFormatEvent) + } }, - encodeArtifact(artifact: SessionFormatArtifact, options: SessionFormatEncodeOptions) { - if (version === 0) assertReleasedV0SourceArtifact(artifact) - else assertReleasedV1PhysicalArtifact(artifact) - return encodeArtifact(artifact, options, version) + finish(inheritedEventCount) { + if (inheritedEventCount > eventCount) { + throw new SessionFormatError('Session inheritedEventCount exceeds its event count') + } }, - } satisfies SessionFormatCodec & { - encodeArtifact( - artifact: SessionFormatArtifact, - options: SessionFormatEncodeOptions, - ): EncodedSessionFormatArtifact - }) + } } function decodeHeader(value: unknown, version: 0 | 1): SessionFormatHeader { @@ -109,7 +171,7 @@ function decodePhysicalHeader( if (record['origin'] !== undefined && record['origin'] !== 'subagent') { throw new SessionFormatError(`released v${version} header origin must be "subagent"`) } - const header = snapshotSessionFormatJson({ + const header = { version, id: record['id'], createdAt, @@ -119,101 +181,34 @@ function decodePhysicalHeader( ...(record['origin'] === undefined ? {} : { origin: record['origin'] }), delegationDepth, ...(record['agentPreset'] === undefined ? {} : { agentPreset: record['agentPreset'] }), - }, `released v${version} logical header`) as SessionFormatHeader + } as SessionFormatHeader assertReleasedSessionFormatHeader(header, version) return { header, inheritedEventCount: seedLength } } -function encodeArtifact( - artifact: SessionFormatArtifact, - options: SessionFormatEncodeOptions, - version: 0 | 1, -): EncodedSessionFormatArtifact { - const header = artifact.header - const physicalHeader = snapshotSessionFormatJson({ - type: 'session', - version, - id: header.id, - createdAt: header.createdAt, - ...(header.cwd === undefined ? {} : { cwd: header.cwd }), - ...(header.parentSession === undefined ? {} : { parentSession: header.parentSession }), - ...(header.isSeeded ? { seedLength: artifact.inheritedEventCount } : {}), - ...(header.origin === undefined ? {} : { origin: header.origin }), - delegationDepth: header.delegationDepth, - ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }), - }, `released v${version} encoded header`) as SessionFormatJsonObject - const records = options.packChunks ? packChunkRuns(artifact.events) : [...artifact.events] - const rows = Object.freeze(records.map(record => encodeProvenance(record))) - return Object.freeze({ header: physicalHeader, rows }) -} - -function scanRows( - rowValues: readonly unknown[], - recoverable: boolean, -): { readonly events: readonly SessionFormatEvent[] } { - const events: SessionFormatEvent[] = [] - let issue: SessionFormatError | undefined - for (const [rowIndex, value] of rowValues.entries()) { - let decoded: readonly SessionFormatEvent[] - try { - const row = snapshotSessionFormatJson(value, `released Session row ${rowIndex}`) - decoded = decodeRow(row, rowIndex) - } catch (error: unknown) { - const current = error instanceof SessionFormatError - ? error - : new SessionFormatError(`released Session row ${rowIndex} is malformed`, { cause: error }) - if (!recoverable) throw current - issue ??= current - continue - } - if (issue !== undefined) { - if (decoded.some(event => event.type === 'turn/end')) throw issue - continue - } - const rowStart = events.length - for (const event of decoded) { - if (event.seq !== events.length) { - const gap = new SessionFormatError( - `released Session row ${rowIndex} has seq gap (expected ${events.length}, got ${event.seq})`, - ) - events.length = rowStart - if (!recoverable) throw gap - issue = gap - break - } - events.push(event) - } - if (issue !== undefined) { - if (decoded.some(event => event.type === 'turn/end')) throw issue - continue - } - } - return Object.freeze({ events: Object.freeze(events) }) -} - -function decodeRow(value: SessionFormatJsonValue, rowIndex: number): readonly SessionFormatEvent[] { - const record = releasedV0Record(value, `released Session row ${rowIndex}`) - const type = record['type'] - if (typeof type === 'string' && PACKED_TAGS.has(type)) return expandPackedRow(record, type, rowIndex) +function decodeEvent( + record: Record, + rowIndex: number, +): SessionFormatEvent { if (record['sourceEventSeqs'] !== undefined) { const seq = sessionFormatCount(record['seq'], `released Session row ${rowIndex} seq`) - return Object.freeze([{ + return { ...record, sourceEventSeqs: decodeSeqRanges(record['sourceEventSeqs'], seq), - } as unknown as SessionFormatEvent]) + } as unknown as SessionFormatEvent } - return Object.freeze([record as unknown as SessionFormatEvent]) + return record as unknown as SessionFormatEvent } -function expandPackedRow( +function decodePackedRun( row: Record, type: string, rowIndex: number, -): readonly SessionFormatEvent[] { +): ReleasedAssistantChunkRun { const label = `released ${type} row ${rowIndex}` assertReleasedV0Keys(row, ['type', 'seq0', 'time0', 'data'], [], label) const seq0 = sessionFormatCount(row['seq0'], `${label} seq0`) - let time = sessionFormatSafeInteger(row['time0'], `${label} time0`) + const time0 = sessionFormatSafeInteger(row['time0'], `${label} time0`) const data = releasedV0Record(row['data'], `${label} data`) const isTool = type === 'tool-call-chunks' assertReleasedV0Keys( @@ -230,37 +225,73 @@ function expandPackedRow( if (!Array.isArray(gaps) || gaps.length !== payload.length - 1) { throw new SessionFormatError(`${label} dt length must match its payload`) } - for (const gap of gaps) sessionFormatSafeInteger(gap, `${label} dt member`) - if (typeof data['turn'] !== 'number' || typeof data['step'] !== 'number' || typeof data['index'] !== 'number') { - throw new SessionFormatError(`${label} turn, step, and index must be numbers`) + let lastTime = time0 + for (const gap of gaps) { + const validGap = sessionFormatSafeInteger(gap, `${label} dt member`) + lastTime = sessionFormatSafeInteger(lastTime + validGap, `${label} member time`) } + const turn = sessionFormatCount(data['turn'], `${label} turn`) + const step = sessionFormatCount(data['step'], `${label} step`) + const chunkIndex = sessionFormatCount(data['index'], `${label} index`) if (isTool && (typeof data['id'] !== 'string' + || data['id'].length === 0 || (data['name'] !== undefined && typeof data['name'] !== 'string'))) { throw new SessionFormatError(`${label} id and optional name must be strings`) } - const output: SessionFormatEvent[] = [] - for (let index = 0; index < payload.length; index += 1) { - if (index > 0) time = sessionFormatSafeInteger(time + (gaps[index - 1] as number), `${label} member time`) - const member = payload[index] as string - const chunk = type === 'text-chunks' - ? { type: 'text-delta', index: data['index'], text: member } - : type === 'reasoning-chunks' - ? { type: 'reasoning-delta', index: data['index'], text: member } + const lastSeq = sessionFormatCount(seq0 + payload.length - 1, `${label} final seq`) + const stream = (type === 'tool-call-chunks' + ? { + type, + time0, + index: chunkIndex, + dt: gaps, + id: data['id'], + ...(data['name'] === undefined ? {} : { name: data['name'] }), + args: payload, + } + : { type, time0, index: chunkIndex, dt: gaps, texts: payload }) as SessionFormatJsonObject + const run: ReleasedAssistantChunkRun = { + runType: 'released-assistant-chunks', + firstSeq: seq0, + eventCount: payload.length, + turn, + step, + lastSeq, + lastTime, + stream, + expand: () => expandAssistantChunkRun(run), + } + return run +} + +function* expandAssistantChunkRun(run: ReleasedAssistantChunkRun): Iterable { + const stream = run.stream + const gaps = stream['dt'] as readonly number[] + const members = stream['type'] === 'tool-call-chunks' + ? stream['args'] as readonly string[] + : stream['texts'] as readonly string[] + let time = run.stream['time0'] as number + for (let index = 0; index < members.length; index += 1) { + if (index > 0) time += gaps[index - 1] as number + const member = members[index] as string + const chunk = stream['type'] === 'text-chunks' + ? { type: 'text-delta', index: stream['index'], text: member } + : stream['type'] === 'reasoning-chunks' + ? { type: 'reasoning-delta', index: stream['index'], text: member } : { type: 'tool-call-delta', - index: data['index'], - id: data['id'], - ...(data['name'] === undefined ? {} : { name: data['name'] }), + index: stream['index'], + id: stream['id'], + ...(stream['name'] === undefined ? {} : { name: stream['name'] }), argumentsDelta: member, } - output.push(snapshotSessionFormatJson({ + yield { type: 'assistant/chunk', - seq: sessionFormatCount(seq0 + index, `${label} member seq`), + seq: run.firstSeq + index, time, - data: { turn: data['turn'], step: data['step'], chunk }, - }, `${label} member`) as SessionFormatEvent) + data: { turn: run.turn, step: run.step, chunk }, + } as SessionFormatEvent } - return Object.freeze(output) } function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly SessionFormatJsonValue[] { @@ -287,130 +318,5 @@ function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): rea if (hasRange && output.some((member, index) => index > 0 && member <= (output[index - 1] as number))) { throw new SessionFormatError('sourceEventSeqs ranges must be strictly increasing') } - return Object.freeze(output) -} - -function encodeProvenance(record: SessionFormatEvent | SessionFormatJsonObject): SessionFormatJsonObject { - if (!Object.hasOwn(record, 'sourceEventSeqs')) return record - const sourceEventSeqs = record['sourceEventSeqs'] as readonly SessionFormatJsonValue[] - const values = sourceEventSeqs.map(value => sessionFormatCount(value, 'sourceEventSeqs member')) - return snapshotSessionFormatJson({ ...record, sourceEventSeqs: encodeSeqRanges(values) }) as SessionFormatJsonObject -} - -function encodeSeqRanges(values: readonly number[]): readonly SessionFormatJsonValue[] { - if (values.some((value, index) => index > 0 && value <= (values[index - 1] as number))) return Object.freeze([...values]) - const output: SessionFormatJsonValue[] = [] - for (let start = 0; start < values.length;) { - let end = start - while (end + 1 < values.length && values[end + 1] === (values[end] as number) + 1) end += 1 - if (end - start >= 2) output.push(Object.freeze([values[start] as number, values[end] as number])) - else for (let index = start; index <= end; index += 1) output.push(values[index] as number) - start = end + 1 - } - return Object.freeze(output) -} - -type ChunkKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta' - -function packChunkRuns(events: readonly SessionFormatEvent[]): readonly (SessionFormatEvent | SessionFormatJsonObject)[] { - const output: Array = [] - let kind: ChunkKind | undefined - let run: SessionFormatEvent[] = [] - const flush = (): void => { - if (kind !== undefined && run.length >= 3) output.push(buildPackedRow(kind, run)) - else output.push(...run) - kind = undefined - run = [] - } - for (const event of events) { - const candidate = classifyChunk(event) - const previous = run.at(-1) - if (candidate !== undefined && candidate === kind && previous !== undefined && continuesChunk(previous, event, candidate)) { - run.push(event) - continue - } - flush() - if (candidate === undefined) output.push(event) - else { - kind = candidate - run = [event] - } - } - flush() - return Object.freeze(output) -} - -function classifyChunk(event: SessionFormatEvent): ChunkKind | undefined { - if (event.type !== 'assistant/chunk' || !hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined - const data = event.data - if (!isSessionFormatJsonObject(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined - const chunk = data['chunk'] - if (!isSessionFormatJsonObject(chunk) - || typeof chunk['index'] !== 'number' - || typeof chunk['type'] !== 'string') return undefined - if (chunk['type'] === 'text-delta' || chunk['type'] === 'reasoning-delta') { - return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk['text'] === 'string' - ? chunk['type'] - : undefined - } - if (chunk['type'] !== 'tool-call-delta') return undefined - const exact = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta']) - || hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) - return exact && typeof chunk['id'] === 'string' - && typeof chunk['argumentsDelta'] === 'string' - && (chunk['name'] === undefined || typeof chunk['name'] === 'string') - ? 'tool-call-delta' - : undefined -} - -function continuesChunk(previous: SessionFormatEvent, next: SessionFormatEvent, kind: ChunkKind): boolean { - const previousData = previous.data as SessionFormatJsonObject - const nextData = next.data as SessionFormatJsonObject - const previousChunk = previousData['chunk'] as SessionFormatJsonObject - const nextChunk = nextData['chunk'] as SessionFormatJsonObject - if (!Number.isSafeInteger(next.time - previous.time)) return false - if (nextData['turn'] !== previousData['turn'] || nextData['step'] !== previousData['step']) return false - if (nextChunk['index'] !== previousChunk['index']) return false - if (kind !== 'tool-call-delta') return true - return nextChunk['id'] === previousChunk['id'] - && Object.hasOwn(nextChunk, 'name') === Object.hasOwn(previousChunk, 'name') - && nextChunk['name'] === previousChunk['name'] -} - -function buildPackedRow(kind: ChunkKind, run: readonly SessionFormatEvent[]): SessionFormatJsonObject { - const first = run[0] as SessionFormatEvent - const firstData = first.data as SessionFormatJsonObject - const firstChunk = firstData['chunk'] as SessionFormatJsonObject - const base = { - turn: firstData['turn'], - step: firstData['step'], - index: firstChunk['index'], - dt: run.slice(1).map((event, index) => event.time - (run[index] as SessionFormatEvent).time), - } - if (kind === 'tool-call-delta') { - return snapshotSessionFormatJson({ - type: 'tool-call-chunks', - seq0: first.seq, - time0: first.time, - data: { - ...base, - id: firstChunk['id'], - ...(firstChunk['name'] === undefined ? {} : { name: firstChunk['name'] }), - args: run.map(event => ((event.data as SessionFormatJsonObject)['chunk'] as SessionFormatJsonObject)['argumentsDelta']), - }, - }) as SessionFormatJsonObject - } - return snapshotSessionFormatJson({ - type: kind === 'text-delta' ? 'text-chunks' : 'reasoning-chunks', - seq0: first.seq, - time0: first.time, - data: { - ...base, - texts: run.map(event => ((event.data as SessionFormatJsonObject)['chunk'] as SessionFormatJsonObject)['text']), - }, - }) as SessionFormatJsonObject -} - -function hasExactKeys(record: Readonly>, keys: readonly string[]): boolean { - return Object.keys(record).length === keys.length && keys.every(key => Object.hasOwn(record, key)) + return output } diff --git a/packages/session/session-format-v0-to-v1/src/index.ts b/packages/session/session-format-v0-to-v1/src/index.ts index 5aa631ee82..4409bb31ad 100644 --- a/packages/session/session-format-v0-to-v1/src/index.ts +++ b/packages/session/session-format-v0-to-v1/src/index.ts @@ -7,7 +7,7 @@ export { assertReleasedPayloadSemantics } from './payload-validation.ts' export { assertReleasedArtifactRelationships } from './relationships.ts' export { assertReleasedSurfaceMetadata, - assertReleasedV1Artifact, + assertReleasedEventPayload, assertReleasedV1Header, restoreReleasedV1Artifact, } from './validation.ts' diff --git a/packages/session/session-format-v0-to-v1/src/migration.ts b/packages/session/session-format-v0-to-v1/src/migration.ts index 21d171f0fc..ca72d582a9 100644 --- a/packages/session/session-format-v0-to-v1/src/migration.ts +++ b/packages/session/session-format-v0-to-v1/src/migration.ts @@ -3,19 +3,20 @@ import { SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, sessionFormatCount, - snapshotSessionFormatArtifact, } from '@deepseek-ai/dsh-session-format' import type { SessionFormatEvent, + SessionFormatEventRun, SessionFormatHeader, SessionFormatJsonObject, SessionFormatJsonValue, + SessionFormatMigrationContext, + SessionFormatMigrationStage, + SessionFormatMigrationStageInput, } from '@deepseek-ai/dsh-session-format' +import { isReleasedAssistantChunkRun } from './codec.ts' import { assertReleasedEventPayload, - assertNormalizedReleasedV0Artifact, - assertReleasedV0SourceArtifact, - assertReleasedV1Artifact, assertReleasedV1Header, } from './validation.ts' import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts' @@ -29,45 +30,179 @@ export const sessionFormatV0ToV1 = defineSessionFormatMigration({ assertHeaderVersion(header, 0) return { ...header, version: 1 } }, - migrate(source) { - assertReleasedV0SourceArtifact(source) - const events = normalizeReleasedV0Events(source.events, source.header.id) - assertNormalizedReleasedV0Artifact({ ...source, events }) - const target = snapshotSessionFormatArtifact({ - header: { ...source.header, version: 1 }, - inheritedEventCount: source.inheritedEventCount, - events, - }, 'released v0-to-v1 target') - assertReleasedV1Artifact(target) - return target + createStage(input) { + return new ReleasedV0ToV1Stage(input) }, - validateTarget: assertReleasedV1Artifact, validateTargetHeader: assertReleasedV1Header, }) +class ReleasedV0ToV1Stage implements SessionFormatMigrationStage { + readonly headerInheritedEventCount: number + private readonly state: LegacyNormalizationState = { messageIds: new Map(), retryIds: new Map() } + + constructor(private readonly input: SessionFormatMigrationStageInput) { + assertHeaderVersion(input.sourceHeader, 0) + this.headerInheritedEventCount = input.sourceInheritedEventCount + } + + transformEvent( + event: SessionFormatEvent, + context: SessionFormatMigrationContext, + ): void { + const normalized = normalizeReleasedV0Event(event, this.input.sourceHeader.id, this.state) + assertSourceDeliveryMarker(normalized, this.input) + context.emitEvent(normalized) + } + + transformRun( + run: SessionFormatEventRun, + context: SessionFormatMigrationContext, + ): void { + if (isReleasedAssistantChunkRun(run)) { + context.emitRun(run) + return + } + for (const event of run.expand()) this.transformEvent(event, context) + } + + finish(_context: SessionFormatMigrationContext): number { + return this.input.sourceInheritedEventCount + } +} + function assertHeaderVersion(header: SessionFormatHeader, version: 0 | 1): void { if (header.version !== version) throw new SessionFormatError(`expected format v${version} header`) } -function normalizeReleasedV0Events( - events: readonly SessionFormatEvent[], +interface LegacyNormalizationState { + readonly messageIds: Map + readonly retryIds: Map + compactionId?: string +} + +function normalizeReleasedV0Event( + event: SessionFormatEvent, sessionId: string, -): readonly SessionFormatEvent[] { - const messageIds = new Map() - const output: SessionFormatEvent[] = [] - for (const event of events) { - assertSupportedLegacyType(event, sessionId) - const start = normalizeLegacyTurnStart(event, sessionId) - const end = normalizeLegacyTurnEnd(start, sessionId) - const header = normalizeLegacyRequestHeader(end, sessionId) - const steering = normalizeLegacySteering(header, sessionId) - const message = normalizeLegacyMessage(steering, sessionId, messageIds) - assertReleasedEventPayload(message, 0) - output.push(message) - const messageId = eventMessageId(message) - if (messageId !== undefined) messageIds.set(message.seq, messageId) + state: LegacyNormalizationState, +): SessionFormatEvent { + const named = normalizeLegacyCompactionType(event) + assertSupportedLegacyType(named, sessionId) + const start = normalizeLegacyTurnStart(named, sessionId) + const end = normalizeLegacyTurnEnd(start, sessionId) + const header = normalizeLegacyRequestHeader(end, sessionId) + const steering = normalizeLegacySteering(header, sessionId) + const retry = normalizeLegacyRetry(steering, sessionId, state.retryIds) + const compaction = normalizeLegacyCompaction(retry, sessionId, state) + const message = normalizeLegacyMessage(compaction, sessionId, state.messageIds) + if (message.type !== 'assistant/chunk') assertReleasedEventPayload(message, 0) + const messageId = eventMessageId(message) + if (messageId !== undefined) state.messageIds.set(message.seq, messageId) + return message +} + +function normalizeLegacyCompactionType(event: SessionFormatEvent): SessionFormatEvent { + const type: string = event.type + switch (type) { + case 'compact/start': + return { ...event, type: 'compaction/start' } + case 'compact/summary': + return { ...event, type: 'compaction/summary' } + case 'compact/end': + return { ...event, type: 'compaction/end' } + case 'compact/prune': + return { ...event, type: 'compaction/prune' } + default: + return event } - return Object.freeze(output) +} + +function assertSourceDeliveryMarker( + event: SessionFormatEvent, + input: SessionFormatMigrationStageInput, +): void { + if (event.type !== 'session-log-deepseek/delivery-accepted') return + const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`) + const acceptedVersion = data['sessionFormatVersion'] ?? 0 + const inherited = input.sourceHeader.parentSession !== undefined + && event.seq < input.sourceInheritedEventCount + if (acceptedVersion === 0 && !inherited && data['sessionId'] !== input.sourceHeader.id) { + throw new SessionFormatError('current-generation delivery marker names the wrong Session') + } +} + +function normalizeLegacyRetry( + event: SessionFormatEvent, + sessionId: string, + retryIds: Map, +): SessionFormatEvent { + if (event.type !== 'llm/retry') return event + const data = releasedV0Record(event.data, `llm/retry ${event.seq} data`) + const chain = [data['turn'], data['step'], data['provider'], data['policyKey']] + .map(value => JSON.stringify(value)) + .join('\0') + const retryId = data['retryId'] + if (typeof retryId === 'string' && retryId.length > 0) { + retryIds.set(chain, retryId) + return event + } + if (Object.hasOwn(data, 'retryId')) return event + const migratedRetryId = retryIds.get(chain) ?? `legacy-retry:${sessionId}:${event.seq}` + retryIds.set(chain, migratedRetryId) + return { ...event, data: { ...data, retryId: migratedRetryId } } +} + +function normalizeLegacyCompaction( + event: SessionFormatEvent, + sessionId: string, + state: LegacyNormalizationState, +): SessionFormatEvent { + if (event.type === 'session/end-seed') { + delete state.compactionId + return event + } + if (event.type === 'compaction/start') { + const data = releasedV0Record(event.data, `compaction/start ${event.seq} data`) + const existing = data['compactionId'] + if (typeof existing === 'string' && existing.length > 0) { + state.compactionId = existing + return event + } + if (Object.hasOwn(data, 'compactionId')) return event + const id = `legacy-compaction:${sessionId}:${event.seq}` + state.compactionId = id + return { ...event, data: { ...data, compactionId: id } } + } + const compactionId = state.compactionId + if (compactionId === undefined) return event + if (event.type === 'compaction/summary' || event.type === 'compaction/end') { + const normalized = addLegacyCompactionId(event, compactionId) + if (event.type === 'compaction/end') delete state.compactionId + return normalized + } + if (event.type !== 'user/message') return event + const data = releasedV0Record(event.data, `user/message ${event.seq} data`) + const source = data['source'] + if (!releasedIsRecord(source) || source['kind'] !== 'plugin' || source['plugin'] !== 'compact' + || Object.hasOwn(source, 'compactionId')) return event + return { + ...event, + data: { + ...data, + source: { + ...source, + compactionId, + }, + }, + } +} + +function addLegacyCompactionId( + event: SessionFormatEvent, + compactionId: string, +): SessionFormatEvent { + const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`) + if (Object.hasOwn(data, 'compactionId')) return event + return { ...event, data: { ...data, compactionId } } } function normalizeLegacyRequestHeader(event: SessionFormatEvent, sessionId: string): SessionFormatEvent { diff --git a/packages/session/session-format-v0-to-v1/src/payload-validation.ts b/packages/session/session-format-v0-to-v1/src/payload-validation.ts index 0a69ca4d1f..5b19f7cc66 100644 --- a/packages/session/session-format-v0-to-v1/src/payload-validation.ts +++ b/packages/session/session-format-v0-to-v1/src/payload-validation.ts @@ -1,4 +1,5 @@ import { SessionFormatError, sessionFormatCount, sessionFormatSafeInteger } from '@deepseek-ai/dsh-session-format' +import { deepEqualJson } from '@deepseek-ai/dsh-util-values' import type { SessionFormatEvent, SessionFormatJsonValue, @@ -489,7 +490,12 @@ function messageValue( if (role === undefined) literalValue(message['role'], ['system', 'user', 'assistant'], `${label} role`) else literalValue(message['role'], [role], `${label} role`) contentBlocksValue(message['content'], `${label} content`, version) - messageSourceValue(message['source'], `${label} source`, version, expected) + const source = releasedV0Record(message['source'], `${label} source`) + if (version < 2 && expected === 'user' && source['kind'] === 'goal' && source['change'] !== undefined) { + legacyGoalMessageValue(message, source, label) + } else { + messageSourceValue(source, `${label} source`, version, expected) + } if (expected === 'tool') { const content = message['content'] const block = Array.isArray(content) && content.length === 1 @@ -502,6 +508,34 @@ function messageValue( } } +function legacyGoalMessageValue(message: JsonRecord, source: JsonRecord, label: string): void { + assertReleasedV0Keys(source, ['kind', 'goalId', 'revision', 'round', 'change'], [], `${label} source`) + nonEmptyString(source['goalId'], `${label} source goalId`) + positiveIntegerValue(source['revision'], `${label} source revision`) + if (source['round'] !== 0) throw new SessionFormatError(`${label} legacy goal source round must be 0`) + const change = releasedV0Record(source['change'], `${label} source change`) + goalChangeValue(change, `${label} source change`) + const ref = releasedV0Record( + change['operation'] === 'clear' ? change['cleared'] : change['goal'], + `${label} source change ref`, + ) + if (source['goalId'] !== ref['id'] || source['revision'] !== ref['revision']) { + throw new SessionFormatError(`${label} legacy goal source does not match its change`) + } + const payload = change['operation'] === 'clear' + ? { cleared: change['cleared'], clearedAt: change['clearedAt'] } + : { + goal: change['goal'], + roundsStarted: change['roundsStarted'], + createdAt: change['createdAt'], + updatedAt: change['updatedAt'], + } + const expected = [{ type: 'text', text: `${JSON.stringify(payload)}` }] + if (!deepEqualJson(message['content'], expected)) { + throw new SessionFormatError(`${label} legacy goal content does not match its change`) + } +} + function messageSourceValue( value: SessionFormatJsonValue | undefined, label: string, diff --git a/packages/session/session-format-v0-to-v1/src/relationships.ts b/packages/session/session-format-v0-to-v1/src/relationships.ts index bc32ed6ac3..2cd4a432ed 100644 --- a/packages/session/session-format-v0-to-v1/src/relationships.ts +++ b/packages/session/session-format-v0-to-v1/src/relationships.ts @@ -34,6 +34,8 @@ export interface ReleasedRelationshipExtensions { readonly stepEvents?: ReadonlySet /** Title-request model input was source-validated and preserved across sequence remapping. */ readonly preservedSourceTitleRequestText?: true + /** Admit the released resume pattern whose next-turn inbox insert omitted the prior turn/end. */ + readonly legacyInterruptedTurnRestart?: true } /** @@ -75,7 +77,22 @@ export function assertReleasedArtifactRelationships( } switch (event.type) { - case 'turn/start': + case 'turn/start': { + const previous = artifact.events[event.seq - 1] + if (extensions.legacyInterruptedTurnRestart === true + && openTurn !== null + && openStep === null + && data['turn'] === openTurn + 1 + && nextTurn === openTurn + && previous?.type === 'agent/inbox/spliced') { + const splice = releasedV0Record(previous.data, `agent/inbox/spliced ${previous.seq} data`) + if (splice['target'] === 'next-turn' + && Array.isArray(splice['inserted']) + && splice['inserted'].length > 0) { + openTurn = null + nextTurn += 1 + } + } if (openTurn !== null || data['turn'] !== nextTurn) { throw new SessionFormatError(`turn/start ${JSON.stringify(data['turn'])} does not open expected turn ${nextTurn}`) } @@ -84,6 +101,7 @@ export function assertReleasedArtifactRelationships( toolLifecycles.clear() nextStep = 1 break + } case 'turn/end': if (openTurn !== data['turn']) { throw new SessionFormatError(`turn/end ${JSON.stringify(data['turn'])} has no matching open turn`) @@ -200,7 +218,11 @@ export function assertReleasedArtifactRelationships( break } case 'llm/retry': - requireOpenStep(event, data, openTurn, openStep) + if (openTurn !== data['turn'] + || data['step'] !== (openStep ?? nextStep - 1) + || openTurn === null) { + throw new SessionFormatError('llm/retry does not match the current turn and step') + } if (data['provider'] !== openStepProvider) { throw new SessionFormatError('llm/retry provider does not match the open request/header') } diff --git a/packages/session/session-format-v0-to-v1/src/testing/restore.ts b/packages/session/session-format-v0-to-v1/src/testing/restore.ts new file mode 100644 index 0000000000..be2278af43 --- /dev/null +++ b/packages/session/session-format-v0-to-v1/src/testing/restore.ts @@ -0,0 +1,81 @@ +import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format' +import type { + SessionFormatArtifact, + SessionFormatCurrentEncoder, + SessionFormatRecovery, +} from '@deepseek-ai/dsh-session-format' +import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec } from '../codec.ts' +import { sessionFormatV0ToV1 } from '../migration.ts' +import { assertReleasedV1Header } from '../validation.ts' +import { + assertReleasedV1Artifact, + assertReleasedV1MigrationSource, +} from './validation.ts' + +const unusedEncoder: SessionFormatCurrentEncoder = { + /* v8 ignore next -- this restore-only test facade never encodes a header. */ + encodeHeader: header => header, + /* v8 ignore next -- this restore-only test facade never encodes an event. */ + encodeEvent: event => event, +} + +const catalog = createSessionFormatCatalog({ + currentVersion: 1, + codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec], + currentEncoder: unusedEncoder, + migrations: [sessionFormatV0ToV1], + restoreCurrent(artifact) { + assertReleasedV1Artifact(artifact) + return artifact + }, + restoreTransformedCurrent(artifact) { + assertReleasedV1MigrationSource(artifact) + return artifact + }, + /* v8 ignore next 3 -- this restore-only test facade never performs a header-only read. */ + restoreCurrentHeader(header) { + assertReleasedV1Header(header) + return header + }, +}) + +function restore( + header: unknown, + rows: readonly unknown[], + recovery: SessionFormatRecovery, + validation: 'transformed' | 'current', +): SessionFormatArtifact { + const current = catalog.createRestore(header, { recovery, validation }) + for (const row of rows) current.decodeRow(row) + return current.finish() +} + +/** + * Restore released v0 rows through the production decoder and v0-to-v1 stage. + * @param header - released v0 physical header. + * @param rows - released v0 physical rows. + * @param recovery - strict or recoverable row policy. + * @returns the transformed released v1 artifact. + */ +export function restoreV0ToV1( + header: unknown, + rows: readonly unknown[], + recovery: SessionFormatRecovery = 'strict', +): SessionFormatArtifact { + return restore(header, rows, recovery, 'transformed') +} + +/** + * Restore released v1 rows through the production decoder and current collector. + * @param header - released v1 physical header. + * @param rows - released v1 physical rows. + * @param recovery - strict or recoverable row policy. + * @returns the restored released v1 artifact. + */ +export function restoreV1( + header: unknown, + rows: readonly unknown[], + recovery: SessionFormatRecovery = 'strict', +): SessionFormatArtifact { + return restore(header, rows, recovery, 'current') +} diff --git a/packages/session/session-format-v0-to-v1/src/testing/validation.ts b/packages/session/session-format-v0-to-v1/src/testing/validation.ts new file mode 100644 index 0000000000..100b8d3bf7 --- /dev/null +++ b/packages/session/session-format-v0-to-v1/src/testing/validation.ts @@ -0,0 +1,65 @@ +import type { SessionFormatArtifact } from '@deepseek-ai/dsh-session-format' +import { RELEASED_V0_EVENT_DISPOSITIONS } from '../dispositions.ts' +import { assertReleasedArtifactRelationships } from '../relationships.ts' +import { + assertReleasedArtifactCoordinates, + assertReleasedEventPayload, + assertReleasedSessionFormatHeader, +} from '../validation.ts' + +const RELEASED_V0_EVENT_TYPE_SET: ReadonlySet = new Set(Object.keys(RELEASED_V0_EVENT_DISPOSITIONS)) + +/** + * Validate one decoded released-v0 source artifact in tests. + * @param artifact - released-v0 source artifact. + */ +export function assertReleasedV0SourceArtifact(artifact: SessionFormatArtifact): void { + assertReleasedSessionFormatHeader(artifact.header, 0) + assertReleasedArtifactCoordinates(artifact, true, RELEASED_V0_EVENT_TYPE_SET, false, true) +} + +/** + * Validate normalized v0 output before the identity header bump in tests. + * @param artifact - normalized v0 artifact. + */ +export function assertNormalizedReleasedV0Artifact(artifact: SessionFormatArtifact): void { + assertReleasedSessionFormatHeader(artifact.header, 0) + assertReleasedArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET, false, true) + for (const event of artifact.events) assertReleasedEventPayload(event, 0) + assertReleasedArtifactRelationships(artifact, { legacyInterruptedTurnRestart: true }) +} + +/** + * Validate v1 input accepted by the v1-to-v2 migration in tests. + * @param artifact - v1 migration source artifact. + */ +export function assertReleasedV1MigrationSource(artifact: SessionFormatArtifact): void { + assertReleasedSessionFormatHeader(artifact.header, 1) + assertReleasedArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET, false, true) + for (const event of artifact.events) { + if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) assertReleasedEventPayload(event, 1) + } + assertReleasedArtifactRelationships(artifact, { legacyInterruptedTurnRestart: true }) +} + +/** + * Validate the exact released-v1 logical artifact in tests. + * @param artifact - released-v1 logical artifact. + */ +export function assertReleasedV1Artifact(artifact: SessionFormatArtifact): void { + assertReleasedSessionFormatHeader(artifact.header, 1) + assertReleasedArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET, false, true) + for (const event of artifact.events) { + if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) assertReleasedEventPayload(event, 1) + } + assertReleasedArtifactRelationships(artifact) +} + +/** + * Validate released-v1 physical decoding without interpreting event vocabulary in tests. + * @param artifact - released-v1 physical artifact. + */ +export function assertReleasedV1PhysicalArtifact(artifact: SessionFormatArtifact): void { + assertReleasedSessionFormatHeader(artifact.header, 1) + assertReleasedArtifactCoordinates(artifact, false, undefined, true) +} diff --git a/packages/session/session-format-v0-to-v1/src/validation.ts b/packages/session/session-format-v0-to-v1/src/validation.ts index 0c08918d7c..f61080c7bc 100644 --- a/packages/session/session-format-v0-to-v1/src/validation.ts +++ b/packages/session/session-format-v0-to-v1/src/validation.ts @@ -4,8 +4,8 @@ import { SessionFormatUnsupportedMigrationError, sessionFormatCount, sessionFormatSafeInteger, - snapshotSessionFormatJson, } from '@deepseek-ai/dsh-session-format' +import { isJsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionFormatArtifact, SessionFormatEvent, @@ -14,7 +14,6 @@ import type { } from '@deepseek-ai/dsh-session-format' import { RELEASED_V0_EVENT_DISPOSITIONS } from './dispositions.ts' import { assertReleasedPayloadSemantics } from './payload-validation.ts' -import { assertReleasedArtifactRelationships } from './relationships.ts' import { assertReleasedV0Keys, releasedV0Record } from './validation-helpers.ts' const HEADER_REQUIRED = ['version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const @@ -23,8 +22,15 @@ const EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const const SURFACE_EVENT_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']) const SURFACE_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const const LOG_OPTIONAL = ['ignorable'] as const -const LEGACY_SOURCE_TYPES = new Set(['steering/message', 'request/header-delta', 'mode/set']) -const RELEASED_V0_EVENT_TYPE_SET: ReadonlySet = new Set(Object.keys(RELEASED_V0_EVENT_DISPOSITIONS)) +const LEGACY_SOURCE_TYPES = new Set([ + 'steering/message', + 'request/header-delta', + 'mode/set', + 'compact/start', + 'compact/summary', + 'compact/end', + 'compact/prune', +]) /** * Validate the logical header shared by released v0 and v1. @@ -62,39 +68,6 @@ export function assertReleasedV1Header(header: SessionFormatHeader): void { assertReleasedSessionFormatHeader(header, 1) } -/** - * Validate v0 before historical normalizers run. - * @param artifact - decoded released-v0 source. - */ -export function assertReleasedV0SourceArtifact(artifact: SessionFormatArtifact): void { - assertReleasedSessionFormatHeader(artifact.header, 0) - assertArtifactCoordinates(artifact, true, RELEASED_V0_EVENT_TYPE_SET) -} - -/** - * Validate normalized v0 events before the identity header version changes. - * @param artifact - normalized released-v0 artifact. - */ -export function assertNormalizedReleasedV0Artifact(artifact: SessionFormatArtifact): void { - assertReleasedSessionFormatHeader(artifact.header, 0) - assertArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET) - for (const event of artifact.events) assertReleasedEventPayload(event, 0) - assertReleasedArtifactRelationships(artifact) -} - -/** - * Validate the exact logical image emitted by the released v1 writer. - * @param artifact - decoded or migration-produced v1 artifact. - */ -export function assertReleasedV1Artifact(artifact: SessionFormatArtifact): void { - assertReleasedV1Header(artifact.header) - assertArtifactCoordinates(artifact, false, RELEASED_V0_EVENT_TYPE_SET) - for (const event of artifact.events) { - if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) assertReleasedEventPayload(event, 1) - } - assertReleasedArtifactRelationships(artifact) -} - /** * Restore v1 against the installed build's ordinary event vocabulary without freezing payload additions. * @param artifact - vocabulary-neutral released-v1 physical decode. @@ -106,24 +79,24 @@ export function restoreReleasedV1Artifact( knownEventTypes: ReadonlySet, ): SessionFormatArtifact { assertReleasedV1Header(artifact.header) - assertArtifactCoordinates(artifact, false, knownEventTypes) + assertReleasedArtifactCoordinates(artifact, false, knownEventTypes) return artifact } /** - * Validate released-v1 physical layout without interpreting event vocabulary. - * @param artifact - physical-codec output. + * Validate released-v0/v1 artifact coordinates under an explicit vocabulary policy. + * @param artifact - logical artifact to validate. + * @param allowLegacySteering - whether to accept the retired steering event name. + * @param knownEventTypes - installed event vocabulary, when vocabulary-aware. + * @param vocabularyNeutral - whether unknown event types remain opaque. + * @param frozenEnvelope - whether event envelopes must already be frozen. */ -export function assertReleasedV1PhysicalArtifact(artifact: SessionFormatArtifact): void { - assertReleasedV1Header(artifact.header) - assertArtifactCoordinates(artifact, false, undefined, true) -} - -function assertArtifactCoordinates( +export function assertReleasedArtifactCoordinates( artifact: SessionFormatArtifact, allowLegacySteering: boolean, knownEventTypes?: ReadonlySet, vocabularyNeutral = false, + frozenEnvelope = false, ): void { const inheritedEventCount = sessionFormatCount(artifact.inheritedEventCount, 'Session inheritedEventCount') if (inheritedEventCount > artifact.events.length) { @@ -151,7 +124,6 @@ function assertArtifactCoordinates( `format v1 contains unknown required event type ${JSON.stringify(type)} at seq ${index}`, ) } - const frozenEnvelope = !vocabularyNeutral && knownEventTypes === RELEASED_V0_EVENT_TYPE_SET const surface = disposition !== undefined ? SURFACE_EVENT_TYPES.has(type) : type === 'steering/message' const optional = frozenEnvelope ? surface ? SURFACE_OPTIONAL : LOG_OPTIONAL @@ -195,8 +167,7 @@ export function assertReleasedSurfaceMetadata( } seen.add(current) } - if (sources.length === 0 - && (type !== 'assistant/message' || assistantSources === 'forbid-assistant')) { + if (sources.length === 0 && type !== 'assistant/message') { throw new SessionFormatError(`${type} ${seq} sourceEventSeqs must be non-empty`) } } @@ -220,7 +191,7 @@ export function assertReleasedEventPayload(event: SessionFormatEvent, version: 0 /* v8 ignore next -- artifact coordinate validation admits only the frozen inventory before payload validation. */ if (disposition === undefined) { throw new SessionFormatUnsupportedMigrationError( - `format v0 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`, + `format v0 contains unknown historical event type ${JSON.stringify(event.type)} at seq ${event.seq}; migration refuses unknown historical events even when ignorable`, ) } const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`) @@ -238,7 +209,9 @@ export function assertReleasedEventPayload(event: SessionFormatEvent, version: 0 : disposition.optional assertReleasedV0Keys(data, disposition.required, versionOptional, `${event.type} ${event.seq} data`) for (const key of disposition.opaque) { - if (Object.hasOwn(data, key)) snapshotSessionFormatJson(data[key], `${event.type} ${event.seq} opaque ${key}`) + if (Object.hasOwn(data, key) && !isJsonValue(data[key])) { + throw new SessionFormatError(`${event.type} ${event.seq} opaque ${key} is not lossless JSON`) + } } assertReleasedPayloadSemantics(event, version) } diff --git a/packages/session/session-format-v0-to-v1/tests/codec.spec.ts b/packages/session/session-format-v0-to-v1/tests/codec.spec.ts index 271e123c2d..fd60e4f0e4 100644 --- a/packages/session/session-format-v0-to-v1/tests/codec.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/codec.spec.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest' -import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format' +import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format' +import type { + SessionFormatArtifact, + SessionFormatArtifactDecoder, + SessionFormatEvent, + SessionFormatEventRun, + SessionFormatJsonValue, + SessionFormatMigrationContext, + SessionFormatRecovery, +} from '@deepseek-ai/dsh-session-format' import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, @@ -11,33 +20,42 @@ const fullHeader = { } as const const textBlock = { type: 'text', text: 'text' } as const -function chunk( - seq: number, - type: 'text-delta' | 'reasoning-delta' | 'tool-call-delta', - value: string, - options: { turn?: number; step?: number; index?: number; time?: number; name?: string } = {}, -): SessionFormatEvent { - const stream = type === 'tool-call-delta' - ? { type, index: options.index ?? 0, id: 'call', ...(options.name === undefined ? {} : { name: options.name }), argumentsDelta: value } - : { type, index: options.index ?? 0, text: value } - return { - type: 'assistant/chunk', seq, time: options.time ?? seq + 1, - data: { turn: options.turn ?? 1, step: options.step ?? 0, chunk: stream }, +class DecodedItemCollector implements SessionFormatMigrationContext { + readonly values: Array = [] + + emitEvent(event: SessionFormatEvent): void { + this.values.push(event) + } + + emitRun(run: SessionFormatEventRun): void { + this.values.push(run) } } -function artifact(events: readonly SessionFormatEvent[], overrides: Partial = {}): SessionFormatArtifact { - return { - header: { version: 1, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0 }, - inheritedEventCount: 0, - events, - ...overrides, - } +function decodeRow( + decoder: SessionFormatArtifactDecoder, + row: SessionFormatJsonValue, +): Array { + const output = new DecodedItemCollector() + decoder.decodeRow(row, output) + return output.values +} + +function decodeArtifact( + codec: typeof releasedV0SessionFormatCodec, + header: unknown, + rows: readonly unknown[], + recovery: SessionFormatRecovery = 'strict', +): SessionFormatArtifact { + const decoder = codec.createDecoder(header, recovery) + const context = new SessionFormatEventCollector() + for (const row of rows) decoder.decodeRow(row, context) + return { header: decoder.header, inheritedEventCount: decoder.finish(context), events: context.values } } describe('released v0/v1 physical codecs', () => { - it('round-trips every physical header field and seeded zero cut', () => { - const decoded = releasedV1SessionFormatCodec.decodeArtifact(fullHeader, []) + it('decodes every physical header field and seeded zero cut', () => { + const decoded = decodeArtifact(releasedV1SessionFormatCodec, fullHeader, []) expect(decoded).toEqual({ header: { version: 1, id: 'codec', createdAt: 1, cwd: '/work', parentSession: 'parent', @@ -46,11 +64,8 @@ describe('released v0/v1 physical codecs', () => { inheritedEventCount: 0, events: [], }) - expect(releasedV1SessionFormatCodec.encodeArtifact(decoded, { packChunks: false }).header).toEqual(fullHeader) - expect(releasedV0SessionFormatCodec.encodeArtifact({ - ...decoded, - header: { ...decoded.header, version: 0 }, - }, { packChunks: false }).header).toEqual({ ...fullHeader, version: 0 }) + expect(releasedV0SessionFormatCodec.decodeHeader({ ...fullHeader, version: 0 })) + .toEqual({ ...decoded.header, version: 0 }) }) it.each([ @@ -69,43 +84,60 @@ describe('released v0/v1 physical codecs', () => { expect(() => releasedV1SessionFormatCodec.decodeHeader(header)).toThrow() }) - it('packs and expands text, reasoning, and named tool-call runs exactly', () => { - const events = [ - chunk(0, 'text-delta', 'a'), chunk(1, 'text-delta', 'b'), chunk(2, 'text-delta', 'c'), - chunk(3, 'reasoning-delta', 'd'), chunk(4, 'reasoning-delta', 'e'), chunk(5, 'reasoning-delta', 'f'), - chunk(6, 'tool-call-delta', '{', { name: 'read' }), - chunk(7, 'tool-call-delta', '}', { name: 'read' }), - chunk(8, 'tool-call-delta', '', { name: 'read' }), - ] - const v0 = { ...artifact(events), header: { ...artifact(events).header, version: 0 } } - const encoded = releasedV0SessionFormatCodec.encodeArtifact(v0, { packChunks: true }) - expect(encoded.rows.map(row => row['type'])).toEqual(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']) - expect(releasedV0SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows).events).toEqual(events) + it('expands valid text, reasoning, and named or unnamed tool-call rows exactly', () => { + const cases = [ + [{ + type: 'text-chunks', seq0: 0, time0: 1, + data: { turn: 1, step: 2, index: 3, dt: [2], texts: ['a', 'b'] }, + }, [ + { type: 'assistant/chunk', seq: 0, time: 1, data: { + turn: 1, step: 2, chunk: { type: 'text-delta', index: 3, text: 'a' }, + } }, + { type: 'assistant/chunk', seq: 1, time: 3, data: { + turn: 1, step: 2, chunk: { type: 'text-delta', index: 3, text: 'b' }, + } }, + ]], + [{ + type: 'reasoning-chunks', seq0: 0, time0: 4, + data: { turn: 2, step: 3, index: 1, dt: [1], texts: ['c', 'd'] }, + }, [ + { type: 'assistant/chunk', seq: 0, time: 4, data: { + turn: 2, step: 3, chunk: { type: 'reasoning-delta', index: 1, text: 'c' }, + } }, + { type: 'assistant/chunk', seq: 1, time: 5, data: { + turn: 2, step: 3, chunk: { type: 'reasoning-delta', index: 1, text: 'd' }, + } }, + ]], + [{ + type: 'tool-call-chunks', seq0: 0, time0: 6, + data: { turn: 3, step: 4, index: 2, id: 'call', name: 'read', dt: [1], args: ['{', '}'] }, + }, [ + { type: 'assistant/chunk', seq: 0, time: 6, data: { + turn: 3, step: 4, + chunk: { type: 'tool-call-delta', index: 2, id: 'call', name: 'read', argumentsDelta: '{' }, + } }, + { type: 'assistant/chunk', seq: 1, time: 7, data: { + turn: 3, step: 4, + chunk: { type: 'tool-call-delta', index: 2, id: 'call', name: 'read', argumentsDelta: '}' }, + } }, + ]], + [{ + type: 'tool-call-chunks', seq0: 0, time0: 8, + data: { turn: 4, step: 5, index: 0, id: 'call', dt: [], args: ['x'] }, + }, [ + { type: 'assistant/chunk', seq: 0, time: 8, data: { + turn: 4, step: 5, + chunk: { type: 'tool-call-delta', index: 0, id: 'call', argumentsDelta: 'x' }, + } }, + ]], + ] as const - const unnamed = [ - chunk(0, 'tool-call-delta', 'a'), - chunk(1, 'tool-call-delta', 'b'), - chunk(2, 'tool-call-delta', 'c'), - ] - const unnamedV0 = { ...artifact(unnamed), header: { ...artifact(unnamed).header, version: 0 } } - const unnamedEncoded = releasedV0SessionFormatCodec.encodeArtifact(unnamedV0, { packChunks: true }) - expect(unnamedEncoded.rows[0]?.['data']).not.toHaveProperty('name') - expect(releasedV0SessionFormatCodec.decodeArtifact(unnamedEncoded.header, unnamedEncoded.rows).events).toEqual(unnamed) - }) - - it('keeps short or non-continuing chunk runs unpacked', () => { - const events = [ - chunk(0, 'text-delta', 'a'), - chunk(1, 'text-delta', 'b', { index: 1 }), - chunk(2, 'text-delta', 'c', { turn: 2 }), - chunk(3, 'text-delta', 'd', { step: 1 }), - chunk(4, 'tool-call-delta', 'a'), - chunk(5, 'tool-call-delta', 'b', { name: 'read' }), - chunk(6, 'tool-call-delta', 'c', { name: 'read' }), - ] - const v0 = { ...artifact(events), header: { ...artifact(events).header, version: 0 } } - const encoded = releasedV0SessionFormatCodec.encodeArtifact(v0, { packChunks: true }) - expect(encoded.rows).toEqual(events) + for (const [row, expected] of cases) { + const decoder = releasedV1SessionFormatCodec.createDecoder(fullHeader, 'strict') + const item = decodeRow(decoder, row)[0] + if (item === undefined || !('runType' in item)) throw new Error('expected a packed Assistant run') + expect([...(item as SessionFormatEventRun).expand()]).toEqual(expected) + } }) it.each([ @@ -118,7 +150,7 @@ describe('released v0/v1 physical codecs', () => { ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 0, index: 0, id: 'id', name: 1, dt: [], args: ['a'] } }], ['unsafe time sum', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 0, index: 0, dt: [1], texts: ['a', 'b'] } }], ])('refuses malformed packed row: %s', (_name, row) => { - expect(() => releasedV1SessionFormatCodec.decodeArtifact(fullHeader, [row])).toThrow() + expect(() => decodeArtifact(releasedV1SessionFormatCodec, fullHeader, [row])).toThrow() }) it.each([ @@ -135,39 +167,83 @@ describe('released v0/v1 physical codecs', () => { id: 'u', role: 'user', content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, }, sourceEventSeqs, surfaceOp: 'append' }, ] - expect(() => releasedV1SessionFormatCodec.decodeArtifact( + expect(() => decodeArtifact(releasedV1SessionFormatCodec, { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 }, rows, )).toThrow() }) it('contains non-SessionFormatError row failures during recoverable scans', () => { - const bad = new Proxy({}, { ownKeys: () => { throw new Error('proxy failure') } }) - const recovered = releasedV1SessionFormatCodec.decodeRecoverableArtifact( + const bad = new Proxy({}, { get: () => { throw new Error('proxy failure') } }) + const decoder = releasedV1SessionFormatCodec.createDecoder( { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 }, - [bad], + 'recoverable', ) - expect(recovered).toMatchObject({ events: [] }) + expect(decodeRow(decoder, bad)).toEqual([]) + expect(() => decodeRow(decoder, { + type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } }, + })).toThrow('released Session row 0 is malformed') + }) + + it('streams the same row-atomic recoverable prefix', () => { + const create = (header: unknown) => releasedV1SessionFormatCodec.createDecoder(header, 'recoverable') + const header = { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 } + + const malformed = create(header) + expect(decodeRow(malformed, null)).toEqual([]) + const malformedProxy = create(header) + const proxy = new Proxy({}, { get: () => { throw new Error('proxy failure') } }) + expect(decodeRow(malformedProxy, proxy)).toEqual([]) + expect(decodeRow(malformedProxy, { type: 'step/start', seq: 0, time: 1, data: { turn: 1, step: 1 } })).toEqual([]) + expect(() => decodeRow(malformedProxy, { + type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } }, + })).toThrow('released Session row 0 is malformed') + + const gap = create(header) + expect(decodeRow(gap, { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } })).toEqual([]) + const terminalGap = create(header) + expect(() => decodeRow(terminalGap, { + type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } }, + })).toThrow(/seq gap/) + + const seeded = create({ ...header, seedLength: 1 }) + expect(() => seeded.finish(new DecodedItemCollector())).toThrow(/inheritedEventCount exceeds/) + + const packed = create(header) + const runs = decodeRow(packed, { + type: 'text-chunks', seq0: 0, time0: 1, + data: { turn: 1, step: 1, index: 0, dt: [1], texts: ['a', 'b'] }, + }) + expect(runs).toHaveLength(1) + expect([...((runs[0] as SessionFormatEventRun).expand())]).toHaveLength(2) + const provenance = create(header) + decodeRow(provenance, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }) + const item = decodeRow(provenance, { + type: 'user/message', seq: 1, time: 2, + data: { id: 'm', role: 'user', content: [], source: { kind: 'user' } }, + sourceEventSeqs: [0], surfaceOp: 'append', + })[0] + expect(item !== undefined && !('runType' in item) ? item.sourceEventSeqs : undefined).toEqual([0]) }) it('ignores decodable non-terminal rows after the first recoverable issue', () => { const header = { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 } - const recovered = releasedV1SessionFormatCodec.decodeRecoverableArtifact(header, [ + const recovered = decodeArtifact(releasedV1SessionFormatCodec, header, [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, { type: 'turn/start', seq: 4, time: 2, data: { turn: 2 } }, { type: 'step/start', seq: 1, time: 3, data: { turn: 1, step: 0 } }, - ]) + ], 'recoverable') expect(recovered).toMatchObject({ events: [{ seq: 0 }] }) }) it('rejects strict gaps and a recoverable gap row that itself closes a turn', () => { const currentHeader = { type: 'session', version: 1, id: 'codec', createdAt: 1, delegationDepth: 0 } - expect(() => releasedV1SessionFormatCodec.decodeArtifact(currentHeader, [ + expect(() => decodeArtifact(releasedV1SessionFormatCodec, currentHeader, [ { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }, ])).toThrow(/seq gap/) - expect(() => releasedV1SessionFormatCodec.decodeRecoverableArtifact(currentHeader, [ + expect(() => decodeArtifact(releasedV1SessionFormatCodec, currentHeader, [ { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, - ])).toThrow(/seq gap/) + ], 'recoverable')).toThrow(/seq gap/) }) it('refuses overlapping ranges after a valid first range', () => { @@ -182,74 +258,6 @@ describe('released v0/v1 physical codecs', () => { data: { id: 'u', role: 'user', content: [textBlock], source: { kind: 'user' } }, }, ] - expect(() => releasedV1SessionFormatCodec.decodeArtifact(header, rows)).toThrow(/strictly increasing/) - }) - - it('keeps non-consecutive provenance scalar and leaves invalid v0 chunks unpacked', () => { - const events = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - { type: 'step/end', seq: 2, time: 3, data: { turn: 1, step: 1 } }, - { - type: 'user/message', seq: 3, time: 4, surfaceOp: 'append', sourceEventSeqs: [0, 2], - data: { id: 'u', role: 'user', content: [textBlock], source: { kind: 'user' } }, - }, - ] as SessionFormatEvent[] - expect(releasedV1SessionFormatCodec.encodeArtifact(artifact(events), { packChunks: false }).rows[3]) - .toEqual(events[3]) - - const invalidChunk = { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 0, chunk: null } } - const v0 = { - header: { version: 0, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0 }, - inheritedEventCount: 0, - events: [invalidChunk], - } as unknown as SessionFormatArtifact - expect(releasedV0SessionFormatCodec.encodeArtifact(v0, { packChunks: true }).rows).toEqual([invalidChunk]) - - const causal = { - ...v0, - events: [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 2, surfaceOp: 'append', data: { - id: 'one', role: 'user', content: [textBlock], source: { kind: 'user' }, - } }, - { type: 'user/message', seq: 2, time: 3, surfaceOp: 'append', data: { - id: 'two', role: 'user', content: [textBlock], source: { kind: 'user' }, - } }, - { type: 'user/message', seq: 3, time: 4, surfaceOp: 'append', sourceEventSeqs: [2, 0], data: { - id: 'three', role: 'user', content: [textBlock], source: { kind: 'user' }, - } }, - ], - } as unknown as SessionFormatArtifact - expect(releasedV0SessionFormatCodec.encodeArtifact(causal, { packChunks: false }).rows[3]?.['sourceEventSeqs']) - .toEqual([2, 0]) - - for (const badData of [ - null, - { turn: 1, step: 1, chunk: null }, - { turn: 1, step: 1, chunk: { type: 'other', index: 0 } }, - { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } }, - { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } }, - ]) { - const bad = { ...v0, events: [{ type: 'assistant/chunk', seq: 0, time: 1, data: badData }] } as unknown as SessionFormatArtifact - expect(releasedV0SessionFormatCodec.encodeArtifact(bad, { packChunks: true }).rows).toHaveLength(1) - } - - const farTimes = [ - chunk(0, 'text-delta', 'a', { time: Number.MIN_SAFE_INTEGER }), - chunk(1, 'text-delta', 'b', { time: Number.MAX_SAFE_INTEGER }), - chunk(2, 'text-delta', 'c', { time: Number.MAX_SAFE_INTEGER }), - ] - const far = { ...v0, events: farTimes } as unknown as SessionFormatArtifact - expect(releasedV0SessionFormatCodec.encodeArtifact(far, { packChunks: true }).rows).toHaveLength(3) - - const ignorable = [ - { ...chunk(0, 'text-delta', 'a'), ignorable: true }, - { ...chunk(1, 'text-delta', 'b'), ignorable: true }, - { ...chunk(2, 'text-delta', 'c'), ignorable: true }, - ] as SessionFormatEvent[] - const ignorableV0 = { ...v0, events: ignorable } as unknown as SessionFormatArtifact - expect(releasedV0SessionFormatCodec.encodeArtifact(ignorableV0, { packChunks: true }).rows) - .toEqual(ignorable) + expect(() => decodeArtifact(releasedV1SessionFormatCodec, header, rows)).toThrow(/strictly increasing/) }) }) diff --git a/packages/session/session-format-v0-to-v1/tests/legacy.spec.ts b/packages/session/session-format-v0-to-v1/tests/legacy.spec.ts index 101b251f24..3dd1d82c3a 100644 --- a/packages/session/session-format-v0-to-v1/tests/legacy.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/legacy.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' import { - releasedV0SessionFormatCodec, sessionFormatV0ToV1, } from '../src/index.ts' +import { restoreV0ToV1 } from '../src/testing/restore.ts' const header = { type: 'session', @@ -14,7 +14,7 @@ const header = { } as const function migrate(rows: readonly unknown[]) { - return sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(header, rows)) + return restoreV0ToV1(header, rows) } describe('released v0 legacy normalization', () => { diff --git a/packages/session/session-format-v0-to-v1/tests/migration.spec.ts b/packages/session/session-format-v0-to-v1/tests/migration.spec.ts index 33bb1376e6..c786a49ddf 100644 --- a/packages/session/session-format-v0-to-v1/tests/migration.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/migration.spec.ts @@ -1,11 +1,33 @@ import { describe, expect, it } from 'vitest' +import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format' +import type { + SessionFormatEvent, + SessionFormatEventRun, +} from '@deepseek-ai/dsh-session-format' import { RELEASED_V0_EVENT_TYPES, - releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, restoreReleasedV1Artifact, sessionFormatV0ToV1, } from '../src/index.ts' +import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts' + +function createMigrationStage(id: string) { + const sourceHeader = { version: 0, id, createdAt: 1, isSeeded: false, delegationDepth: 0 } + const stage = sessionFormatV0ToV1.createStage({ + sourceHeader, + targetHeader: sessionFormatV0ToV1.migrateHeader(sourceHeader), + sourceInheritedEventCount: 0, + sourceKind: 'decoded', + }) + return { + transform(event: SessionFormatEvent): SessionFormatEvent[] { + const context = new SessionFormatEventCollector() + stage.transformEvent(event, context) + return context.values + }, + } +} describe('released Session format v0 to v1', () => { it('changes only the version of a canonical decoded artifact', () => { @@ -27,19 +49,71 @@ describe('released Session format v0 to v1', () => { data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] }, }, ] - const source = releasedV0SessionFormatCodec.decodeArtifact(header, rows) - - const migrated = sessionFormatV0ToV1.migrate(source) + const migrated = restoreV0ToV1(header, rows) expect(migrated).toEqual({ - ...source, - header: { ...source.header, version: 1 }, + header: { + version: 1, id: 'identity', createdAt: 1, cwd: '/work', isSeeded: false, delegationDepth: 0, + }, + inheritedEventCount: 0, + events: [ + rows[0], + rows[1], + { type: 'assistant/chunk', seq: 2, time: 4, data: { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' }, + } }, + { type: 'assistant/chunk', seq: 3, time: 5, data: { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' }, + } }, + { type: 'assistant/chunk', seq: 4, time: 6, data: { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'c' }, + } }, + ], }) - sessionFormatV0ToV1.validateTarget(migrated) - expect(releasedV1SessionFormatCodec.encodeArtifact(migrated, { packChunks: true })).toEqual({ - header: { ...header, version: 1 }, - rows, + }) + + it('expands an unrecognized compact run through the identity stage', () => { + const sourceHeader = { + version: 0, id: 'generic-run', createdAt: 1, isSeeded: false, delegationDepth: 0, + } as const + const stage = sessionFormatV0ToV1.createStage({ + sourceHeader, + targetHeader: sessionFormatV0ToV1.migrateHeader(sourceHeader), + sourceInheritedEventCount: 0, + sourceKind: 'transformed', }) + const source = { + type: 'feedback/record', seq: 0, time: 2, data: { text: 'retained' }, + } as const + const run: SessionFormatEventRun = { + runType: 'test-run', firstSeq: 0, eventCount: 1, *expand() { yield source }, + } + const output = new SessionFormatEventCollector() + + stage.transformRun(run, output) + + expect(output.values).toEqual([source]) + }) + + it('accepts an inherited delivery marker for its source generation', () => { + const sourceHeader = { + version: 0, id: 'child', createdAt: 1, parentSession: 'parent', isSeeded: true, delegationDepth: 0, + } as const + const stage = sessionFormatV0ToV1.createStage({ + sourceHeader, + targetHeader: sessionFormatV0ToV1.migrateHeader(sourceHeader), + sourceInheritedEventCount: 2, + sourceKind: 'decoded', + }) + const marker = { + type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, + data: { sessionId: 'parent', throughSeq: 0 }, + } as const + const output = new SessionFormatEventCollector() + + stage.transformEvent(marker, output) + + expect(output.values).toEqual([marker]) }) it('recovers only the complete row prefix and refuses a later committing turn end', () => { @@ -61,13 +135,13 @@ describe('released Session format v0 to v1', () => { data: { turn: 2, step: 0, index: 0, dt: [1], texts: ['x', 'y'] }, } - expect(releasedV0SessionFormatCodec.decodeRecoverableArtifact(header, [...prefix, badRow])) - .toEqual(releasedV0SessionFormatCodec.decodeArtifact(header, prefix)) - expect(() => releasedV0SessionFormatCodec.decodeRecoverableArtifact(header, [ + expect(restoreV0ToV1(header, [...prefix, badRow], 'recoverable')) + .toEqual(restoreV0ToV1(header, prefix)) + expect(() => restoreV0ToV1(header, [ ...prefix, badRow, { type: 'turn/end', seq: 2, time: 6, data: { turn: 2, reason: { kind: 'interrupted' } } }, - ])).toThrow(/seq gap/) + ], 'recoverable')).toThrow(/seq gap/) }) it('requires canonical delegation depth and decodes provenance without mutating source rows', () => { @@ -100,15 +174,15 @@ describe('released Session format v0 to v1', () => { provenanceRow, ] - const decoded = releasedV0SessionFormatCodec.decodeArtifact(header, rows) + const migrated = restoreV0ToV1(header, rows) - expect(() => releasedV0SessionFormatCodec.decodeArtifact(incompleteHeader, rows)).toThrow(/delegationDepth/) - expect(decoded.header.delegationDepth).toBe(0) - expect(decoded.events[3]?.sourceEventSeqs).toEqual([0, 1, 2]) + expect(() => restoreV0ToV1(incompleteHeader, rows)).toThrow(/delegationDepth/) + expect(migrated.header.delegationDepth).toBe(0) + expect(migrated.events[3]?.sourceEventSeqs).toEqual([0, 1, 2]) expect(provenanceRow.sourceEventSeqs).toEqual([[0, 2]]) - const migrated = sessionFormatV0ToV1.migrate(decoded) - expect(releasedV1SessionFormatCodec.encodeArtifact(migrated, { packChunks: false }).header) - .toEqual({ ...header, version: 1 }) + expect(migrated.header).toEqual({ + version: 1, id: 'old', createdAt: 1, isSeeded: false, delegationDepth: 0, + }) }) it('refuses v1-only generation fields in v0 and accepts them in v1', () => { @@ -122,10 +196,91 @@ describe('released Session format v0 to v1', () => { const v0 = { type: 'session', version: 0, id: 'delivery', createdAt: 1, delegationDepth: 0 } const v1 = { ...v0, version: 1 } - expect(() => sessionFormatV0ToV1.migrate( - releasedV0SessionFormatCodec.decodeArtifact(v0, [prefix, event]), - )).toThrow(/unexpected member "sessionFormatVersion"/) - expect(releasedV1SessionFormatCodec.decodeArtifact(v1, [prefix, event]).events).toEqual([prefix, event]) + expect(() => restoreV0ToV1(v0, [prefix, event])).toThrow(/unexpected member "sessionFormatVersion"/) + expect(restoreV1(v1, [prefix, event]).events).toEqual([prefix, event]) + }) + + it('does not synthesize over an explicitly invalid retry id', () => { + const header = { type: 'session', version: 0, id: 'retry', createdAt: 1, delegationDepth: 0 } + const rows = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 2, time: 3, data: { + header: { config: { provider: 'p', model: 'm' } }, reason: 'initial', + } }, + { type: 'llm/retry', seq: 3, time: 4, data: { + retryId: null, turn: 1, step: 1, provider: 'p', mode: 'normal', policyKey: 'k', + retry: 1, maxRetries: 1, delayMs: 0, failure: { message: 'retry', code: 'SERVER' }, + } }, + ] + expect(() => restoreV0ToV1(header, rows)).toThrow(/retryId/) + + const transformer = createMigrationStage('retry') + const retry = (seq: number, attempt: number) => ({ + type: 'llm/retry', seq, time: seq + 1, + data: { + turn: 1, step: 1, provider: 'p', mode: 'normal', policyKey: 'k', retry: attempt, + maxRetries: 2, delayMs: 0, failure: { message: 'retry', code: 'SERVER' }, + }, + }) + expect(transformer.transform(retry(0, 1))[0]?.data).toMatchObject({ retryId: 'legacy-retry:retry:0' }) + expect(transformer.transform(retry(1, 2))[0]?.data).toMatchObject({ retryId: 'legacy-retry:retry:0' }) + }) + + it('normalizes legacy compaction names and assigns one deterministic id to the group', () => { + const transformer = createMigrationStage('compact') + const rows = [ + { type: 'compaction/start', seq: 0, time: 1, data: { turn: null } }, + { + type: 'compaction/summary', seq: 1, time: 2, + data: { + summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start: 0, end: 0 }, + shadowedSeqs: [0], shadowedTokenCount: 0, provider: 'p', model: 'm', + }, + }, + { + type: 'compaction/prune', seq: 2, time: 3, + data: { shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [0], shadowedTokenCount: 1 }, + }, + { + type: 'user/message', seq: 3, time: 4, + data: { + id: 'checkpoint', role: 'user', content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, + sourceEventSeqs: [0, 1], surfaceOp: { op: 'replace', start: 0, end: 0 }, + }, + { type: 'compaction/end', seq: 4, time: 5, data: { turn: null } }, + ] + + const migrated = rows.map(row => transformer.transform(row)[0]) + expect(migrated.map(event => event?.type === 'user/message' + ? (event.data as { source: { compactionId: string } }).source.compactionId + : (event?.data as { compactionId: string }).compactionId)) + .toEqual([ + 'legacy-compaction:compact:0', + 'legacy-compaction:compact:0', + undefined, + 'legacy-compaction:compact:0', + 'legacy-compaction:compact:0', + ]) + + const historical = rows.map(row => ({ + ...row, + type: row.type.replace(/^compaction\//, 'compact/'), + })) + const historicalTransformer = createMigrationStage('compact') + expect(historical.map(row => historicalTransformer.transform(row as SessionFormatEvent)[0])).toEqual(migrated) + + const reset = createMigrationStage('compact') + expect(() => reset.transform({ + ...(rows[0] as SessionFormatEvent), data: { turn: null, compactionId: null }, + })).toThrow(/compactionId/) + reset.transform(rows[0] as never) + expect(reset.transform({ type: 'feedback/record', seq: 1, time: 2, data: { text: 'kept' } })[0]?.type) + .toBe('feedback/record') + reset.transform({ type: 'session/end-seed', seq: 2, time: 3, data: {} }) + expect(() => reset.transform({ ...(rows[1] as SessionFormatEvent), seq: 3 })).toThrow(/compactionId/) }) it('preserves a complete canonical multi-owner log except for header.version', () => { @@ -209,9 +364,14 @@ describe('released Session format v0 to v1', () => { }, { type: 'turn/end', seq: 19, time: 20, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const source = releasedV0SessionFormatCodec.decodeArtifact(physicalHeader, rows) - const migrated = sessionFormatV0ToV1.migrate(source) - expect(migrated).toEqual({ ...source, header: { ...source.header, version: 1 } }) + const migrated = restoreV0ToV1(physicalHeader, rows) + expect(migrated).toEqual({ + header: { + version: 1, id: 'full-identity', createdAt: 1, cwd: '/work', isSeeded: false, delegationDepth: 0, + }, + inheritedEventCount: 0, + events: rows, + }) }) it('keeps the v1 physical codec vocabulary-neutral for current growth and a future source freeze', () => { @@ -219,10 +379,17 @@ describe('released Session format v0 to v1', () => { type: 'session', version: 1, id: 'ordinary-growth', createdAt: 1, delegationDepth: 0, } const ordinary = { type: 'ordinary/post-v1', seq: 0, time: 1, data: { required: true } } - const decoded = releasedV1SessionFormatCodec.decodeArtifact(physicalHeader, [ordinary]) + const decoder = releasedV1SessionFormatCodec.createDecoder(physicalHeader, 'strict') + const events = new SessionFormatEventCollector() + decoder.decodeRow(ordinary, events) + const decoded = { + header: decoder.header, + inheritedEventCount: decoder.finish(events), + events: events.values, + } expect(decoded.events).toEqual([ordinary]) - expect(() => { sessionFormatV0ToV1.validateTarget(decoded) }).toThrow(/unknown required event/) + expect(RELEASED_V0_EVENT_TYPES).not.toContain(ordinary.type) const generatedCurrentTypes = new Set([...RELEASED_V0_EVENT_TYPES, ordinary.type]) expect(() => restoreReleasedV1Artifact(decoded, generatedCurrentTypes)).not.toThrow() @@ -230,10 +397,19 @@ describe('released Session format v0 to v1', () => { const frozenFutureV1SourceTypes = new Set(generatedCurrentTypes) expect(() => restoreReleasedV1Artifact(decoded, frozenFutureV1SourceTypes)).not.toThrow() - const extendedKnownPayload = releasedV1SessionFormatCodec.decodeArtifact(physicalHeader, [{ + const extendedDecoder = releasedV1SessionFormatCodec.createDecoder(physicalHeader, 'strict') + const extendedEvents = new SessionFormatEventCollector() + extendedDecoder.decodeRow({ + type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true }, + }, extendedEvents) + const extendedKnownPayload = { + header: extendedDecoder.header, + inheritedEventCount: extendedDecoder.finish(extendedEvents), + events: extendedEvents.values, + } + expect(extendedKnownPayload.events).toEqual([{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true }, }]) - expect(() => { sessionFormatV0ToV1.validateTarget(extendedKnownPayload) }).toThrow(/unexpected member/) expect(() => restoreReleasedV1Artifact(extendedKnownPayload, generatedCurrentTypes)).not.toThrow() }) }) diff --git a/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts b/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts index 1354e7c2ea..ef6223aa79 100644 --- a/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/relationships.spec.ts @@ -1,11 +1,9 @@ import { describe, expect, it } from 'vitest' import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' import { - releasedV0SessionFormatCodec, - releasedV1SessionFormatCodec, - assertReleasedV1Artifact, - sessionFormatV0ToV1, + assertReleasedArtifactRelationships, } from '../src/index.ts' +import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts' const header = { type: 'session', version: 1, id: 'relationships', createdAt: 1, delegationDepth: 0, @@ -15,9 +13,7 @@ const user = (id: string, source: object = { kind: 'user' }) => ({ }) function decode(rows: readonly unknown[], physicalHeader: unknown = header) { - const artifact = releasedV1SessionFormatCodec.decodeArtifact(physicalHeader, rows) - assertReleasedV1Artifact(artifact) - return artifact + return restoreV1(physicalHeader, rows) } describe('released v1 whole-artifact relationships', () => { @@ -299,6 +295,7 @@ describe('released v1 whole-artifact relationships', () => { }) expect(() => decode([...prefix, retry({ maxRetries: undefined })])).toThrow() expect(() => decode([...prefix, retry({ retry: 2, maxRetries: 1 })])).toThrow() + expect(() => decode([...prefix, retry({ turn: 2 })])).toThrow(/current turn/) expect(() => decode([...prefix, retry({ provider: 'q' })])).toThrow(/provider/) expect(() => decode([...prefix, retry({ failure: { message: 'x', code: 'X', status: 99 } })])).toThrow(/status/) expect(() => decode([...prefix, retry({ delayMs: -0.5 })])).toThrow(/non-negative/) @@ -309,6 +306,25 @@ describe('released v1 whole-artifact relationships', () => { .toHaveLength(4) }) + it('accepts a legacy retry scheduled immediately after its step closes', () => { + const rows = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 2, time: 3, data: { header: { config: { provider: 'p', model: 'm' } }, reason: 'initial' } }, + { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, + { + type: 'llm/retry', seq: 4, time: 5, + data: { + retryId: 'r', turn: 1, step: 1, provider: 'p', mode: 'normal', policyKey: 'k', retry: 1, + maxRetries: 2, delayMs: 1, failure: { message: 'retry', code: 'SERVER' }, + }, + }, + { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + + expect(decode(rows).events).toHaveLength(rows.length) + }) + it('enforces command pairing and authoritative source-event rules', () => { expect(() => decode([{ type: 'command/done', seq: 0, time: 1, data: { commandId: 'c', kind: 'success' }, @@ -366,7 +382,9 @@ describe('released v1 whole-artifact relationships', () => { } const decoded = decode([...prefix, inertV0]) expect(decoded.events[1]).toEqual(inertV0) - expect(() => { sessionFormatV0ToV1.validateTarget(decoded) }).not.toThrow() + expect(() => { + assertReleasedArtifactRelationships(decoded, { legacyInterruptedTurnRestart: true }) + }).not.toThrow() }) it('validates versioned subagent descriptors by source/current policy', () => { @@ -376,7 +394,7 @@ describe('released v1 whole-artifact relationships', () => { } expect(decode([future]).events).toEqual([future]) const v0Header = { ...header, version: 0 } - expect(() => sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(v0Header, [future]))) + expect(() => restoreV0ToV1(v0Header, [future])) .toThrow(SessionFormatUnsupportedMigrationError) }) @@ -465,10 +483,30 @@ describe('released v1 whole-artifact relationships', () => { data: { sessionId: 'wrong', throughSeq: 0 }, }, ] - expect(() => sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(v0, rows))) + expect(() => restoreV0ToV1(v0, rows)) .toThrow(/wrong Session/) }) + it('accepts the released interrupted-turn restart sequence during v0 migration', () => { + const v0 = { ...header, version: 0 } + const rows = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'step/end', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'agent/inbox/spliced', seq: 3, time: 4, + data: { target: 'next-turn', start: 0, inserted: [user('queued')] }, + }, + { type: 'turn/start', seq: 4, time: 5, data: { turn: 2 } }, + { type: 'turn/end', seq: 5, time: 6, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + + expect(restoreV0ToV1(v0, rows).events).toEqual(rows) + expect(() => restoreV0ToV1(v0, rows.map(candidate => candidate.seq === 3 + ? { ...candidate, data: { ...candidate.data, inserted: [] } } + : candidate))).toThrow(/does not open expected turn/) + }) + it('accepts recoverable open tails for later interrupted-turn repair', () => { const v0 = { ...header, version: 0 } const rows = [ @@ -500,8 +538,7 @@ describe('released v1 whole-artifact relationships', () => { }, { type: 'compaction/start', seq: 7, time: 8, data: { compactionId: 'c', turn: 1 } }, ] - const recovered = releasedV0SessionFormatCodec.decodeRecoverableArtifact(v0, rows) - expect(sessionFormatV0ToV1.migrate(recovered).events).toHaveLength(rows.length) + expect(restoreV0ToV1(v0, rows, 'recoverable').events).toHaveLength(rows.length) }) it('refuses invalid core openings, request placement, and replacement placement', () => { diff --git a/packages/session/session-format-v0-to-v1/tests/validation.spec.ts b/packages/session/session-format-v0-to-v1/tests/validation.spec.ts index 6c26a510e5..852f810164 100644 --- a/packages/session/session-format-v0-to-v1/tests/validation.spec.ts +++ b/packages/session/session-format-v0-to-v1/tests/validation.spec.ts @@ -4,12 +4,16 @@ import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session' import { RELEASED_V0_EVENT_TYPES, RELEASED_V0_EVENT_DISPOSITIONS, - assertReleasedV1Artifact, - releasedV0SessionFormatCodec, - releasedV1SessionFormatCodec, - sessionFormatV0ToV1, } from '../src/index.ts' -import { assertReleasedEventPayload } from '../src/validation.ts' +import { assertReleasedEventPayload, assertReleasedSurfaceMetadata } from '../src/validation.ts' +import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts' +import { + assertNormalizedReleasedV0Artifact, + assertReleasedV0SourceArtifact, + assertReleasedV1Artifact, + assertReleasedV1MigrationSource, + assertReleasedV1PhysicalArtifact, +} from '../src/testing/validation.ts' const v0Header = { type: 'session', version: 0, id: 'validation', createdAt: 1, delegationDepth: 0, @@ -290,6 +294,15 @@ describe('released event and payload inventory', () => { } }) + it('refuses opaque members that are not lossless JSON without snapshotting them', () => { + const data = { + ...(validPayloads['tool/result'] as Record), + meta: { value: undefined }, + } + expect(() => { assertPayload('tool/result', data as unknown as SessionFormatJsonValue) }) + .toThrow(/opaque meta is not lossless JSON/) + }) + it.each([ ['user/message content block', 'user/message', { ...userMessage, @@ -312,10 +325,58 @@ describe('released event and payload inventory', () => { it('refuses unknown v0 events even when the envelope marks them ignorable', () => { const row = { type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true } - expect(() => releasedV0SessionFormatCodec.decodeArtifact(v0Header, [row])) + expect(() => restoreV0ToV1(v0Header, [row])) .toThrow(/unknown historical event.*refuses.*ignorable/) }) + it('exercises each released test validation policy', () => { + const v0 = { + header: { version: 0, id: 'validation', createdAt: 1, isSeeded: false, delegationDepth: 0 }, + inheritedEventCount: 0, + events: [], + } as const + const v1 = { ...v0, header: { ...v0.header, version: 1 } } as const + const forwardCompatible = { + ...v1, + events: [{ type: 'plugin/future', seq: 0, time: 1, data: {}, ignorable: true }], + } as const + + expect(() => { assertReleasedV0SourceArtifact(v0) }).not.toThrow() + expect(() => { assertNormalizedReleasedV0Artifact({ + ...v0, + events: [{ type: 'feedback/record', seq: 0, time: 1, data: { text: 'retained' } }], + }) }).not.toThrow() + expect(() => { assertReleasedV1MigrationSource(v1) }).not.toThrow() + expect(() => { assertReleasedV1MigrationSource(forwardCompatible) }).not.toThrow() + expect(() => { assertReleasedV1PhysicalArtifact(v1) }).not.toThrow() + expect(() => { assertReleasedV0SourceArtifact({ + ...v0, + events: [{ + type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append', + data: { turn: 1, content: [], source: { kind: 'user' } }, + }], + }) }).not.toThrow() + expect(() => { assertReleasedV0SourceArtifact({ + ...v0, + events: [{ type: 'compact/start', seq: 0, time: 1, data: { turn: null } }], + }) }).not.toThrow() + expect(() => { assertReleasedV0SourceArtifact({ + ...v0, + events: [{ type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true }], + }) }).toThrow(/unknown historical event/) + }) + + it('permits empty Assistant provenance only under the released-v1 policy', () => { + const assistant = { + type: 'assistant/message', seq: 1, time: 2, data: {}, + sourceEventSeqs: [], surfaceOp: 'append', + } + expect(() => { assertReleasedSurfaceMetadata(assistant, 1, assistant.type, 'allow-empty-assistant') }) + .not.toThrow() + expect(() => { assertReleasedSurfaceMetadata(assistant, 1, assistant.type, 'forbid-assistant') }) + .toThrow(/obsolete chunk provenance/) + }) + it('keeps capturedFormatVersion v1-only inside session-reference sources', () => { const data = { id: 'reference', role: 'user', content: [textBlock], @@ -329,9 +390,9 @@ describe('released event and payload inventory', () => { }, } const event = { type: 'user/message', seq: 0, time: 1, data, surfaceOp: 'append' } - expect(() => sessionFormatV0ToV1.migrate(releasedV0SessionFormatCodec.decodeArtifact(v0Header, [event]))) + expect(() => restoreV0ToV1(v0Header, [event])) .toThrow(/capturedFormatVersion/) - expect(releasedV1SessionFormatCodec.decodeArtifact(v1Header, [event]).events).toEqual([event]) + expect(restoreV1(v1Header, [event]).events).toEqual([event]) }) it('accepts every released nested union variant and optional member', () => { @@ -489,6 +550,30 @@ describe('released event and payload inventory', () => { } }) + it('validates legacy round-zero goal mutation messages', () => { + const change = { + kind: 'goal/change', version: 1, operation: 'clear', + cleared: { id: 'goal', revision: 2 }, clearedAt: 3, + } + const content = [{ + type: 'text', + text: `${JSON.stringify({ cleared: change.cleared, clearedAt: change.clearedAt })}`, + }] + const message = { + id: 'legacy-goal', role: 'user', content, + source: { kind: 'goal', goalId: 'goal', revision: 2, round: 0, change }, + } + expect(() => { assertPayload('user/message', message) }).not.toThrow() + expect(() => { assertPayload('user/message', { + ...message, source: { ...message.source, round: 1 }, + }) }).toThrow(/round must be 0/) + expect(() => { assertPayload('user/message', { + ...message, source: { ...message.source, goalId: 'other' }, + }) }).toThrow(/does not match/) + expect(() => { assertPayload('user/message', { ...message, content: [textBlock] }) }) + .toThrow(/content does not match/) + }) + it('refuses malformed logical headers, cuts, event envelopes, and surface metadata', () => { const base = v1Artifact('turn/start', { turn: 1 }) const invalidHeaders = [ diff --git a/packages/session/session-format-v1-to-v2/src/codec.ts b/packages/session/session-format-v1-to-v2/src/codec.ts index 6708f8e451..7aad50c1c3 100644 --- a/packages/session/session-format-v1-to-v2/src/codec.ts +++ b/packages/session/session-format-v1-to-v2/src/codec.ts @@ -1,40 +1,42 @@ import { SessionFormatError, sessionFormatCount, - snapshotSessionFormatArtifact, + sessionFormatSafeInteger, snapshotSessionFormatJson, } from '@deepseek-ai/dsh-session-format' import type { - EncodedSessionFormatArtifact, - SessionFormatArtifact, + SessionFormatArtifactDecoder, SessionFormatCodec, + SessionFormatCurrentEncoder, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject, SessionFormatJsonValue, + SessionFormatRecovery, } from '@deepseek-ai/dsh-session-format' -import { assertReleasedV2Header, assertReleasedV2PhysicalArtifact } from './validation.ts' +import { assertReleasedV2Header } from './validation.ts' const HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const const HEADER_OPTIONAL = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const +const EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const +const EVENT_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const +const EVENT_KEYS: ReadonlySet = new Set([...EVENT_REQUIRED, ...EVENT_OPTIONAL]) /** Frozen physical JSON codec for released v2. */ export const releasedV2SessionFormatCodec = Object.freeze({ version: 2, decodeHeader(value: unknown) { return decodePhysicalHeader(value) }, - decodeArtifact(headerValue: unknown, rowValues: readonly unknown[]) { - return decodeArtifact(headerValue, rowValues, false) + createDecoder(headerValue: unknown, recovery: SessionFormatRecovery) { + return createDecoder(headerValue, recovery) }, - decodeRecoverableArtifact(headerValue: unknown, rowValues: readonly unknown[]) { - return decodeArtifact(headerValue, rowValues, true) + encodeHeader(header: SessionFormatHeader, inheritedEventCount: number) { + return encodeHeader(header, inheritedEventCount) }, - encodeArtifact(artifact: SessionFormatArtifact) { - return encodeArtifact(artifact) + encodeEvent(event: SessionFormatEvent) { + return encodeProvenance(event) }, -} satisfies SessionFormatCodec & { - encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatArtifact -}) +} satisfies SessionFormatCodec & SessionFormatCurrentEncoder) function decodePhysicalHeader(value: unknown): SessionFormatHeader { const snapshot = snapshotSessionFormatJson(value, 'released v2 physical header') @@ -70,78 +72,96 @@ function decodePhysicalHeader(value: unknown): SessionFormatHeader { return header } -function decodeArtifact( +function createDecoder( headerValue: unknown, - rowValues: readonly unknown[], - recoverable: boolean, -): SessionFormatArtifact { + recovery: SessionFormatRecovery, +): SessionFormatArtifactDecoder { const header = decodePhysicalHeader(headerValue) - const events: SessionFormatEvent[] = [] + let rowIndex = 0 + let eventCount = 0 + let inheritedEventCount: number | undefined let issue: SessionFormatError | undefined - for (const [rowIndex, value] of rowValues.entries()) { - let event: SessionFormatEvent - try { - event = decodeEvent(value, rowIndex) - } catch (error: unknown) { - const current = error instanceof SessionFormatError - ? error - : new SessionFormatError(`released v2 row ${rowIndex} is malformed`, { cause: error }) - if (!recoverable) throw current - issue ??= current - continue - } - if (issue !== undefined) { - if (event.type === 'turn/end') throw issue - continue - } - if (event.seq !== events.length) { - const gap = new SessionFormatError( - `released v2 row ${rowIndex} has seq gap (expected ${events.length}, got ${event.seq})`, - ) - if (!recoverable) throw gap - issue = gap - if (event.type === 'turn/end') throw issue - continue - } - events.push(event) + return { + header, + decodeRow(value, context) { + const currentRow = rowIndex + rowIndex += 1 + let event: SessionFormatEvent + try { + event = decodeEvent(value, currentRow) + } catch (error: unknown) { + const current = error instanceof SessionFormatError + ? error + : new SessionFormatError(`released v2 row ${currentRow} is malformed`, { cause: error }) + if (recovery === 'strict') throw current + issue ??= current + return + } + if (issue !== undefined) { + if (event.type === 'turn/end') throw issue + return + } + if (event.seq !== eventCount) { + const gap = new SessionFormatError( + `released v2 row ${currentRow} has seq gap (expected ${eventCount}, got ${event.seq})`, + ) + if (recovery === 'strict') throw gap + issue = gap + if (event.type === 'turn/end') throw issue + return + } + eventCount += 1 + if (event.type === 'session/end-seed') { + const data = jsonRecord(event.data, `session/end-seed ${event.seq} data`) + if (data['inherited'] === true) inheritedEventCount = event.seq + } + context.emitEvent(event) + }, + finish(_context) { + if (header.isSeeded && inheritedEventCount === undefined) { + throw new SessionFormatError('released v2 seeded Session lacks an inherited end-seed marker') + } + if (!header.isSeeded && inheritedEventCount !== undefined) { + throw new SessionFormatError('released v2 unseeded Session contains an inherited end-seed marker') + } + return inheritedEventCount ?? 0 + }, } - const inheritedEventCount = deriveInheritedEventCount(header, events) - const artifact = snapshotSessionFormatArtifact({ header, inheritedEventCount, events }, 'released v2 artifact') - assertReleasedV2PhysicalArtifact(artifact) - return artifact } function decodeEvent(value: unknown, rowIndex: number): SessionFormatEvent { - const snapshot = snapshotSessionFormatJson(value, `released v2 row ${rowIndex}`) - const record = jsonRecord(snapshot, `released v2 row ${rowIndex}`) + const record = jsonRecord(value as SessionFormatJsonValue, `released v2 row ${rowIndex}`) + const missing = EVENT_REQUIRED.find(key => !Object.hasOwn(record, key)) + if (missing !== undefined) throw new SessionFormatError(`released v2 row ${rowIndex} lacks required field ${missing}`) + const unexpected = Object.keys(record).find(key => !EVENT_KEYS.has(key)) + if (unexpected !== undefined) { + throw new SessionFormatError(`released v2 row ${rowIndex} has unexpected field ${unexpected}`) + } + if (typeof record['type'] !== 'string') { + throw new SessionFormatError(`released v2 row ${rowIndex} type must be a string`) + } + sessionFormatSafeInteger(record['time'], `released v2 row ${rowIndex} time`) + if (record['ignorable'] !== undefined && record['ignorable'] !== true) { + throw new SessionFormatError(`released v2 row ${rowIndex} ignorable must be true when present`) + } if (record['sourceEventSeqs'] === undefined) return record as unknown as SessionFormatEvent const seq = sessionFormatCount(record['seq'], `released v2 row ${rowIndex} seq`) - return snapshotSessionFormatJson({ + return { ...record, sourceEventSeqs: decodeSeqRanges(record['sourceEventSeqs'], seq), - }, `released v2 row ${rowIndex} provenance`) as SessionFormatEvent + } as unknown as SessionFormatEvent } -function deriveInheritedEventCount(header: SessionFormatHeader, events: readonly SessionFormatEvent[]): number { - let cut: number | undefined - for (const event of events) { - if (event.type !== 'session/end-seed') continue - const data = jsonRecord(event.data, `session/end-seed ${event.seq} data`) - if (data['inherited'] === true) cut = event.seq +function encodeHeader( + header: SessionFormatHeader, + inheritedEventCount: number, +): SessionFormatJsonObject { + assertReleasedV2Header(header) + const cut = sessionFormatCount(inheritedEventCount, 'format v2 inherited event count') + if (!header.isSeeded && cut !== 0) { + throw new SessionFormatError('unseeded format v2 Session has inherited events') } - if (header.isSeeded && cut === undefined) { - throw new SessionFormatError('released v2 seeded Session lacks an inherited end-seed marker') - } - if (!header.isSeeded && cut !== undefined) { - throw new SessionFormatError('released v2 unseeded Session contains an inherited end-seed marker') - } - return cut ?? 0 -} - -function encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatArtifact { - assertReleasedV2PhysicalArtifact(artifact) - const header = artifact.header - const physicalHeader = snapshotSessionFormatJson({ + return { type: 'session', version: 2, id: header.id, @@ -152,17 +172,15 @@ function encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatAr ...(header.origin === undefined ? {} : { origin: header.origin }), delegationDepth: header.delegationDepth, ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }), - }, 'released v2 encoded header') as SessionFormatJsonObject - const rows = Object.freeze(artifact.events.map(event => encodeProvenance(event))) - return Object.freeze({ header: physicalHeader, rows }) + } } function encodeProvenance(event: SessionFormatEvent): SessionFormatJsonObject { if (event.sourceEventSeqs === undefined) return event - return snapshotSessionFormatJson({ + return { ...event, sourceEventSeqs: encodeSeqRanges(event.sourceEventSeqs as readonly number[]), - }, `released v2 event ${event.seq} provenance`) as SessionFormatJsonObject + } } function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly number[] { diff --git a/packages/session/session-format-v1-to-v2/src/index.ts b/packages/session/session-format-v1-to-v2/src/index.ts index 98c62d4901..9e6977e0c8 100644 --- a/packages/session/session-format-v1-to-v2/src/index.ts +++ b/packages/session/session-format-v1-to-v2/src/index.ts @@ -4,4 +4,4 @@ export { releasedV1SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0 export * from './codec.ts' export * from './dispositions.ts' export * from './migration.ts' -export * from './validation.ts' +export { assertReleasedV2Header, restoreReleasedV2Artifact } from './validation.ts' diff --git a/packages/session/session-format-v1-to-v2/src/migration.ts b/packages/session/session-format-v1-to-v2/src/migration.ts index c0e0f651a1..963f78ecc5 100644 --- a/packages/session/session-format-v1-to-v2/src/migration.ts +++ b/packages/session/session-format-v1-to-v2/src/migration.ts @@ -1,33 +1,41 @@ import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm' +import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm' import { SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, - snapshotSessionFormatArtifact, } from '@deepseek-ai/dsh-session-format' import type { - SessionFormatArtifact, SessionFormatEvent, + SessionFormatEventRun, SessionFormatJsonObject, SessionFormatJsonValue, + SessionFormatHeader, + SessionFormatMigrationContext, + SessionFormatMigrationStage, + SessionFormatMigrationStageInput, } from '@deepseek-ai/dsh-session-format' import { RELEASED_V0_EVENT_DISPOSITIONS, - assertReleasedV1Artifact, + assertReleasedEventPayload, assertReleasedV1Header, + isReleasedAssistantChunkRun, } from '@deepseek-ai/dsh-session-format-v0-to-v1' -import { assertReleasedV2Artifact, assertReleasedV2Header } from './validation.ts' +import { assertReleasedV2Header } from './validation.ts' + +const CHUNK_EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const +const CHUNK_EVENT_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const +const CHUNK_EVENT_KEYS: ReadonlySet = new Set([...CHUNK_EVENT_REQUIRED, ...CHUNK_EVENT_OPTIONAL]) interface AttemptGroup { readonly turn: number readonly step: number - readonly chunks: SessionFormatEvent[] + readonly spans: Array<{ firstSeq: number; eventCount: number }> + readonly stream: Array<{ record: AssistantStreamRecord; lastTime: number }> + accumulator?: AssistantStreamAccumulator + chunkCount: number + lastChunkSeq?: number + lastChunkTime?: number terminal: boolean - messageSeq?: number -} - -interface StagedEvent { - readonly origin: number - readonly event: SessionFormatEvent } /** Adjacent migration that embeds released-v1 top-level Assistant chunks into v2 attempt events. */ @@ -39,164 +47,501 @@ export const sessionFormatV1ToV2 = defineSessionFormatMigration({ assertReleasedV1Header(header) return { ...header, version: 2 } }, - migrate(source) { - assertReleasedV1Artifact(source) - const unknown = source.events.find(event => RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined) - if (unknown !== undefined) { - throw refusal(`format v1 contains unknown event type ${JSON.stringify(unknown.type)} at seq ${unknown.seq}`) - } - const groups = collectAttemptGroups(source.events) - const groupByChunk = new Map() - const groupByMessage = new Map() - for (const group of groups) { - for (const chunk of group.chunks) groupByChunk.set(chunk.seq, group) - if (group.messageSeq !== undefined) groupByMessage.set(group.messageSeq, group) - } - - const staged: StagedEvent[] = [] - const oldToNew = new Map() - for (const sourceEvent of source.events) { - const group = groupByChunk.get(sourceEvent.seq) - if (group !== undefined) { - if (group.messageSeq === undefined && sourceEvent.seq === group.chunks.at(-1)?.seq) { - stage(staged, oldToNew, sourceEvent.seq, attemptEvent(group)) - } - continue - } - const messageGroup = groupByMessage.get(sourceEvent.seq) - if (messageGroup !== undefined) { - stage(staged, oldToNew, sourceEvent.seq, messageEvent(sourceEvent, messageGroup)) - continue - } - const event = source.header.isSeeded - && sourceEvent.seq === source.inheritedEventCount - && sourceEvent.type === 'session/end-seed' - ? { ...sourceEvent, data: { inherited: true } } - : sourceEvent - stage(staged, oldToNew, sourceEvent.seq, event) - } - - const inheritedEventCount = remapInheritedCut(source, groups, staged) - if (source.header.isSeeded && source.events[source.inheritedEventCount]?.type !== 'session/end-seed') { - const next = source.events[source.inheritedEventCount] - const previous = source.events[source.inheritedEventCount - 1] - staged.splice(inheritedEventCount, 0, { - origin: -1, - event: { - type: 'session/end-seed', - seq: inheritedEventCount, - time: next?.time ?? previous?.time ?? source.header.createdAt, - data: { inherited: true }, - }, - }) - oldToNew.clear() - for (const [seq, candidate] of staged.entries()) { - if (candidate.origin >= 0) oldToNew.set(candidate.origin, seq) - } - } - for (const group of groups) { - for (const chunk of group.chunks) oldToNew.delete(chunk.seq) - } - - const target = snapshotSessionFormatArtifact({ - header: { ...source.header, version: 2 }, - inheritedEventCount, - events: staged.map(({ event }, seq) => remapReferences(event, seq, oldToNew)), - }, 'released v1-to-v2 target') - assertReleasedV2Artifact(target) - return target + createStage(input) { + return input.sourceKind === 'decoded' + ? new DecodedReleasedV1ToV2Stage(input) + : new TransformedReleasedV1ToV2Stage(input) }, - validateTarget: assertReleasedV2Artifact, validateTargetHeader: assertReleasedV2Header, }) -function collectAttemptGroups(events: readonly SessionFormatEvent[]): readonly AttemptGroup[] { - const groups: AttemptGroup[] = [] - const current = new Map() - for (const event of events) { - if (event.type === 'assistant/chunk') { - const data = record(event.data) - const turn = coordinate(data['turn']) - const step = coordinate(data['step']) - const key = `${turn}:${step}` - let group = current.get(key) - if (group === undefined || group.terminal) { - group = { turn, step, chunks: [], terminal: false } - groups.push(group) - current.set(key, group) - } - group.chunks.push(event) - const chunk = record(data['chunk']) - if (chunk['type'] === 'finish') group.terminal = true - continue +class TransformedReleasedV1ToV2Stage implements SessionFormatMigrationStage { + private readonly state: ReleasedV1ToV2State + + constructor(input: SessionFormatMigrationStageInput) { + assertReleasedV1Header(input.sourceHeader) + this.state = { + sourceHeader: input.sourceHeader, + sourceCut: input.sourceInheritedEventCount, + mapping: new Map(), + legacyTurns: legacyTurnState(), + pending: undefined, + targetSeq: 0, + targetCut: input.sourceHeader.isSeeded ? undefined : 0, + lastTime: input.sourceHeader.createdAt, } - if (event.type !== 'assistant/message') { - closeAttemptAtBoundary(event, current) - continue - } - const data = record(event.data) - const turn = coordinate(data['turn']) - const step = coordinate(data['step']) - const sources = event.sourceEventSeqs - if (!Array.isArray(sources)) { - const unclaimed = groups.some(candidate => candidate.messageSeq === undefined - && candidate.turn === turn - && candidate.step === step) - if (unclaimed) throw refusal(`assistant/message ${event.seq} does not cite its complete v1 chunk attempt`) - groups.push({ turn, step, chunks: [], terminal: true, messageSeq: event.seq }) - continue - } - if (sources.length === 0) { - // Released v1 uses an explicit empty list to state that this message - // owns no preceding chunks; an absent list cannot make that claim. - groups.push({ turn, step, chunks: [], terminal: true, messageSeq: event.seq }) - continue - } - const group = groups.find(candidate => candidate.messageSeq === undefined - && candidate.turn === turn - && candidate.step === step - && sameNumbers(candidate.chunks.map(chunk => chunk.seq), sources)) - if (group === undefined) { - throw refusal(`assistant/message ${event.seq} chunk provenance is not one complete ordered attempt`) - } - group.messageSeq = event.seq - group.terminal = true } - return groups + + transformEvent( + event: SessionFormatEvent, + context: SessionFormatMigrationContext, + ): void { + transformReleasedEvent(this.state, event, context) + } + + transformRun( + run: SessionFormatEventRun, + context: SessionFormatMigrationContext, + ): void { + transformReleasedRun(this.state, run, context) + } + + finish(context: SessionFormatMigrationContext): number { + return finishMigration(this.state, context) + } } -function closeAttemptAtBoundary( - event: SessionFormatEvent, - current: ReadonlyMap, -): void { - if (event.type === 'turn/end') { - const data = record(event.data) - const turn = coordinate(data['turn']) - for (const group of current.values()) { - if (group.turn === turn) group.terminal = true +class DecodedReleasedV1ToV2Stage extends TransformedReleasedV1ToV2Stage { + override transformEvent( + event: SessionFormatEvent, + context: SessionFormatMigrationContext, + ): void { + if (event.type !== 'assistant/chunk' + && RELEASED_V0_EVENT_DISPOSITIONS[event.type] !== undefined) { + assertReleasedEventPayload(event, 1) } + super.transformEvent(event, context) + } +} + +interface StreamingAttempt { + readonly group: AttemptGroup + readonly afterLastChunk: SessionFormatEvent[] +} + +interface ReleasedV1ToV2State { + readonly sourceHeader: SessionFormatHeader + readonly sourceCut: number + readonly mapping: Map + readonly legacyTurns: LegacyTurnState + pending: StreamingAttempt | undefined + targetSeq: number + targetCut: number | undefined + lastTime: number +} + +function transformReleasedEvent( + state: ReleasedV1ToV2State, + event: SessionFormatEvent, + context: SessionFormatMigrationContext, +): void { + if (event.type === 'assistant/chunk') assertChunkEnvelope(event) + if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined) { + throw refusal(`format v1 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`) + } + const interrupted = legacyInterruptedTurn(state.legacyTurns, event) + if (event.type === 'turn/start' && state.legacyTurns.openTurn !== null && interrupted === undefined) { + throw refusal(`turn/start ${JSON.stringify(record(event.data)['turn'])} does not close the prior turn`) + } + assertSourceDeliveryMarker(state, event) + observeLegacyTurn(state.legacyTurns, event) + state.lastTime = event.time + if (interrupted !== undefined) { + finishAttempt(state, context) + emitGenerated(state, event.seq, interrupted, context) + } + const legacyGoal = splitLegacyGoalChange(event) + if (legacyGoal !== undefined) { + emitGenerated(state, event.seq, legacyGoal.change, context) + emitSource(state, legacyGoal.message, context) return } - if (event.type !== 'step/end' - && event.type !== 'llm/retry' - && event.type !== 'llm/retry-started') return + if (event.type === 'assistant/chunk') { + transformChunk(state, event, context) + return + } + if (event.type === 'assistant/message') { + transformMessage(state, event, context) + return + } + if (closesAttempt(event)) { + finishAttempt(state, context) + emitSource(state, event, context) + return + } + if (state.pending !== undefined) { + state.pending.afterLastChunk.push(event) + return + } + emitSource(state, event, context) +} + +function assertChunkEnvelope(event: SessionFormatEvent): void { + const unexpected = Object.keys(event).find(key => !CHUNK_EVENT_KEYS.has(key)) + if (unexpected !== undefined) throw refusal(`assistant/chunk ${event.seq} has unexpected member ${unexpected}`) + const missing = CHUNK_EVENT_REQUIRED.find(key => !Object.hasOwn(event, key)) + if (missing !== undefined) throw refusal(`assistant/chunk ${event.seq} lacks required member ${missing}`) + if (event.ignorable !== undefined && event.ignorable !== true) { + throw refusal(`assistant/chunk ${event.seq} ignorable must be true when present`) + } +} + +function assertSourceDeliveryMarker(state: ReleasedV1ToV2State, event: SessionFormatEvent): void { + if (event.type !== 'session-log-deepseek/delivery-accepted') return + const data = record(event.data) + const inherited = state.sourceHeader.parentSession !== undefined && event.seq < state.sourceCut + if (data['sessionFormatVersion'] === 1 && !inherited && data['sessionId'] !== state.sourceHeader.id) { + throw refusal('current-generation delivery marker names the wrong Session') + } +} + +function transformReleasedRun( + state: ReleasedV1ToV2State, + run: SessionFormatEventRun, + context: SessionFormatMigrationContext, +): void { + if (!isReleasedAssistantChunkRun(run)) { + for (const event of run.expand()) transformReleasedEvent(state, event, context) + return + } + state.legacyTurns.previous = undefined + state.lastTime = run.lastTime + if (state.pending !== undefined + && (state.pending.group.terminal + || state.pending.group.turn !== run.turn + || state.pending.group.step !== run.step)) { + finishAttempt(state, context) + } else if (state.pending !== undefined) { + flushBuffered(state, state.pending, context) + } + state.pending ??= { group: attemptGroup(run.turn, run.step), afterLastChunk: [] } + assertAttemptRange(state, run.firstSeq, run.lastSeq) + flushAccumulator(state.pending.group) + appendStreamRecord( + state.pending.group, + run.stream as unknown as AssistantStreamRecord, + run.lastTime, + ) + recordChunkSpan(state.pending.group, run.firstSeq, run.eventCount, run.lastTime) +} + +function finishMigration( + state: ReleasedV1ToV2State, + context: SessionFormatMigrationContext, +): number { + finishAttempt(state, context) + if (state.sourceHeader.isSeeded && state.targetCut === undefined) { + state.targetCut = state.targetSeq + context.emitEvent({ + type: 'session/end-seed', + seq: state.targetSeq, + time: state.lastTime, + data: { inherited: true }, + }) + state.targetSeq += 1 + } + return state.targetCut as number +} + +function transformChunk( + state: ReleasedV1ToV2State, + event: SessionFormatEvent, + context: SessionFormatMigrationContext, +): void { const data = record(event.data) const turn = coordinate(data['turn']) const step = coordinate(data['step']) - const group = current.get(`${turn}:${step}`) - if (group !== undefined) group.terminal = true + const chunk = record(data['chunk']) + if (state.pending !== undefined + && (state.pending.group.terminal + || state.pending.group.turn !== turn + || state.pending.group.step !== step)) { + finishAttempt(state, context) + } else if (state.pending !== undefined) { + flushBuffered(state, state.pending, context) + } + state.pending ??= { group: attemptGroup(turn, step), afterLastChunk: [] } + assertAttemptCut(state, state.pending.group, event.seq) + state.pending.group.accumulator ??= new AssistantStreamAccumulator() + state.pending.group.accumulator.push({ + time: event.time, + chunk: data['chunk'] as Parameters[0]['chunk'], + }) + recordChunkSpan(state.pending.group, event.seq, 1, event.time) + if (chunk['type'] === 'finish') state.pending.group.terminal = true +} + +function transformMessage( + state: ReleasedV1ToV2State, + event: SessionFormatEvent, + context: SessionFormatMigrationContext, +): void { + const data = record(event.data) + const turn = coordinate(data['turn']) + const step = coordinate(data['step']) + const sources = event.sourceEventSeqs + const pending = state.pending + if (pending !== undefined && (pending.group.turn !== turn || pending.group.step !== step)) { + finishAttempt(state, context) + emitSource(state, messageEvent(event, attemptGroup(turn, step)), context) + return + } + if (!Array.isArray(sources)) { + if (pending !== undefined) { + throw refusal(`assistant/message ${event.seq} does not cite its complete v1 chunk attempt`) + } + emitSource(state, messageEvent(event, attemptGroup(turn, step)), context) + return + } + if (sources.length === 0) { + finishAttempt(state, context) + emitSource(state, messageEvent(event, attemptGroup(turn, step)), context) + return + } + if (pending === undefined + || !matchesChunkSources(pending.group, sources)) { + throw refusal(`assistant/message ${event.seq} chunk provenance is not one complete ordered attempt`) + } + assertAttemptCut(state, pending.group, event.seq) + pending.group.terminal = true + flushBuffered(state, pending, context) + emitSource(state, messageEvent(event, pending.group), context) + state.pending = undefined +} + +function finishAttempt( + state: ReleasedV1ToV2State, + context: SessionFormatMigrationContext, +): void { + const pending = state.pending + if (pending === undefined) return + emitGenerated(state, pending.group.lastChunkSeq as number, attemptEvent(pending.group), context) + flushBuffered(state, pending, context) + state.pending = undefined +} + +function flushBuffered( + state: ReleasedV1ToV2State, + pending: StreamingAttempt, + context: SessionFormatMigrationContext, +): void { + for (const event of pending.afterLastChunk) emitSource(state, event, context) + pending.afterLastChunk.length = 0 +} + +function emitSource( + state: ReleasedV1ToV2State, + event: SessionFormatEvent, + context: SessionFormatMigrationContext, +): void { + let source = event + if (state.sourceHeader.isSeeded + && event.seq === state.sourceCut + && event.type === 'session/end-seed') { + source = { ...event, data: { inherited: true } } + } + ensureTargetCut(state, event.seq, event.time, source.type, context) + state.mapping.set(event.seq, state.targetSeq) + context.emitEvent(remapReferences(source, state.targetSeq, state.mapping)) + state.targetSeq += 1 +} + +function emitGenerated( + state: ReleasedV1ToV2State, + origin: number, + event: SessionFormatEvent, + context: SessionFormatMigrationContext, +): void { + ensureTargetCut(state, origin, event.time, event.type, context) + context.emitEvent(remapReferences(event, state.targetSeq, state.mapping)) + state.targetSeq += 1 +} + +function ensureTargetCut( + state: ReleasedV1ToV2State, + origin: number, + time: number, + type: string, + context: SessionFormatMigrationContext, +): void { + if (!state.sourceHeader.isSeeded || state.targetCut !== undefined || origin < state.sourceCut) return + state.targetCut = state.targetSeq + if (origin === state.sourceCut && type === 'session/end-seed') return + context.emitEvent({ + type: 'session/end-seed', + seq: state.targetSeq, + time, + data: { inherited: true }, + }) + state.targetSeq += 1 +} + +function assertAttemptCut(state: ReleasedV1ToV2State, group: AttemptGroup, member: number): void { + const first = group.spans[0]?.firstSeq ?? member + if ((first < state.sourceCut) !== (member < state.sourceCut)) { + throw refusal(`inherited Session cut ${state.sourceCut} splits one Assistant attempt`) + } +} + +function assertAttemptRange(state: ReleasedV1ToV2State, first: number, last: number): void { + if ((first < state.sourceCut) !== (last < state.sourceCut)) { + throw refusal(`inherited Session cut ${state.sourceCut} splits one Assistant attempt`) + } +} + +interface LegacyTurnState { + openTurn: number | null + openStep: number | null + previous: SessionFormatEvent | undefined +} + +function legacyTurnState(): LegacyTurnState { + return { openTurn: null, openStep: null, previous: undefined } +} + +function legacyInterruptedTurn( + state: LegacyTurnState, + event: SessionFormatEvent, +): SessionFormatEvent | undefined { + if (event.type !== 'turn/start' || state.openTurn === null || state.openStep !== null + || coordinate(record(event.data)['turn']) !== state.openTurn + 1 + || state.previous?.type !== 'agent/inbox/spliced') return undefined + const splice = record(state.previous.data) + if (splice['target'] !== 'next-turn' || !Array.isArray(splice['inserted']) || splice['inserted'].length === 0) { + return undefined + } + return { + type: 'turn/end', + seq: event.seq, + time: event.time, + data: { turn: state.openTurn, reason: { kind: 'interrupted' } }, + } +} + +function observeLegacyTurn(state: LegacyTurnState, event: SessionFormatEvent): void { + const data = record(event.data) + if (event.type === 'turn/start') { + state.openTurn = coordinate(data['turn']) + state.openStep = null + } else if (event.type === 'turn/end') { + state.openTurn = null + state.openStep = null + } else if (event.type === 'step/start') { + state.openStep = coordinate(data['step']) + } else if (event.type === 'step/end') { + state.openStep = null + } + state.previous = event +} + +function splitLegacyGoalChange(event: SessionFormatEvent): { + readonly change: SessionFormatEvent + readonly message: SessionFormatEvent +} | undefined { + if (event.type !== 'user/message') return undefined + const data = record(event.data) + const source = record(data['source']) + if (source['kind'] !== 'goal' || source['change'] === undefined) return undefined + return { + change: { + type: 'goal/change', + seq: event.seq, + time: event.time, + data: source['change'], + }, + message: { + ...event, + data: { ...data, source: { kind: 'plugin', plugin: 'goal' } }, + }, + } +} + +function closesAttempt(event: SessionFormatEvent): boolean { + return event.type === 'turn/end' + || event.type === 'step/end' + || event.type === 'llm/retry' + || event.type === 'llm/retry-started' +} + +function attemptGroup(turn: number, step: number): AttemptGroup { + return { turn, step, spans: [], stream: [], chunkCount: 0, terminal: false } +} + +function recordChunkSpan(group: AttemptGroup, firstSeq: number, eventCount: number, lastTime: number): void { + const previous = group.spans.at(-1) + if (previous !== undefined && previous.firstSeq + previous.eventCount === firstSeq) { + previous.eventCount += eventCount + } else { + group.spans.push({ firstSeq, eventCount }) + } + group.chunkCount += eventCount + group.lastChunkSeq = firstSeq + eventCount - 1 + group.lastChunkTime = lastTime +} + +function matchesChunkSources(group: AttemptGroup, sources: readonly unknown[]): boolean { + if (sources.length !== group.chunkCount) return false + let index = 0 + for (const span of group.spans) { + for (let offset = 0; offset < span.eventCount; offset += 1) { + if (sources[index] !== span.firstSeq + offset) return false + index += 1 + } + } + return true +} + +function recordLastTime(record: AssistantStreamRecord): number { + if (record.type === 'chunk') return record.time + return record.dt.reduce((time, gap) => time + gap, record.time0) +} + +function mutableRecord(record: AssistantStreamRecord): AssistantStreamRecord { + if (record.type === 'chunk') return record + if (record.type === 'tool-call-chunks') { + return { ...record, dt: [...record.dt], args: [...record.args] } + } + return { ...record, dt: [...record.dt], texts: [...record.texts] } +} + +function appendStreamRecord( + group: AttemptGroup, + source: AssistantStreamRecord, + lastTime: number, + owned = true, +): void { + const previous = group.stream.at(-1) + if (previous === undefined || source.type === 'chunk' || previous.record.type !== source.type) { + group.stream.push({ record: owned ? source : mutableRecord(source), lastTime }) + return + } + const gap = source.time0 - previous.lastTime + if (previous.record.index !== source.index || !Number.isSafeInteger(gap)) { + group.stream.push({ record: owned ? source : mutableRecord(source), lastTime }) + return + } + if (source.type === 'tool-call-chunks') { + const target = previous.record as Extract + if (target.id !== source.id || target.name !== source.name) { + group.stream.push({ record: owned ? source : mutableRecord(source), lastTime }) + return + } + ;(target.dt as number[]).push(gap) + for (const value of source.dt) (target.dt as number[]).push(value) + for (const value of source.args) (target.args as string[]).push(value) + } else { + const target = previous.record as Extract + ;(target.dt as number[]).push(gap) + for (const value of source.dt) (target.dt as number[]).push(value) + for (const value of source.texts) (target.texts as string[]).push(value) + } + previous.lastTime = lastTime +} + +function flushAccumulator(group: AttemptGroup): void { + const accumulator = group.accumulator + if (accumulator === undefined) return + for (const record of accumulator.snapshot()) { + appendStreamRecord(group, record, recordLastTime(record), false) + } + delete group.accumulator } function streamOf(group: AttemptGroup) { - const accumulator = new AssistantStreamAccumulator() - for (const event of group.chunks) { - const data = record(event.data) - accumulator.push({ - time: event.time, - chunk: data['chunk'] as Parameters[0]['chunk'], - }) - } - return accumulator.snapshot() as unknown as SessionFormatJsonValue + flushAccumulator(group) + return group.stream.map(({ record }) => record) as unknown as SessionFormatJsonValue } function messageEvent(source: SessionFormatEvent, group: AttemptGroup): SessionFormatEvent { @@ -209,42 +554,14 @@ function messageEvent(source: SessionFormatEvent, group: AttemptGroup): SessionF } function attemptEvent(group: AttemptGroup): SessionFormatEvent { - const last = group.chunks.at(-1) as SessionFormatEvent return { type: 'assistant/attempt', - seq: last.seq, - time: last.time, + seq: group.lastChunkSeq as number, + time: group.lastChunkTime as number, data: { turn: group.turn, step: group.step, stream: streamOf(group) }, } } -function stage( - staged: StagedEvent[], - oldToNew: Map, - origin: number, - event: SessionFormatEvent, -): void { - oldToNew.set(origin, staged.length) - staged.push({ origin, event }) -} - -function remapInheritedCut( - source: SessionFormatArtifact, - groups: readonly AttemptGroup[], - staged: readonly StagedEvent[], -): number { - const cut = source.inheritedEventCount - for (const group of groups) { - const members = group.messageSeq === undefined - ? group.chunks.map(chunk => chunk.seq) - : [...group.chunks.map(chunk => chunk.seq), group.messageSeq] - const before = members.some(seq => seq < cut) - const after = members.some(seq => seq >= cut) - if (before && after) throw refusal(`inherited Session cut ${cut} splits one Assistant attempt`) - } - return staged.filter(candidate => candidate.origin < cut).length -} - function remapReferences( source: SessionFormatEvent, targetSeq: number, @@ -360,7 +677,6 @@ function mapOne( return mapped } -/* assertReleasedV1Artifact validates these payload coordinates before migration. */ function record(value: SessionFormatJsonValue | undefined): SessionFormatJsonObject { return value as SessionFormatJsonObject } @@ -373,10 +689,6 @@ function coordinate(value: SessionFormatJsonValue | undefined): number { return value as number } -function sameNumbers(left: readonly number[], right: readonly number[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]) -} - function refusal(message: string): SessionFormatUnsupportedMigrationError { return new SessionFormatUnsupportedMigrationError(message) } diff --git a/packages/session/session-format-v1-to-v2/src/testing/validation.ts b/packages/session/session-format-v1-to-v2/src/testing/validation.ts new file mode 100644 index 0000000000..aac6176da3 --- /dev/null +++ b/packages/session/session-format-v1-to-v2/src/testing/validation.ts @@ -0,0 +1,105 @@ +import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm' +import { deepEqualJson } from '@deepseek-ai/dsh-util-values' +import { + SessionFormatError, + SessionFormatUnsupportedMigrationError, + sessionFormatCount, + snapshotSessionFormatJson, +} from '@deepseek-ai/dsh-session-format' +import type { + SessionFormatArtifact, + SessionFormatEvent, + SessionFormatJsonValue, +} from '@deepseek-ai/dsh-session-format' +import { + assertReleasedPayloadSemantics, + assertReleasedSurfaceMetadata, +} from '@deepseek-ai/dsh-session-format-v0-to-v1' +import { RELEASED_V2_EVENT_DISPOSITIONS, RELEASED_V2_EVENT_TYPES } from '../dispositions.ts' +import { + assertReleasedV2Keys, + assertReleasedV2PhysicalArtifact, + releasedV2Record, + restoreReleasedV2Artifact, +} from '../validation.ts' + +const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES) +const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']) + +/** + * Validate the frozen released-v2 writer image in tests. + * @param artifact - released-v2 logical artifact. + */ +export function assertReleasedV2Artifact(artifact: SessionFormatArtifact): void { + assertReleasedV2PhysicalArtifact(artifact) + for (const [index, event] of artifact.events.entries()) { + const disposition = RELEASED_V2_EVENT_DISPOSITIONS[event.type] + if (disposition === undefined) { + throw new SessionFormatUnsupportedMigrationError( + `format v2 contains unknown event type ${JSON.stringify(event.type)} at seq ${index}`, + ) + } + if (SURFACE_TYPES.has(event.type)) { + assertReleasedSurfaceMetadata(event, index, event.type, 'forbid-assistant') + } + assertPayload(event, disposition) + } + restoreReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET) +} + +export { assertReleasedV2PhysicalArtifact } from '../validation.ts' + +function assertPayload( + event: SessionFormatEvent, + disposition: (typeof RELEASED_V2_EVENT_DISPOSITIONS)[string], +): void { + const data = releasedV2Record(event.data, `${event.type} ${event.seq} data`) + assertReleasedV2Keys(data, disposition.required, disposition.optional, `${event.type} ${event.seq} data`) + for (const key of disposition.opaque) { + if (Object.hasOwn(data, key)) snapshotSessionFormatJson(data[key], `${event.type} ${event.seq} opaque ${key}`) + } + if (event.type === 'assistant/attempt' || event.type === 'assistant/message') { + const turn = sessionFormatCount(data['turn'], `${event.type} ${event.seq} turn`) + const step = sessionFormatCount(data['step'], `${event.type} ${event.seq} step`) + const assembler = new BlockAssembler() + let timed: ReturnType + try { + timed = expandAssistantStream(data['stream'] as never) + for (const member of timed) { + assertReleasedPayloadSemantics({ + type: 'assistant/chunk', + seq: event.seq, + time: member.time, + data: { turn, step, chunk: member.chunk } as unknown as SessionFormatJsonValue, + }, 2) + assembler.push(member.chunk) + } + } catch (error: unknown) { + throw new SessionFormatError(`${event.type} ${event.seq} has an invalid embedded stream`, { cause: error }) + } + if (event.type === 'assistant/attempt') return + assertReleasedPayloadSemantics(event, 2) + if (timed.length > 0) { + const message = releasedV2Record(data['message'], `assistant/message ${event.seq} message`) + const content = data['interrupted'] === true ? assembler.interruptedBlocks() : assembler.blocks() + if (!deepEqualJson(message['content'], content)) { + throw new SessionFormatError(`assistant/message ${event.seq} message content disagrees with its embedded stream`) + } + if (!deepEqualJson(data['usage'], assembler.usage)) { + throw new SessionFormatError(`assistant/message ${event.seq} usage disagrees with its embedded stream`) + } + const source = releasedV2Record(message['source'], `assistant/message ${event.seq} source`) + if (!deepEqualJson(source['replayState'], assembler.replayState)) { + throw new SessionFormatError(`assistant/message ${event.seq} replay state disagrees with its embedded stream`) + } + } + return + } + if (event.type === 'session/end-seed') { + if (data['inherited'] !== undefined && data['inherited'] !== true) { + throw new SessionFormatError(`session/end-seed ${event.seq} inherited must be true when present`) + } + return + } + assertReleasedPayloadSemantics(event, 2) +} diff --git a/packages/session/session-format-v1-to-v2/src/validation.ts b/packages/session/session-format-v1-to-v2/src/validation.ts index 5ad9b48d88..bf8b73188f 100644 --- a/packages/session/session-format-v1-to-v2/src/validation.ts +++ b/packages/session/session-format-v1-to-v2/src/validation.ts @@ -1,26 +1,18 @@ import { isAbsolute } from 'node:path' -import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import { deepEqualJson } from '@deepseek-ai/dsh-util-values' import { SessionFormatError, SessionFormatUnsupportedMigrationError, sessionFormatCount, sessionFormatSafeInteger, - snapshotSessionFormatJson, } from '@deepseek-ai/dsh-session-format' import type { SessionFormatArtifact, - SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject, SessionFormatJsonValue, } from '@deepseek-ai/dsh-session-format' -import { - assertReleasedArtifactRelationships, - assertReleasedPayloadSemantics, - assertReleasedSurfaceMetadata, -} from '@deepseek-ai/dsh-session-format-v0-to-v1' -import { RELEASED_V2_EVENT_DISPOSITIONS, RELEASED_V2_EVENT_TYPES } from './dispositions.ts' +import { assertReleasedArtifactRelationships } from '@deepseek-ai/dsh-session-format-v0-to-v1' +import { RELEASED_V2_EVENT_DISPOSITIONS } from './dispositions.ts' const HEADER_REQUIRED = ['version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const const HEADER_OPTIONAL = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const @@ -28,7 +20,6 @@ const EVENT_REQUIRED = ['type', 'seq', 'time', 'data'] as const const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']) const SURFACE_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const const LOG_OPTIONAL = ['ignorable'] as const -const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES) const RELEASED_V2_RELATIONSHIP_EXTENSIONS = { stepEvents: new Set(['assistant/attempt']), preservedSourceTitleRequestText: true, @@ -40,8 +31,8 @@ const RELEASED_V2_RELATIONSHIP_EXTENSIONS = { * @throws {SessionFormatError} when the header is not an exact released-v2 value. */ export function assertReleasedV2Header(header: SessionFormatHeader): void { - const record = jsonRecord(header, 'format v2 header') - exactKeys(record, HEADER_REQUIRED, HEADER_OPTIONAL, 'format v2 header') + const record = releasedV2Record(header, 'format v2 header') + assertReleasedV2Keys(record, HEADER_REQUIRED, HEADER_OPTIONAL, 'format v2 header') if (record['version'] !== 2) throw new SessionFormatError('expected format v2 header') if (typeof record['id'] !== 'string') throw new SessionFormatError('format v2 header id must be a string') sessionFormatCount(record['createdAt'], 'format v2 header createdAt') @@ -60,28 +51,9 @@ export function assertReleasedV2Header(header: SessionFormatHeader): void { } } -/** - * Validate the exact logical image emitted by the released v2 writer. - * @param artifact - complete decoded released-v2 Session artifact. - * @throws {SessionFormatError} when an envelope, payload, relationship, or inherited cut is invalid. - * @throws {SessionFormatUnsupportedMigrationError} when the artifact contains an unknown event type. - */ -export function assertReleasedV2Artifact(artifact: SessionFormatArtifact): void { - validateReleasedV2Artifact(artifact, 'target', RELEASED_V2_EVENT_TYPE_SET) -} - -/** - * Validate only the released-v2 physical header, event envelopes, and inherited cut. - * Event vocabulary and payload semantics belong to target or installed-current restoration. - * @param artifact - complete physical-codec output. - */ -export function assertReleasedV2PhysicalArtifact(artifact: SessionFormatArtifact): void { - validateReleasedV2Artifact(artifact, 'physical') -} - function validateReleasedV2Artifact( artifact: SessionFormatArtifact, - mode: 'target' | 'current' | 'physical', + mode: 'current' | 'physical', knownEventTypes?: ReadonlySet, ): void { assertReleasedV2Header(artifact.header) @@ -90,15 +62,13 @@ function validateReleasedV2Artifact( if (!artifact.header.isSeeded && cut !== 0) throw new SessionFormatError('unseeded format v2 Session has inherited events') let lastInheritedMarker: number | undefined for (const [index, event] of artifact.events.entries()) { - const record = jsonRecord(event, `format v2 event ${index}`) + const record = releasedV2Record(event, `format v2 event ${index}`) const type = record['type'] if (typeof type !== 'string') throw new SessionFormatError(`format v2 event ${index} type must be a string`) const disposition = RELEASED_V2_EVENT_DISPOSITIONS[type] const installed = knownEventTypes?.has(type) === true - const ignorableUnknown = disposition === undefined - && mode === 'current' - && record['ignorable'] === true - if (mode !== 'physical' && disposition === undefined && !installed && !ignorableUnknown) { + const ignorableUnknown = disposition === undefined && record['ignorable'] === true + if (mode === 'current' && disposition === undefined && !installed && !ignorableUnknown) { throw new SessionFormatUnsupportedMigrationError( `format v2 contains unknown event type ${JSON.stringify(type)} at seq ${index}`, ) @@ -107,16 +77,14 @@ function validateReleasedV2Artifact( const optional = mode === 'physical' || disposition === undefined ? SURFACE_OPTIONAL : surface ? SURFACE_OPTIONAL : LOG_OPTIONAL - exactKeys(record, EVENT_REQUIRED, optional, `format v2 event ${index}`) + assertReleasedV2Keys(record, EVENT_REQUIRED, optional, `format v2 event ${index}`) if (record['seq'] !== index) throw new SessionFormatError(`format v2 event ${index} is not dense`) sessionFormatSafeInteger(record['time'], `format v2 event ${index} time`) if (record['ignorable'] !== undefined && record['ignorable'] !== true) { throw new SessionFormatError(`format v2 event ${index} ignorable must be true when present`) } - if (mode === 'target' && surface) assertReleasedSurfaceMetadata(record, index, type, 'forbid-assistant') - if (mode === 'target' && disposition !== undefined) assertPayload(event, disposition) if (type === 'session/end-seed') { - const data = jsonRecord(event.data, `session/end-seed ${index} data`) + const data = releasedV2Record(event.data, `session/end-seed ${index} data`) if (data['inherited'] === true) lastInheritedMarker = index } } @@ -126,74 +94,35 @@ function validateReleasedV2Artifact( if (!artifact.header.isSeeded && lastInheritedMarker !== undefined) { throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker') } - if (mode === 'target') { + if (mode === 'current') { assertReleasedArtifactRelationships(artifact, RELEASED_V2_RELATIONSHIP_EXTENSIONS) } } -function assertPayload( - event: SessionFormatEvent, - disposition: (typeof RELEASED_V2_EVENT_DISPOSITIONS)[string], -): void { - const data = jsonRecord(event.data, `${event.type} ${event.seq} data`) - exactKeys(data, disposition.required, disposition.optional, `${event.type} ${event.seq} data`) - for (const key of disposition.opaque) { - if (Object.hasOwn(data, key)) snapshotSessionFormatJson(data[key], `${event.type} ${event.seq} opaque ${key}`) - } - if (event.type === 'assistant/attempt' || event.type === 'assistant/message') { - const turn = sessionFormatCount(data['turn'], `${event.type} ${event.seq} turn`) - const step = sessionFormatCount(data['step'], `${event.type} ${event.seq} step`) - const assembler = new BlockAssembler() - let timed: ReturnType - try { - timed = expandAssistantStream(data['stream'] as never) - for (const member of timed) { - assertReleasedPayloadSemantics({ - type: 'assistant/chunk', - seq: event.seq, - time: member.time, - data: { turn, step, chunk: member.chunk } as unknown as SessionFormatJsonValue, - }, 2) - assembler.push(member.chunk) - } - } catch (error: unknown) { - throw new SessionFormatError(`${event.type} ${event.seq} has an invalid embedded stream`, { cause: error }) - } - if (event.type === 'assistant/attempt') return - assertReleasedPayloadSemantics(event, 2) - if (timed.length > 0) { - const message = jsonRecord(data['message'], `assistant/message ${event.seq} message`) - const content = data['interrupted'] === true ? assembler.interruptedBlocks() : assembler.blocks() - if (!deepEqualJson(message['content'], content)) { - throw new SessionFormatError(`assistant/message ${event.seq} message content disagrees with its embedded stream`) - } - if (!deepEqualJson(data['usage'], assembler.usage)) { - throw new SessionFormatError(`assistant/message ${event.seq} usage disagrees with its embedded stream`) - } - const source = jsonRecord(message['source'], `assistant/message ${event.seq} source`) - if (!deepEqualJson(source['replayState'], assembler.replayState)) { - throw new SessionFormatError(`assistant/message ${event.seq} replay state disagrees with its embedded stream`) - } - } - return - } - if (event.type === 'session/end-seed') { - if (data['inherited'] !== undefined && data['inherited'] !== true) { - throw new SessionFormatError(`session/end-seed ${event.seq} inherited must be true when present`) - } - return - } - assertReleasedPayloadSemantics(event, 2) -} - -function jsonRecord(value: SessionFormatJsonValue | undefined, label: string): SessionFormatJsonObject { +/** + * Require one released-v2 value to be a JSON object. + * @param value - value to narrow. + * @param label - diagnostic subject. + * @returns the narrowed object. + */ +export function releasedV2Record( + value: SessionFormatJsonValue | undefined, + label: string, +): SessionFormatJsonObject { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new SessionFormatError(`${label} must be an object`) } return value as SessionFormatJsonObject } -function exactKeys( +/** + * Require one released-v2 object to contain exactly the admitted keys. + * @param value - object to inspect. + * @param required - keys that must be present. + * @param optional - additional keys that may be present. + * @param label - diagnostic subject. + */ +export function assertReleasedV2Keys( value: SessionFormatJsonObject, required: readonly string[], optional: readonly string[], @@ -219,3 +148,11 @@ export function restoreReleasedV2Artifact( validateReleasedV2Artifact(artifact, 'current', knownEventTypes) return artifact } + +/** + * Validate the released-v2 physical envelope without interpreting event vocabulary. + * @param artifact - released-v2 physical artifact. + */ +export function assertReleasedV2PhysicalArtifact(artifact: SessionFormatArtifact): void { + validateReleasedV2Artifact(artifact, 'physical') +} diff --git a/packages/session/session-format-v1-to-v2/tests/codec.spec.ts b/packages/session/session-format-v1-to-v2/tests/codec.spec.ts index bd9032d2e4..bb1d37b075 100644 --- a/packages/session/session-format-v1-to-v2/tests/codec.spec.ts +++ b/packages/session/session-format-v1-to-v2/tests/codec.spec.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' +import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format' import type { SessionFormatArtifact, + SessionFormatArtifactDecoder, SessionFormatEvent, SessionFormatJsonObject, + SessionFormatRecovery, } from '@deepseek-ai/dsh-session-format' import { decodeSeqRanges as decodeCurrentSeqRanges } from '@deepseek-ai/dsh-session' import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2' @@ -21,6 +24,10 @@ const fullPhysicalHeader = { const textBlock = { type: 'text', text: 'text' } as const +function throwUnknown(value: unknown): never { + throw value +} + function feedback(seq: number): SessionFormatEvent { return { type: 'feedback/record', seq, time: seq + 1, data: { text: `feedback-${seq}` } } } @@ -50,6 +57,27 @@ function artifact( } } +function decodeV2( + header: unknown, + rows: readonly unknown[], + recovery: SessionFormatRecovery = 'strict', +): SessionFormatArtifact { + const decoder: SessionFormatArtifactDecoder = releasedV2SessionFormatCodec.createDecoder(header, recovery) + const context = new SessionFormatEventCollector() + for (const row of rows) decoder.decodeRow(row, context) + return { header: decoder.header, inheritedEventCount: decoder.finish(context), events: context.values } +} + +function encodeV2(source: SessionFormatArtifact): { + readonly header: SessionFormatJsonObject + readonly rows: readonly SessionFormatJsonObject[] +} { + return { + header: releasedV2SessionFormatCodec.encodeHeader(source.header, source.inheritedEventCount), + rows: source.events.map(event => releasedV2SessionFormatCodec.encodeEvent(event)), + } +} + describe('releasedV2SessionFormatCodec headers', () => { it('round-trips the minimal and complete optional header images', () => { expect(releasedV2SessionFormatCodec.version).toBe(2) @@ -68,7 +96,7 @@ describe('releasedV2SessionFormatCodec headers', () => { agentPreset: 'default', }) - const encodedMinimal = releasedV2SessionFormatCodec.encodeArtifact(artifact([])) + const encodedMinimal = encodeV2(artifact([])) expect(encodedMinimal).toStrictEqual({ header: minimalPhysicalHeader, rows: [] }) const complete = artifact([], { header: { @@ -83,8 +111,12 @@ describe('releasedV2SessionFormatCodec headers', () => { agentPreset: 'default', }, }) - expect(releasedV2SessionFormatCodec.encodeArtifact(complete).header) + expect(encodeV2(complete).header) .toStrictEqual(fullPhysicalHeader) + expect(() => releasedV2SessionFormatCodec.encodeHeader( + artifact([]).header, + 1, + )).toThrow(/unseeded.*inherited events/) }) it.each([ @@ -117,14 +149,14 @@ describe('releasedV2SessionFormatCodec rows', () => { userMessage(2, [0, 1]), userMessage(3, [0, 1, 2]), ]) - const encoded = releasedV2SessionFormatCodec.encodeArtifact(source) + const encoded = encodeV2(source) expect(encoded.rows.map(row => row['sourceEventSeqs'])).toStrictEqual([ undefined, [0], [0, 1], [[0, 2]], ]) - expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source) + expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source) }) it('keeps non-monotonic provenance scalar-only for the current backend reader', () => { @@ -134,12 +166,12 @@ describe('releasedV2SessionFormatCodec rows', () => { userMessage(6, sourceEventSeqs), ]) - const encoded = releasedV2SessionFormatCodec.encodeArtifact(source) + const encoded = encodeV2(source) const stored = encoded.rows[6]?.['sourceEventSeqs'] expect(stored).toStrictEqual(sourceEventSeqs) expect(decodeCurrentSeqRanges(stored, 6)).toStrictEqual(sourceEventSeqs) - expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source) + expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source) }) it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => { @@ -149,9 +181,9 @@ describe('releasedV2SessionFormatCodec rows', () => { { type: 'turn/start', seq: 2, time: 3, data: { turn: 1, postReleaseMember: true } }, ]) - const encoded = releasedV2SessionFormatCodec.encodeArtifact(source) + const encoded = encodeV2(source) - expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source) + expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source) }) it('expands mixed stored ranges and preserves non-provenance rows', () => { @@ -159,10 +191,10 @@ describe('releasedV2SessionFormatCodec rows', () => { ...userMessage(5), sourceEventSeqs: [[0, 2], 4], }] - const decoded = releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, rows) + const decoded = decodeV2(minimalPhysicalHeader, rows) expect(decoded.events[0]).toStrictEqual(feedback(0)) expect(decoded.events[5]?.sourceEventSeqs).toStrictEqual([0, 1, 2, 4]) - const descending = releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [ + const descending = decodeV2(minimalPhysicalHeader, [ feedback(0), feedback(1), { ...userMessage(2), sourceEventSeqs: [1, 0] }, ]) expect(descending.events[2]?.sourceEventSeqs).toStrictEqual([1, 0]) @@ -185,42 +217,61 @@ describe('releasedV2SessionFormatCodec rows', () => { const rows = [feedback(0), feedback(1), feedback(2), feedback(3), { ...userMessage(4), sourceEventSeqs, }] - expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, rows)).toThrow(message) + expect(() => decodeV2(minimalPhysicalHeader, rows)).toThrow(message) }) it('contains ordinary and non-SessionFormatError row failures in a recoverable tail', () => { - const explosive = new Proxy({}, { ownKeys: () => { throw new Error('proxy failure') } }) - expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact( + const explosive = new Proxy(feedback(1), { ownKeys: () => { throw new Error('proxy failure') } }) + const primitiveFailure = new Proxy(feedback(1), { + ownKeys: () => throwUnknown('primitive proxy failure'), + }) + expect(decodeV2( minimalPhysicalHeader, [feedback(0), explosive], + 'recoverable', ).events).toStrictEqual([feedback(0)]) - expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact( + expect(decodeV2( minimalPhysicalHeader, [feedback(0), null], + 'recoverable', ).events).toStrictEqual([feedback(0)]) - expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact( + expect(decodeV2( + minimalPhysicalHeader, + [feedback(0), primitiveFailure], + 'recoverable', + ).events).toStrictEqual([feedback(0)]) + expect(decodeV2( minimalPhysicalHeader, [null, [], feedback(0)], + 'recoverable', ).events).toStrictEqual([]) }) it('refuses malformed strict rows, strict gaps, and terminal recoverable tails', () => { - expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [null])) + expect(() => decodeV2(minimalPhysicalHeader, [null])) .toThrow(/row 0/) - expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [feedback(1)])) + expect(() => decodeV2(minimalPhysicalHeader, [feedback(1)])) .toThrow(/seq gap/) - expect(() => releasedV2SessionFormatCodec.decodeRecoverableArtifact(minimalPhysicalHeader, [{ + expect(() => decodeV2(minimalPhysicalHeader, [{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, - }])).toThrow(/seq gap/) - expect(() => releasedV2SessionFormatCodec.decodeRecoverableArtifact(minimalPhysicalHeader, [null, { + }], 'recoverable')).toThrow(/seq gap/) + expect(() => decodeV2(minimalPhysicalHeader, [null, { type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, - }])).toThrow(/row 0/) + }], 'recoverable')).toThrow(/row 0/) + expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), extra: true }])) + .toThrow(/unexpected field extra/) + expect(() => decodeV2(minimalPhysicalHeader, [{ type: 'feedback/record', seq: 0, time: 1 }])) + .toThrow(/lacks required field data/) + expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), type: 1 }])) + .toThrow(/type must be a string/) + expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), ignorable: false }])) + .toThrow(/ignorable must be true/) }) it('keeps the prefix before a recoverable non-terminal seq gap', () => { - expect(releasedV2SessionFormatCodec.decodeRecoverableArtifact(minimalPhysicalHeader, [ + expect(decodeV2(minimalPhysicalHeader, [ feedback(0), feedback(3), feedback(1), - ]).events).toStrictEqual([feedback(0)]) + ], 'recoverable').events).toStrictEqual([feedback(0)]) }) it('derives the last tagged seed marker and rejects lineage disagreements', () => { @@ -230,13 +281,13 @@ describe('releasedV2SessionFormatCodec rows', () => { { type: 'session/end-seed', seq: 1, time: 2, data: {} }, { type: 'session/end-seed', seq: 2, time: 3, data: { inherited: true } }, ] - expect(releasedV2SessionFormatCodec.decodeArtifact(seededHeader, markers).inheritedEventCount).toBe(2) - expect(releasedV2SessionFormatCodec.decodeArtifact(seededHeader, [markers[0]]).inheritedEventCount).toBe(0) - expect(() => releasedV2SessionFormatCodec.decodeArtifact(seededHeader, [])) + expect(decodeV2(seededHeader, markers).inheritedEventCount).toBe(2) + expect(decodeV2(seededHeader, [markers[0]]).inheritedEventCount).toBe(0) + expect(() => decodeV2(seededHeader, [])) .toThrow(/lacks an inherited end-seed marker/) - expect(() => releasedV2SessionFormatCodec.decodeArtifact(minimalPhysicalHeader, [markers[0]])) + expect(() => decodeV2(minimalPhysicalHeader, [markers[0]])) .toThrow(/unseeded Session contains an inherited end-seed marker/) - expect(() => releasedV2SessionFormatCodec.decodeArtifact(seededHeader, [{ + expect(() => decodeV2(seededHeader, [{ type: 'session/end-seed', seq: 0, time: 1, data: null, }])).toThrow(/must be an object/) }) diff --git a/packages/session/session-format-v1-to-v2/tests/migration.spec.ts b/packages/session/session-format-v1-to-v2/tests/migration.spec.ts index d39fc191d9..df3b595304 100644 --- a/packages/session/session-format-v1-to-v2/tests/migration.spec.ts +++ b/packages/session/session-format-v1-to-v2/tests/migration.spec.ts @@ -1,6 +1,23 @@ import { describe, expect, it } from 'vitest' -import { sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2' -import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format' +import { + assertReleasedV2Header, + releasedV2SessionFormatCodec, + sessionFormatV1ToV2, +} from '@deepseek-ai/dsh-session-format-v1-to-v2' +import { assertReleasedV2Artifact } from '../src/testing/validation.ts' +import { + releasedV0SessionFormatCodec, + releasedV1SessionFormatCodec, + sessionFormatV0ToV1, +} from '@deepseek-ai/dsh-session-format-v0-to-v1' +import type { + SessionFormatArtifact, + SessionFormatEvent, + SessionFormatEventRun, + SessionFormatHeader, + SessionFormatJsonObject, +} from '@deepseek-ai/dsh-session-format' +import { createSessionFormatCatalog, SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format' const message = { id: 'assistant-1', @@ -20,6 +37,94 @@ function event(type: string, seq: number, time: number, data: SessionFormatEvent return { type, seq, time, data } } +const catalog = createSessionFormatCatalog({ + currentVersion: 2, + codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec], + currentEncoder: releasedV2SessionFormatCodec, + migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2], + restoreCurrent(artifact) { + assertReleasedV2Artifact(artifact) + return artifact + }, + restoreTransformedCurrent(artifact) { + assertReleasedV2Artifact(artifact) + return artifact + }, + restoreCurrentHeader(header) { + assertReleasedV2Header(header) + return header + }, +}) + +function physicalV1Header(header: SessionFormatHeader, inheritedEventCount: number) { + return { + type: 'session', + version: 1, + id: header.id, + createdAt: header.createdAt, + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + ...(header.parentSession === undefined ? {} : { parentSession: header.parentSession }), + ...(header.isSeeded ? { seedLength: inheritedEventCount } : {}), + ...(header.origin === undefined ? {} : { origin: header.origin }), + delegationDepth: header.delegationDepth, + ...(header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }), + } +} + +function migrateV1ToV2(source: SessionFormatArtifact): SessionFormatArtifact { + const restore = catalog.createRestore( + physicalV1Header(source.header, source.inheritedEventCount), + { recovery: 'strict', validation: 'current' }, + ) + for (const row of source.events) restore.decodeRow(row) + return restore.finish() +} + +function stageHarness(options: { + readonly id?: string + readonly sourceCut?: number + readonly seeded?: boolean + readonly sourceKind?: 'decoded' | 'transformed' +} = {}) { + const sourceHeader: SessionFormatHeader = { + version: 1, + id: options.id ?? 'stage', + createdAt: 1, + ...(options.seeded === true ? { parentSession: 'parent' } : {}), + isSeeded: options.seeded === true, + delegationDepth: 0, + } + const stage = sessionFormatV1ToV2.createStage({ + sourceHeader, + targetHeader: sessionFormatV1ToV2.migrateHeader(sourceHeader), + sourceInheritedEventCount: options.sourceCut ?? 0, + sourceKind: options.sourceKind ?? 'decoded', + }) + return { stage, output: new SessionFormatEventCollector() } +} + +function packedRun(options: { + readonly firstSeq: number + readonly eventCount?: number + readonly turn?: number + readonly step?: number + readonly lastTime: number + readonly stream: SessionFormatJsonObject +}): SessionFormatEventRun { + const eventCount = options.eventCount ?? 1 + return { + runType: 'released-assistant-chunks', + firstSeq: options.firstSeq, + eventCount, + turn: options.turn ?? 1, + step: options.step ?? 1, + lastSeq: options.firstSeq + eventCount - 1, + lastTime: options.lastTime, + stream: options.stream, + *expand() {}, + } as SessionFormatEventRun +} + describe('sessionFormatV1ToV2', () => { it('migrates one exact released-v1 header without reading events', () => { const header = { @@ -33,6 +138,346 @@ describe('sessionFormatV1ToV2', () => { expect(() => sessionFormatV1ToV2.migrateHeader({ ...header, version: 0 })).toThrow(/v1 header/) }) + it('validates a directly decoded v1 source before transforming it', () => { + const header = { + version: 1, id: 'source-validation', createdAt: 1, isSeeded: false, delegationDepth: 0, + } + expect(() => migrateV1ToV2({ + header, + inheritedEventCount: 0, + events: [event('external/post-v1', 0, 1, null)], + })).toThrow(/unknown event type/) + expect(() => migrateV1ToV2({ + header, + inheritedEventCount: 0, + events: [{ + type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true }, + }], + })).toThrow(/unexpected member/) + }) + + it.each([ + ['unexpected member', { ...event('assistant/chunk', 0, 1, {}), extra: true }, /unexpected member extra/], + ['missing member', { type: 'assistant/chunk', seq: 0, time: 1 }, /lacks required member data/], + ['invalid ignorable marker', { ...event('assistant/chunk', 0, 1, {}), ignorable: false }, /ignorable must be true/], + ])('refuses an Assistant chunk envelope with an %s', (_name, candidate, expected) => { + const { stage, output } = stageHarness({ sourceKind: 'transformed' }) + expect(() => { stage.transformEvent(candidate as SessionFormatEvent, output) }).toThrow(expected) + }) + + it('checks own-generation delivery markers but accepts inherited markers', () => { + const marker = (sessionId: string): SessionFormatEvent => event( + 'session-log-deepseek/delivery-accepted', + 1, + 2, + { sessionId, throughSeq: 0, sessionFormatVersion: 1 }, + ) + const own = stageHarness({ id: 'child' }) + expect(() => { own.stage.transformEvent(marker('other'), own.output) }).toThrow(/wrong Session/) + + const inherited = stageHarness({ id: 'child', seeded: true, sourceCut: 2 }) + inherited.stage.transformEvent(marker('parent'), inherited.output) + expect(inherited.output.values).toEqual([{ ...marker('parent'), seq: 0 }]) + }) + + it('expands generic runs and refuses a packed run split by the inherited cut', () => { + const generic = stageHarness({ sourceKind: 'transformed' }) + const retained = event('feedback/record', 0, 1, { text: 'retained' }) + generic.stage.transformRun({ + runType: 'test-run', firstSeq: 0, eventCount: 1, *expand() { yield retained }, + }, generic.output) + expect(generic.output.values).toEqual([retained]) + + const split = stageHarness({ seeded: true, sourceCut: 1 }) + expect(() => { split.stage.transformRun(packedRun({ + firstSeq: 0, + eventCount: 2, + lastTime: 2, + stream: { type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] }, + }), split.output) }).toThrow(/splits one Assistant attempt/) + }) + + it('coalesces adjacent packed text and tool-call runs without expanding them', () => { + const text = stageHarness() + text.stage.transformRun(packedRun({ + firstSeq: 0, lastTime: 1, + stream: { type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }, + }), text.output) + text.stage.transformRun(packedRun({ + firstSeq: 1, eventCount: 2, lastTime: 4, + stream: { type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['b', 'c'] }, + }), text.output) + text.stage.finish(text.output) + expect(text.output.values[0]?.data).toMatchObject({ + stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [2, 1], texts: ['a', 'b', 'c'] }], + }) + + const tool = stageHarness() + tool.stage.transformEvent(event('assistant/chunk', 0, 1, { + turn: 1, step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call', name: 'read', argumentsDelta: '{' }, + }), tool.output) + tool.stage.transformRun(packedRun({ + firstSeq: 1, eventCount: 2, lastTime: 3, + stream: { + type: 'tool-call-chunks', time0: 2, index: 0, + id: 'call', name: 'read', dt: [1], args: ['}', '!'], + }, + }), tool.output) + tool.stage.finish(tool.output) + expect(tool.output.values[0]?.data).toMatchObject({ + stream: [{ + type: 'tool-call-chunks', time0: 1, index: 0, + id: 'call', name: 'read', dt: [1, 1], args: ['{', '}', '!'], + }], + }) + }) + + it('keeps incompatible packed records and attempt groups separate', () => { + const mismatched = [ + { index: 1, id: 'call', name: 'read', time0: 2 }, + { index: 0, id: 'other', name: 'read', time0: 2 }, + { index: 0, id: 'call', name: 'write', time0: 2 }, + { index: 0, id: 'call', name: 'read', time0: Number.MAX_SAFE_INTEGER }, + ] as const + for (const [index, next] of mismatched.entries()) { + const current = stageHarness() + current.stage.transformRun(packedRun({ + firstSeq: 0, + lastTime: index === mismatched.length - 1 ? Number.MIN_SAFE_INTEGER : 1, + stream: { + type: 'tool-call-chunks', time0: index === mismatched.length - 1 ? Number.MIN_SAFE_INTEGER : 1, + index: 0, id: 'call', name: 'read', dt: [], args: ['a'], + }, + }), current.output) + current.stage.transformRun(packedRun({ + firstSeq: 1, + lastTime: next.time0, + stream: { + type: 'tool-call-chunks', time0: next.time0, + index: next.index, id: next.id, name: next.name, dt: [], args: ['b'], + }, + }), current.output) + current.stage.finish(current.output) + expect((current.output.values[0]?.data as { stream: unknown[] }).stream).toHaveLength(2) + } + + const groups = stageHarness() + groups.stage.transformRun(packedRun({ + firstSeq: 0, lastTime: 1, + stream: { type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }, + }), groups.output) + groups.stage.transformRun(packedRun({ + firstSeq: 1, step: 2, lastTime: 2, + stream: { type: 'text-chunks', time0: 2, index: 0, dt: [], texts: ['b'] }, + }), groups.output) + groups.stage.finish(groups.output) + expect(groups.output.values).toHaveLength(2) + }) + + it('copies incompatible accumulator records behind an owned packed prefix', () => { + const text = stageHarness() + text.stage.transformRun(packedRun({ + firstSeq: 0, lastTime: 1, + stream: { type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['packed'] }, + }), text.output) + text.stage.transformEvent(event('assistant/chunk', 1, 2, { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'a' }, + }), text.output) + text.stage.transformEvent(event('assistant/chunk', 2, 3, { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'b' }, + }), text.output) + text.stage.transformRun(packedRun({ + firstSeq: 3, lastTime: 4, + stream: { type: 'reasoning-chunks', time0: 4, index: 0, dt: [], texts: ['flush'] }, + }), text.output) + text.stage.finish(text.output) + expect((text.output.values[0]?.data as { stream: unknown[] }).stream).toHaveLength(3) + + const tool = stageHarness() + tool.stage.transformRun(packedRun({ + firstSeq: 0, lastTime: 1, + stream: { type: 'tool-call-chunks', time0: 1, index: 0, id: 'call', dt: [], args: ['a'] }, + }), tool.output) + tool.stage.transformEvent(event('assistant/chunk', 1, 2, { + turn: 1, step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'other', argumentsDelta: 'b' }, + }), tool.output) + tool.stage.transformRun(packedRun({ + firstSeq: 2, lastTime: 3, + stream: { type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['flush'] }, + }), tool.output) + tool.stage.finish(tool.output) + expect((tool.output.values[0]?.data as { stream: unknown[] }).stream).toHaveLength(3) + }) + + it('separates a terminal packed successor and a message from another attempt', () => { + const terminal = stageHarness() + terminal.stage.transformEvent(event('assistant/chunk', 0, 1, { + turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } }, + }), terminal.output) + terminal.stage.transformRun(packedRun({ + firstSeq: 1, lastTime: 2, + stream: { type: 'text-chunks', time0: 2, index: 0, dt: [], texts: ['next'] }, + }), terminal.output) + terminal.stage.finish(terminal.output) + expect(terminal.output.values).toHaveLength(2) + + const messageMismatch = stageHarness() + messageMismatch.stage.transformEvent(event('assistant/chunk', 0, 1, { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial' }, + }), messageMismatch.output) + messageMismatch.stage.transformEvent({ + ...event('assistant/message', 1, 2, { turn: 2, step: 1, message }), + surfaceOp: 'append', + }, messageMismatch.output) + expect(messageMismatch.output.values).toHaveLength(2) + }) + + it('settles incremental attempts and rejects ambiguous streamed input', () => { + const header = { + version: 1, id: 'streaming', createdAt: 1, isSeeded: false, delegationDepth: 0, + } + const chunk = (seq: number, value: string | undefined, turn = 1): SessionFormatEvent => event( + 'assistant/chunk', + seq, + seq + 1, + { + turn, + step: 1, + chunk: value === undefined + ? { type: 'finish', reason: { kind: 'stop' } } + : { type: 'text-delta', index: 0, text: value }, + }, + ) + const assistant = ( + seq: number, + turn: number, + sourceEventSeqs?: readonly number[], + ): SessionFormatEvent => ({ + ...event('assistant/message', seq, seq + 1, { turn, step: 1, message }), + ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }), + surfaceOp: 'append', + }) + + const restarted = migrateV1ToV2({ + header, + inheritedEventCount: 0, + events: [ + event('turn/start', 0, 1, { turn: 1 }), + event('step/start', 1, 2, { turn: 1, step: 1 }), + chunk(2, 'first'), + chunk(3, undefined), + chunk(4, 'second'), + event('step/end', 5, 6, { turn: 1, step: 1 }), + event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }), + ], + }) + expect(restarted.events.filter(candidate => candidate.type === 'assistant/attempt')).toHaveLength(2) + + expect(() => migrateV1ToV2({ + header, + inheritedEventCount: 0, + events: [chunk(0, 'partial'), assistant(1, 1)], + })).toThrow(/does not cite/) + + expect(() => migrateV1ToV2({ + header, + inheritedEventCount: 0, + events: [chunk(0, 'partial'), assistant(1, 1, [1])], + })).toThrow(/complete ordered attempt/) + expect(() => migrateV1ToV2({ + header, + inheritedEventCount: 0, + events: [event('external/unknown', 0, 1, null)], + })).toThrow(/unknown event type/) + + const seededHeader = { ...header, parentSession: 'parent', isSeeded: true } + expect(migrateV1ToV2({ header: seededHeader, inheritedEventCount: 0, events: [] }).events).toEqual([{ + type: 'session/end-seed', seq: 0, time: 1, data: { inherited: true }, + }]) + expect(migrateV1ToV2({ + header: seededHeader, + inheritedEventCount: 0, + events: [event('turn/start', 0, 2, { turn: 1 })], + }).events[0]).toEqual({ + type: 'session/end-seed', seq: 0, time: 2, data: { inherited: true }, + }) + + expect(() => migrateV1ToV2({ + header: seededHeader, + inheritedEventCount: 1, + events: [chunk(0, 'partial'), assistant(1, 1, [0])], + })).toThrow(/splits one Assistant attempt/) + }) + + it('splits a legacy goal mutation from its preserved model-visible message', () => { + const change = { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: 'goal-1', revision: 1, objective: 'finish', phase: 'active', maxGoalRounds: 2 }, + roundsStarted: 0, createdAt: 1, updatedAt: 1, + } + const payload = { + goal: change.goal, + roundsStarted: change.roundsStarted, + createdAt: change.createdAt, + updatedAt: change.updatedAt, + } + const source: SessionFormatArtifact = { + header: { version: 1, id: 'legacy-goal', createdAt: 1, isSeeded: false, delegationDepth: 0 }, + inheritedEventCount: 0, + events: [ + { + ...event('user/message', 0, 1, { + id: 'goal-message', role: 'user', + content: [{ type: 'text', text: `${JSON.stringify(payload)}` }], + source: { kind: 'goal', goalId: 'goal-1', revision: 1, round: 0, change }, + }), + surfaceOp: 'append', + }, + ], + } + + expect(migrateV1ToV2(source).events).toMatchObject([ + { type: 'goal/change', seq: 0, data: change }, + { type: 'user/message', seq: 1, data: { source: { kind: 'plugin', plugin: 'goal' } } }, + ]) + }) + + it('closes the prior turn for the bounded legacy next-turn resume pattern', () => { + const source: SessionFormatArtifact = { + header: { version: 1, id: 'legacy-turn', createdAt: 1, isSeeded: false, delegationDepth: 0 }, + inheritedEventCount: 0, + events: [ + event('turn/start', 0, 1, { turn: 1 }), + event('step/start', 1, 2, { turn: 1, step: 1 }), + event('step/end', 2, 3, { turn: 1, step: 1 }), + event('agent/inbox/spliced', 3, 4, { + target: 'next-turn', start: 0, inserted: [userMessage], + }), + event('turn/start', 4, 5, { turn: 2 }), + event('turn/end', 5, 6, { turn: 2, reason: { kind: 'completed' } }), + ], + } + + const migrated = migrateV1ToV2(source) + expect(migrated.events.map(candidate => candidate.type)).toEqual([ + 'turn/start', 'step/start', 'step/end', 'agent/inbox/spliced', + 'turn/end', 'turn/start', 'turn/end', + ]) + expect(migrated.events[4]).toMatchObject({ + data: { turn: 1, reason: { kind: 'interrupted' } }, + }) + + const invalid = { + ...source, + events: source.events.map(candidate => candidate.seq === 3 + ? event('agent/inbox/spliced', 3, 4, { target: 'next-turn', start: 0, inserted: [] }) + : candidate), + } + expect(() => migrateV1ToV2(invalid)).toThrow(/turn\/start 2/) + }) + it('embeds an interleaved successful stream and densely remaps survivors', () => { const source: SessionFormatArtifact = { header: { @@ -73,7 +518,7 @@ describe('sessionFormatV1ToV2', () => { ], } - expect(sessionFormatV1ToV2.migrate(source)).toStrictEqual({ + expect(migrateV1ToV2(source)).toStrictEqual({ header: { ...source.header, version: 2 }, inheritedEventCount: 0, events: [ @@ -133,7 +578,7 @@ describe('sessionFormatV1ToV2', () => { ], } - expect(sessionFormatV1ToV2.migrate(source)).toStrictEqual({ + expect(migrateV1ToV2(source)).toStrictEqual({ header: { ...source.header, version: 2 }, inheritedEventCount: 0, events: [ @@ -203,7 +648,7 @@ describe('sessionFormatV1ToV2', () => { ], } - const migrated = sessionFormatV1ToV2.migrate(source) + const migrated = migrateV1ToV2(source) expect(migrated.events.filter(event => event.type.startsWith('assistant/'))).toStrictEqual([ event('assistant/attempt', 3, 110, { turn: 1, @@ -260,7 +705,7 @@ describe('sessionFormatV1ToV2', () => { ], } - const migrated = sessionFormatV1ToV2.migrate(source) + const migrated = migrateV1ToV2(source) expect(migrated.inheritedEventCount).toBe(5) expect(migrated.events[5]).toStrictEqual({ type: 'session/end-seed', @@ -284,7 +729,7 @@ describe('sessionFormatV1ToV2', () => { events: [], } - expect(sessionFormatV1ToV2.migrate(source)).toStrictEqual({ + expect(migrateV1ToV2(source)).toStrictEqual({ header: { ...source.header, version: 2 }, inheritedEventCount: 0, events: [{ @@ -305,7 +750,7 @@ describe('sessionFormatV1ToV2', () => { inheritedEventCount: 1, events: [event('feedback/record', 0, 9, { text: 'inherited' })], } - expect(sessionFormatV1ToV2.migrate(source)).toMatchObject({ + expect(migrateV1ToV2(source)).toMatchObject({ inheritedEventCount: 1, events: [ { type: 'feedback/record', seq: 0 }, @@ -351,7 +796,7 @@ describe('sessionFormatV1ToV2', () => { ], } - expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(/message content disagrees with its embedded stream/) + expect(() => migrateV1ToV2(source)).toThrow(/message content disagrees with its embedded stream/) }) it('refuses an undeclared reference into the last consumed chunk of a failed attempt', () => { @@ -391,7 +836,7 @@ describe('sessionFormatV1ToV2', () => { ], } - expect(() => sessionFormatV1ToV2.migrate(source)).toThrow( + expect(() => migrateV1ToV2(source)).toThrow( /command\/done 7 sourceEventSeq targets consumed assistant\/chunk 3/, ) }) @@ -412,7 +857,7 @@ describe('sessionFormatV1ToV2', () => { }], } - expect(() => sessionFormatV1ToV2.migrate(source)).toThrow( + expect(() => migrateV1ToV2(source)).toThrow( /format v1 contains unknown event type "external\/info" at seq 0/, ) }) @@ -438,7 +883,7 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 4, 5, { turn: 1, reason: { kind: 'completed' } }), ], } - const migrated = sessionFormatV1ToV2.migrate(source) + const migrated = migrateV1ToV2(source) expect(migrated.events[2]).toMatchObject({ type: 'assistant/message', data: { stream: [] }, surfaceOp: 'append', }) @@ -470,9 +915,51 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }), ], }) - expect(() => sessionFormatV1ToV2.migrate(build(undefined))).toThrow(/does not cite/) - expect(() => sessionFormatV1ToV2.migrate(build([2]))).toThrow(/complete ordered attempt/) - expect(() => sessionFormatV1ToV2.migrate(build([3, 2]))).toThrow(/complete ordered attempt/) + expect(() => migrateV1ToV2(build(undefined))).toThrow(/does not cite/) + expect(() => migrateV1ToV2(build([2]))).toThrow(/complete ordered attempt/) + expect(() => migrateV1ToV2(build([3, 2]))).toThrow(/complete ordered attempt/) + }) + + it.each([ + ['a message after its attempt step has closed', [ + event('turn/start', 0, 1, { turn: 1 }), + event('step/start', 1, 2, { turn: 1, step: 1 }), + event('assistant/chunk', 2, 3, { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' }, + }), + event('assistant/chunk', 3, 4, { + turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } }, + }), + event('step/end', 4, 5, { turn: 1, step: 1 }), + { ...event('assistant/message', 5, 6, { turn: 1, step: 1, message }), surfaceOp: 'append' }, + event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }), + ]], + ['a message that cites a different pending attempt', [ + event('turn/start', 0, 1, { turn: 1 }), + event('step/start', 1, 2, { turn: 1, step: 1 }), + event('assistant/chunk', 2, 3, { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' }, + }), + event('assistant/chunk', 3, 4, { + turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } }, + }), + { + ...event('assistant/message', 4, 5, { turn: 1, step: 2, message }), + sourceEventSeqs: [2, 3], + surfaceOp: 'append', + }, + event('step/end', 5, 6, { turn: 1, step: 1 }), + event('turn/end', 6, 7, { turn: 1, reason: { kind: 'completed' } }), + ]], + ] as const)('rejects %s during complete target validation', (_name, events) => { + expect(() => migrateV1ToV2({ + header: { + version: 1, id: 'v1-final-validation', createdAt: 1, + isSeeded: false, delegationDepth: 0, + }, + inheritedEventCount: 0, + events, + })).toThrow(/does not match an open turn and step/) }) it('refuses a lineage cut between members of one interleaved stream', () => { @@ -501,7 +988,7 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 7, 8, { turn: 1, reason: { kind: 'completed' } }), ], } - expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(/cut 3 splits one Assistant attempt/) + expect(() => migrateV1ToV2(source)).toThrow(/cut 3 splits one Assistant attempt/) }) it('refuses a lineage cut between a complete stream and its committed message', () => { @@ -530,7 +1017,7 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 7, 8, { turn: 1, reason: { kind: 'completed' } }), ], } - expect(() => sessionFormatV1ToV2.migrate(source)).toThrow(/cut 4 splits one Assistant attempt/) + expect(() => migrateV1ToV2(source)).toThrow(/cut 4 splits one Assistant attempt/) }) it('keeps referenced-session generation provenance frozen', () => { @@ -565,7 +1052,7 @@ describe('sessionFormatV1ToV2', () => { events: [{ ...event('user/message', 0, 1, reference), surfaceOp: 'append' }], } - const migrated = sessionFormatV1ToV2.migrate(source) + const migrated = migrateV1ToV2(source) expect(migrated.events[0]?.data).toStrictEqual(reference) }) @@ -634,7 +1121,7 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 15, 16, { turn: 1, reason: { kind: 'completed' } }), ], } - const migrated = sessionFormatV1ToV2.migrate(source) + const migrated = migrateV1ToV2(source) expect(migrated.events.find(event => event.type === 'compaction/prune')?.data).toMatchObject({ shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2], }) @@ -687,7 +1174,7 @@ describe('sessionFormatV1ToV2', () => { ], } - const migrated = sessionFormatV1ToV2.migrate(source) + const migrated = migrateV1ToV2(source) const titleRequest = migrated.events.find(event => event.type === 'session/title-llm-request') expect(titleRequest?.data).toMatchObject({ @@ -728,7 +1215,7 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 10, 11, { turn: 2, reason: { kind: 'completed' } }), ], } - expect(sessionFormatV1ToV2.migrate(source).events[3]?.data).toMatchObject({ + expect(migrateV1ToV2(source).events[3]?.data).toMatchObject({ shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], }) }) @@ -758,7 +1245,7 @@ describe('sessionFormatV1ToV2', () => { event('turn/end', 9, 10, { turn: 2, reason: { kind: 'error', error: failure } }), ], } - expect(sessionFormatV1ToV2.migrate(source).events.filter(event => event.type === 'assistant/attempt')) + expect(migrateV1ToV2(source).events.filter(event => event.type === 'assistant/attempt')) .toHaveLength(2) }) }) diff --git a/packages/session/session-format-v1-to-v2/tests/validation.spec.ts b/packages/session/session-format-v1-to-v2/tests/validation.spec.ts index 891d4788a1..0ad2542a6a 100644 --- a/packages/session/session-format-v1-to-v2/tests/validation.spec.ts +++ b/packages/session/session-format-v1-to-v2/tests/validation.spec.ts @@ -8,10 +8,10 @@ import type { } from '@deepseek-ai/dsh-session-format' import { RELEASED_V2_EVENT_TYPES, - assertReleasedV2Artifact, assertReleasedV2Header, restoreReleasedV2Artifact, } from '@deepseek-ai/dsh-session-format-v1-to-v2' +import { assertReleasedV2Artifact } from '../src/testing/validation.ts' const textBlock = { type: 'text', text: 'hello' } as const const usage = { inputTokens: 3, outputTokens: 2 } as const @@ -208,6 +208,22 @@ describe('released v2 event envelopes and payloads', () => { expect(() => { assertReleasedV2Artifact(value) }).toThrow(message) }) + it.each([ + ['inherited count beyond events', artifact([], { inheritedEventCount: 1 }), /exceeds its events/], + ['unseeded inherited count', artifact([ + event('feedback/record', 0, { text: 'x' }), + ], { inheritedEventCount: 1 }), /unseeded.*inherited events/], + ['non-string type', artifact([ + { type: 1, seq: 0, time: 1, data: {} } as unknown as SessionFormatEvent, + ]), /type must be a string/], + ['non-dense seq', artifact([event('feedback/record', 1, { text: 'x' })]), /not dense/], + ['false ignorable marker', artifact([ + event('feedback/record', 0, { text: 'x' }, { ignorable: false }), + ]), /ignorable must be true/], + ])('rejects production artifact drift before payload restoration: %s', (_name, value, message) => { + expect(() => { restoreReleasedV2Artifact(value, new Set(RELEASED_V2_EVENT_TYPES)) }).toThrow(message) + }) + it('validates present and absent opaque payload members before relationship checks', () => { for (const includeMeta of [false, true]) { const value = artifact([ @@ -298,6 +314,16 @@ describe('released v2 seed and surface relationships', () => { expect(() => { assertReleasedV2Artifact(value) }).toThrow(message) }) + it('rejects production seed lineage disagreement', () => { + const known = new Set(RELEASED_V2_EVENT_TYPES) + expect(() => { restoreReleasedV2Artifact(artifact([], { + header: { version: 2, id: 'seeded', createdAt: 1, isSeeded: true, delegationDepth: 0 }, + }), known) }).toThrow(/seeded header disagrees/) + expect(() => { restoreReleasedV2Artifact(artifact([ + event('session/end-seed', 0, { inherited: true }), + ]), known) }).toThrow(/unseeded.*inherited end-seed/) + }) + it('accepts append and exact replacement surface operations', () => { expect(() => { assertReleasedV2Artifact(artifact([ event('user/message', 0, userData('one'), { surfaceOp: 'append' }), diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts index 61776ae251..9242ed4eb4 100644 --- a/packages/test-support/llm-replay/src/index.ts +++ b/packages/test-support/llm-replay/src/index.ts @@ -48,15 +48,10 @@ interface ParsedSessionFixture { readonly createdAt: number readonly inheritedEventCount: SessionLogOffsetType readonly events: SessionEvent[] - readonly artifact: ReturnType + readonly artifact: ReturnType['finish']> readonly sourceHeader: Readonly> } -interface FixtureJsonLine { - readonly lineNumber: number - readonly value: Record -} - /** * One recorded model call. `throw` may replay prefix chunks before failing; * `hang` models cancellation. Derived chunk entries come from ordinary model @@ -210,7 +205,13 @@ export function parseSessionLog(text: string): SessionEvent[] { /** Parse, complete, decode, and migrate one projected snapshot artifact without writing its source. */ function parseSessionFixture(text: string): ParsedSessionFixture { - const parsed: FixtureJsonLine[] = [] + let headerLineNumber: number | undefined + let sourceHeader: Record | undefined + let restore: ReturnType | undefined + const rowLines: number[] = [] + const eventLines: number[] = [] + let bodyKind: 'complete' | 'projected' | undefined + let nextSeq = 0 for (const [index, line] of text.split(/\r?\n/).entries()) { if (line.trim().length === 0) continue let value: unknown @@ -222,19 +223,22 @@ function parseSessionFixture(text: string): ParsedSessionFixture { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`session snapshot line ${index + 1} must be a JSON object`) } - parsed.push({ lineNumber: index + 1, value: value as Record }) - } - const headerLine = parsed[0] - if (headerLine === undefined) throw new Error('session snapshot must start with a session header') - - const header = normalizeProjectedHeader(headerLine.value) - const rows: Record[] = [] - const rowLines: number[] = [] - const eventLines: number[] = [] - let bodyKind: 'complete' | 'projected' | undefined - let nextSeq = 0 - for (const source of parsed.slice(1)) { - const record = normalizeProjectedRow(source.value) + const lineNumber = index + 1 + const recordValue = value as Record + if (restore === undefined) { + headerLineNumber = lineNumber + sourceHeader = recordValue + try { + restore = sessionFormatCatalog.createRestore(normalizeProjectedHeader(recordValue), { + recovery: 'strict', + validation: 'current', + }) + } catch (error: unknown) { + throw fixtureFormatError(error, lineNumber, [], []) + } + continue + } + const record = normalizeProjectedRow(recordValue) const packed = PACKED_CHUNK_ROW_TYPES.has(record.type as string) const seqKey = packed ? 'seq0' : 'seq' const timeKey = packed ? 'time0' : 'time' @@ -242,12 +246,12 @@ function parseSessionFixture(text: string): ParsedSessionFixture { const hasTime = Object.hasOwn(record, timeKey) if (hasSeq !== hasTime) { throw new Error( - `session snapshot line ${source.lineNumber} must contain both ${seqKey} and ${timeKey}, or neither`, + `session snapshot line ${lineNumber} must contain both ${seqKey} and ${timeKey}, or neither`, ) } const currentKind = hasSeq ? 'complete' : 'projected' if (bodyKind !== undefined && currentKind !== bodyKind) { - throw new Error(`session snapshot line ${source.lineNumber} cannot mix projected and complete body rows`) + throw new Error(`session snapshot line ${lineNumber} cannot mix projected and complete body rows`) } bodyKind = currentKind if (currentKind === 'projected') { @@ -255,36 +259,28 @@ function parseSessionFixture(text: string): ParsedSessionFixture { record[timeKey] = 0 } const cardinality = physicalRowCardinality(record) - rows.push(record) - rowLines.push(source.lineNumber) - eventLines.push(...Array.from({ length: cardinality }, () => source.lineNumber)) + rowLines.push(lineNumber) + eventLines.push(...Array.from({ length: cardinality }, () => lineNumber)) nextSeq += cardinality + try { + restore.decodeRow(record) + } catch (error: unknown) { + throw fixtureFormatError(error, headerLineNumber as number, rowLines, eventLines, rowLines.length - 1) + } } - - let decoded: ReturnType - try { - decoded = sessionFormatCatalog.decodeArtifact(header, rows) - } catch (error: unknown) { - const physicalRow = locateUnlabelledPhysicalFailure(error, header, rows) - throw fixtureFormatError( - error, - headerLine.lineNumber, - rowLines, - eventLines, - physicalRow, - ) + if (restore === undefined || sourceHeader === undefined || headerLineNumber === undefined) { + throw new Error('session snapshot must start with a session header') } try { - const current = sessionFormatCatalog.migrate(decoded) - return parsedSessionFixture(current, headerLine.value) + return parsedSessionFixture(restore.finish(), sourceHeader) } catch (error: unknown) { - throw fixtureFormatError(error, headerLine.lineNumber, rowLines, eventLines) + throw fixtureFormatError(error, headerLineNumber, rowLines, eventLines) } } /** Materialize the common replay view from a migrated artifact. */ function parsedSessionFixture( - artifact: ReturnType, + artifact: ParsedSessionFixture['artifact'], sourceHeader: Readonly>, ): ParsedSessionFixture { return { @@ -410,17 +406,16 @@ function migrateWrappedEventGroup( time: 0, data: { text: `comparison prefix ${String(seq)}` }, })) - const migrated = sessionFormatCatalog.migrate({ - header: { - version: 1, - id: first.sessionId, - createdAt: 0, - isSeeded: false, - delegationDepth: 0, - }, - inheritedEventCount: 0, - events: [...prefix, ...entries.map(entry => normalizeProjectedRow(entry.event))] as never, - }).events.slice(prefix.length) as readonly Readonly>[] + const restore = sessionFormatCatalog.createRestore({ + type: 'session', + version: 1, + id: first.sessionId, + createdAt: 0, + delegationDepth: 0, + }, { recovery: 'strict', validation: 'current' }) + for (const event of prefix) restore.decodeRow(event) + for (const entry of entries) restore.decodeRow(normalizeProjectedRow(entry.event)) + const migrated = restore.finish().events.slice(prefix.length) as readonly Readonly>[] const assigned = new Map>[]>() let migratedIndex = 0 @@ -498,11 +493,18 @@ export function prepareSessionEventNotificationsForComparison(text: string): str /** Encode one migrated fixture while retaining a projected cwd token. */ function encodeCurrentSessionSnapshotFixture(text: string, parsed: ParsedSessionFixture): string { - const encoded = sessionFormatCatalog.encodeCurrent(parsed.artifact) - const header = { ...encoded.header } + const header = { + ...sessionFormatCatalog.encodeCurrentHeader( + parsed.artifact.header, + parsed.artifact.inheritedEventCount, + ), + } const sourceCwd = parsed.sourceHeader['cwd'] if (typeof sourceCwd === 'string' && /^\{\{cwd\}\}(?:\/|$)/.test(sourceCwd)) header['cwd'] = sourceCwd - const output = [header, ...encoded.rows].map(record => JSON.stringify(record)).join('\n') + const output = [ + JSON.stringify(header), + ...parsed.artifact.events.map(event => JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event))), + ].join('\n') return text.endsWith('\n') ? `${output}\n` : output } @@ -564,13 +566,15 @@ function fixtureFormatError( const locationDetail = error instanceof Error && error.cause instanceof Error ? error.cause.message : detail - const row = /released (?:Session|(?:text|reasoning|tool-call)-chunks) row (\d+)/.exec(locationDetail) + const storedRow = /^released Session row (\d+)/.exec(locationDetail) const event = /Session event (\d+)/.exec(locationDetail) ?? / at seq (\d+)/.exec(locationDetail) - ?? /^[^ ]+ (\d+) /.exec(locationDetail) - const line = physicalRow === undefined && row === null - ? event === null ? headerLine : eventLines[Number(event[1])] ?? headerLine - : rowLines[physicalRow ?? Number(row?.[1])] ?? headerLine + ?? /inherited Session cut (\d+)/.exec(locationDetail) + let line: number + if (physicalRow !== undefined) line = rowLines[physicalRow] as number + else if (storedRow !== null) line = rowLines[Number(storedRow[1])] ?? headerLine + else if (event === null) line = headerLine + else line = eventLines[Number(event[1])] ?? headerLine const message = `session snapshot line ${line}: ${detail}` if (error instanceof SessionFormatUnsupportedMigrationError) { return new SessionFormatUnsupportedMigrationError(message, { cause: error }) @@ -578,26 +582,6 @@ function fixtureFormatError( return new Error(message, { cause: error }) } -/** Locate range-decoder failures whose frozen diagnostic predates physical-row labels. */ -function locateUnlabelledPhysicalFailure( - error: unknown, - header: Readonly>, - rows: readonly Readonly>[], -): number | undefined { - const detail = error instanceof Error ? error.message : String(error) - if (!detail.startsWith('sourceEventSeqs ')) return undefined - const diagnosticHeader = Object.hasOwn(header, 'seedLength') ? { ...header, seedLength: 0 } : header - for (let index = 0; index < rows.length; index += 1) { - try { - sessionFormatCatalog.decodeArtifact(diagnosticHeader, rows.slice(0, index + 1)) - } catch (candidate: unknown) { - const candidateDetail = candidate instanceof Error ? candidate.message : String(candidate) - if (candidateDetail === detail) return index - } - } - return undefined -} - /** * Read replay identity, ordering, and fork-seed facts from the JSONL header. * diff --git a/packages/test-support/llm-replay/tests/llm-replay.spec.ts b/packages/test-support/llm-replay/tests/llm-replay.spec.ts index c3d6970198..9901ea908a 100644 --- a/packages/test-support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/test-support/llm-replay/tests/llm-replay.spec.ts @@ -235,7 +235,7 @@ describe('Session format package parity', () => { }) describe('fixture format diagnostics', () => { - it('attaches the header line to a non-Error catalog failure', async () => { + it('attaches the header line to a restore-construction failure', async () => { vi.resetModules() vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => { const actual = await importOriginal() @@ -243,7 +243,7 @@ describe('fixture format diagnostics', () => { ...actual, sessionFormatCatalog: { ...actual.sessionFormatCatalog, - decodeArtifact(): never { + createRestore(): never { const failure: unknown = 'decoder exploded' throw failure }, @@ -261,6 +261,37 @@ describe('fixture format diagnostics', () => { } }) + it('attaches the header line to a restore-finalization failure', async () => { + vi.resetModules() + vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sessionFormatCatalog: { + ...actual.sessionFormatCatalog, + createRestore() { + return { + header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 }, + decodeRow() {}, + finish(): never { + throw new Error('Session event 99 restore finalization failed') + }, + } + }, + }, + } + }) + try { + const replay = await import('../src/index.ts') + + expect(() => replay.parseSessionLog(sessionJsonl([]))) + .toThrow('session snapshot line 1: Session event 99 restore finalization failed') + } finally { + vi.doUnmock('@deepseek-ai/dsh-session-format-catalog') + vi.resetModules() + } + }) + it('falls back to the header when a source-range diagnostic has no matching physical prefix', async () => { vi.resetModules() vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => { @@ -269,8 +300,14 @@ describe('fixture format diagnostics', () => { ...actual, sessionFormatCatalog: { ...actual.sessionFormatCatalog, - decodeArtifact(): never { - throw new Error('sourceEventSeqs synthetic unmatched failure') + createRestore() { + return { + header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 }, + decodeRow() {}, + finish(): never { + throw new Error('sourceEventSeqs synthetic unmatched failure') + }, + } }, }, } @@ -286,22 +323,24 @@ describe('fixture format diagnostics', () => { } }) - it('maps a matching non-Error source-range prefix failure to its physical row', async () => { + it('maps a non-Error row failure to its physical row', async () => { vi.resetModules() vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => { const actual = await importOriginal() - let callCount = 0 + let row = 0 return { ...actual, sessionFormatCatalog: { ...actual.sessionFormatCatalog, - decodeArtifact(): never { - callCount += 1 - if (callCount === 1) throw new Error('sourceEventSeqs synthetic prefix failure') - const failure: unknown = callCount === 2 - ? 'sourceEventSeqs different prefix failure' - : 'sourceEventSeqs synthetic prefix failure' - throw failure + createRestore() { + return { + header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 }, + decodeRow(): void { + row += 1 + if (row === 2) throw 'row decoder exploded' + }, + finish(): never { throw new Error('unexpected finish') }, + } }, }, } @@ -314,14 +353,21 @@ describe('fixture format diagnostics', () => { ] expect(() => replay.parseSessionLog(sessionJsonl(events))) - .toThrow('session snapshot line 3: sourceEventSeqs synthetic prefix failure') + .toThrow('session snapshot line 3: row decoder exploded') } finally { vi.doUnmock('@deepseek-ai/dsh-session-format-catalog') vi.resetModules() } }) - it('falls back to the header for an out-of-range physical-row diagnostic', async () => { + it.each([ + ['physical row', 'released Session row 0 is malformed', 2], + ['logical event', 'Session event 0 is malformed', 2], + ['event seq', 'format v1 contains an invalid event at seq 0', 2], + ['inherited cut', 'inherited Session cut 0 splits one Assistant attempt', 2], + ['out-of-range physical row', 'released Session row 99 is malformed', 1], + ['out-of-range logical event', 'Session event 99 is malformed', 1], + ])('maps a %s finalization diagnostic to its source line', async (_label, message, line) => { vi.resetModules() vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => { const actual = await importOriginal() @@ -329,8 +375,12 @@ describe('fixture format diagnostics', () => { ...actual, sessionFormatCatalog: { ...actual.sessionFormatCatalog, - decodeArtifact(): never { - throw new Error('released Session row 99 is malformed') + createRestore() { + return { + header: { version: 2, id: 'fixture', createdAt: 0, isSeeded: false, delegationDepth: 0 }, + decodeRow() {}, + finish(): never { throw new Error(message) }, + } }, }, } @@ -340,33 +390,7 @@ describe('fixture format diagnostics', () => { const event: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } } expect(() => replay.parseSessionLog(sessionJsonl([event]))) - .toThrow('session snapshot line 1: released Session row 99 is malformed') - } finally { - vi.doUnmock('@deepseek-ai/dsh-session-format-catalog') - vi.resetModules() - } - }) - - it('falls back to the header for an out-of-range logical-event diagnostic', async () => { - vi.resetModules() - vi.doMock('@deepseek-ai/dsh-session-format-catalog', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - sessionFormatCatalog: { - ...actual.sessionFormatCatalog, - migrate(): never { - throw new Error('Session event 99 is malformed') - }, - }, - } - }) - try { - const replay = await import('../src/index.ts') - const event: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } } - - expect(() => replay.parseSessionLog(sessionJsonl([event]))) - .toThrow('session snapshot line 1: Session event 99 is malformed') + .toThrow(`session snapshot line ${line}: ${message}`) } finally { vi.doUnmock('@deepseek-ai/dsh-session-format-catalog') vi.resetModules() diff --git a/scripts/gen-session-format-catalog.spec.ts b/scripts/gen-session-format-catalog.spec.ts index 47f077efda..8fcde731cb 100644 --- a/scripts/gen-session-format-catalog.spec.ts +++ b/scripts/gen-session-format-catalog.spec.ts @@ -103,8 +103,9 @@ describe('session format catalog generator', () => { expect(declarations.map(item => [item.from, item.to])).toEqual([[0, 1], [1, 2]]) expect(output).toContain("from '@deepseek-ai/dsh-session-format-v0-to-v1'") expect(output).toContain('currentVersion: 2') - expect(output).toContain('encodeCurrentArtifact: artifact => releasedV2SessionFormatCodec.encodeArtifact(artifact)') + expect(output).toContain('currentEncoder: releasedV2SessionFormatCodec') expect(output).toContain('restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)') + expect(output).toContain('restoreTransformedCurrent(artifact)') expect(output).toContain('assertReleasedV2Header(header)') expect(output).toContain('validateInstalledCurrentSessionHeader(header)') expect(output).toContain("from '@deepseek-ai/dsh-session'") diff --git a/scripts/gen-session-format-catalog.ts b/scripts/gen-session-format-catalog.ts index 2886a5e224..5fff6b4cab 100644 --- a/scripts/gen-session-format-catalog.ts +++ b/scripts/gen-session-format-catalog.ts @@ -191,13 +191,16 @@ export function renderSessionFormatCatalog( 'export const sessionFormatCatalog = createSessionFormatCatalog({', ` currentVersion: ${currentVersion},`, ` codecs: [${codecs.join(', ')}],`, - ` encodeCurrentArtifact: artifact => ${currentCodec}.encodeArtifact(artifact),`, + ` currentEncoder: ${currentCodec},`, ` migrations: [${declarations.map(item => item.migration).join(', ')}],`, ' restoreCurrent(artifact) {', ` const restored = ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`, ' validateInstalledCurrentSessionArtifact(restored)', ' return restored', ' },', + ' restoreTransformedCurrent(artifact) {', + ` return ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`, + ' },', ' restoreCurrentHeader(header) {', ` ${headerValidator}(header)`, ' validateInstalledCurrentSessionHeader(header)', diff --git a/scripts/session-fixture-layout.ts b/scripts/session-fixture-layout.ts index 830d69ceb0..dbc4414b9d 100644 --- a/scripts/session-fixture-layout.ts +++ b/scripts/session-fixture-layout.ts @@ -58,6 +58,51 @@ function validationHeader(value: unknown): unknown { return header } +function validationRow(source: Readonly>): Record { + if (source.type !== 'request/header') return { ...source } + const data = source.data + if (data === null || typeof data !== 'object' || Array.isArray(data)) return { ...source } + const header = (data as Record).header + if (header === null || typeof header !== 'object' || Array.isArray(header)) return { ...source } + if ((header as Record).tools !== '{{tools}}') return { ...source } + return { + ...source, + data: { + ...data, + header: { ...header, tools: [] }, + }, + } +} + +function restoreRequestHeaderTokens( + events: readonly SessionEvent[], + rows: readonly Readonly>[], +): SessionEvent[] { + const sources = rows.filter(row => row.type === 'request/header') + let sourceIndex = 0 + return events.map((event) => { + if (event.type !== 'request/header') return event + const source = sources[sourceIndex] + sourceIndex += 1 + const sourceData = source?.data + const sourceHeader = sourceData !== null && typeof sourceData === 'object' && !Array.isArray(sourceData) + ? (sourceData as Record).header + : undefined + if (sourceHeader === null || typeof sourceHeader !== 'object' || Array.isArray(sourceHeader) + || (sourceHeader as Record).tools !== '{{tools}}') return event + return { + ...event, + data: { + ...event.data, + header: { + ...(event.data as unknown as { header: Record }).header, + tools: '{{tools}}', + }, + }, + } as SessionEvent + }) +} + function renderFixture(headerLine: string, events: readonly SessionEvent[]): string { return [ headerLine, @@ -95,6 +140,7 @@ function parseFixtureObjectLine(line: string, lineNumber: number): Record[] = [] const rowLines: number[] = [] + const eventLines: number[] = [] let nextSeq: SessionLogOffsetType = SessionLogOffset(0) let headerSkipped = false for (const [index, line] of content.split(/\r?\n/).entries()) { @@ -113,7 +159,9 @@ function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[] if (!Object.hasOwn(record, timeKey)) record[timeKey] = 0 rows.push(record) rowLines.push(index + 1) - nextSeq = SessionLogOffset(nextSeq + projectedRowCardinality(record)) + const cardinality = projectedRowCardinality(record) + for (let offset = 0; offset < cardinality; offset += 1) eventLines.push(index + 1) + nextSeq = SessionLogOffset(nextSeq + cardinality) } // Versionless protocol fixtures and current projected snapshots use scalar // event rows. Current snapshots may contain owner-restored scrub tokens such @@ -143,18 +191,52 @@ function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[] } }) } + let restore: ReturnType try { - return [ - ...sessionFormatCatalog.decodeArtifact(validationHeader(headerValue), rows).events, - ] as unknown as SessionEvent[] + restore = sessionFormatCatalog.createRestore(validationHeader(headerValue), { + recovery: 'strict', + validation: 'current', + }) } catch (error) { const detail = error instanceof Error ? error.message : String(error) - const storedRow = /\brow (\d+)\b/.exec(detail) - const line = storedRow === null ? 1 : rowLines[Number(storedRow[1])] ?? 1 + throw new Error(`session snapshot line 1: ${detail}`, { cause: error }) + } + for (const [index, row] of rows.entries()) { + try { + restore.decodeRow(validationRow(row)) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`session snapshot line ${rowLines[index] ?? 1}: ${detail}`, { cause: error }) + } + } + try { + return restoreRequestHeaderTokens( + [...restore.finish().events] as unknown as SessionEvent[], + rows, + ) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + const line = fixtureDiagnosticLine(error, rowLines, eventLines) throw new Error(`session snapshot line ${line}: ${detail}`, { cause: error }) } } +function fixtureDiagnosticLine( + error: unknown, + rowLines: readonly number[], + eventLines: readonly number[], +): number { + const detail = error instanceof Error && error.cause instanceof Error + ? error.cause.message + : error instanceof Error ? error.message : String(error) + const physicalRow = /^released Session row (\d+)/.exec(detail) + if (physicalRow !== null) return rowLines[Number(physicalRow[1])] ?? 1 + const event = /Session event (\d+)/.exec(detail) + ?? / at seq (\d+)/.exec(detail) + ?? /inherited Session cut (\d+)/.exec(detail) + return event === null ? 1 : eventLines[Number(event[1])] ?? 1 +} + function withoutEnvelope(events: readonly SessionEvent[]): Array> { return events.map((event) => { const { seq: _seq, time: _time, ...projected } = event