mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
perf(session-format): stream released v0-to-v2 migrations
This commit is contained in:
1 parent
a84a8da9e1
commit
46196d6f95
31 files changed
+2692
-1122
No files matched your search
@@ -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: [],
|
||||
|
||||
@@ -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}}
|
||||
@@ -869,19 +869,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
* in-memory record-mode harvest, so the on-disk zstd default never matters.
|
||||
*/
|
||||
function rawSessionLog(session: Session): string {
|
||||
const encoded = sessionFormatCatalog.encodeCurrent({
|
||||
header: {
|
||||
...session.header,
|
||||
delegationDepth: session.header.delegationDepth ?? 0,
|
||||
},
|
||||
inheritedEventCount: session.inheritedEventCount,
|
||||
// Session validates durable payloads as JSON; its closed event unions do
|
||||
// not carry the index signature used by the format package's JSON types.
|
||||
events: session.snapshotEvents() as unknown as readonly SessionFormatEvent[],
|
||||
})
|
||||
const encodedEvents = (session.snapshotEvents() as unknown as readonly SessionFormatEvent[])
|
||||
.map(event => 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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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<string, SessionFormatJsonValue>,
|
||||
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<string, SessionFormatJsonValue>,
|
||||
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<SessionFormatEvent> {
|
||||
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<SessionFormatEvent | SessionFormatJsonObject> = []
|
||||
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<Record<string, unknown>>, keys: readonly string[]): boolean {
|
||||
return Object.keys(record).length === keys.length && keys.every(key => Object.hasOwn(record, key))
|
||||
return output
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export { assertReleasedPayloadSemantics } from './payload-validation.ts'
|
||||
export { assertReleasedArtifactRelationships } from './relationships.ts'
|
||||
export {
|
||||
assertReleasedSurfaceMetadata,
|
||||
assertReleasedV1Artifact,
|
||||
assertReleasedEventPayload,
|
||||
assertReleasedV1Header,
|
||||
restoreReleasedV1Artifact,
|
||||
} from './validation.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<number, string>
|
||||
readonly retryIds: Map<string, string>
|
||||
compactionId?: string
|
||||
}
|
||||
|
||||
function normalizeReleasedV0Event(
|
||||
event: SessionFormatEvent,
|
||||
sessionId: string,
|
||||
): readonly SessionFormatEvent[] {
|
||||
const messageIds = new Map<number, string>()
|
||||
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<string, string>,
|
||||
): 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 {
|
||||
|
||||
@@ -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: `<goal_state>${JSON.stringify(payload)}</goal_state>` }]
|
||||
if (!deepEqualJson(message['content'], expected)) {
|
||||
throw new SessionFormatError(`${label} legacy goal content does not match its change`)
|
||||
}
|
||||
}
|
||||
|
||||
function messageSourceValue(
|
||||
value: SessionFormatJsonValue | undefined,
|
||||
label: string,
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ReleasedRelationshipExtensions {
|
||||
readonly stepEvents?: ReadonlySet<string>
|
||||
/** 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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string> = 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)
|
||||
}
|
||||
@@ -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<string> = 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<string>,
|
||||
): 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<string>,
|
||||
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)
|
||||
}
|
||||
@@ -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<SessionFormatEvent | SessionFormatEventRun> = []
|
||||
|
||||
emitEvent(event: SessionFormatEvent): void {
|
||||
this.values.push(event)
|
||||
}
|
||||
|
||||
emitRun(run: SessionFormatEventRun): void {
|
||||
this.values.push(run)
|
||||
}
|
||||
}
|
||||
|
||||
function artifact(events: readonly SessionFormatEvent[], overrides: Partial<SessionFormatArtifact> = {}): SessionFormatArtifact {
|
||||
return {
|
||||
header: { version: 1, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0 },
|
||||
inheritedEventCount: 0,
|
||||
events,
|
||||
...overrides,
|
||||
}
|
||||
function decodeRow(
|
||||
decoder: SessionFormatArtifactDecoder,
|
||||
row: SessionFormatJsonValue,
|
||||
): Array<SessionFormatEvent | SessionFormatEventRun> {
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
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: `<goal_state>${JSON.stringify({ cleared: change.cleared, clearedAt: change.clearedAt })}</goal_state>`,
|
||||
}]
|
||||
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 = [
|
||||
|
||||
@@ -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<string> = 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[] {
|
||||
|
||||
@@ -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'
|
||||
@@ -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<string> = 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<number, AttemptGroup>()
|
||||
const groupByMessage = new Map<number, AttemptGroup>()
|
||||
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<number, number>()
|
||||
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<string, AttemptGroup>()
|
||||
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<string, AttemptGroup>,
|
||||
): 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<number, number>
|
||||
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<AssistantStreamAccumulator['push']>[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<AssistantStreamRecord, { readonly type: 'tool-call-chunks' }>
|
||||
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<AssistantStreamRecord, {
|
||||
readonly type: 'text-chunks' | 'reasoning-chunks'
|
||||
}>
|
||||
;(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<AssistantStreamAccumulator['push']>[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<number, number>,
|
||||
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)
|
||||
}
|
||||
@@ -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<typeof expandAssistantStream>
|
||||
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)
|
||||
}
|
||||
@@ -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<string>,
|
||||
): 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<typeof expandAssistantStream>
|
||||
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')
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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: `<goal_state>${JSON.stringify(payload)}</goal_state>` }],
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
|
||||
@@ -48,15 +48,10 @@ interface ParsedSessionFixture {
|
||||
readonly createdAt: number
|
||||
readonly inheritedEventCount: SessionLogOffsetType
|
||||
readonly events: SessionEvent[]
|
||||
readonly artifact: ReturnType<typeof sessionFormatCatalog.migrate>
|
||||
readonly artifact: ReturnType<ReturnType<typeof sessionFormatCatalog.createRestore>['finish']>
|
||||
readonly sourceHeader: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface FixtureJsonLine {
|
||||
readonly lineNumber: number
|
||||
readonly value: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> | undefined
|
||||
let restore: ReturnType<typeof sessionFormatCatalog.createRestore> | 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<string, unknown> })
|
||||
}
|
||||
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<string, unknown>[] = []
|
||||
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<string, unknown>
|
||||
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<typeof sessionFormatCatalog.decodeArtifact>
|
||||
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<typeof sessionFormatCatalog.decodeArtifact>,
|
||||
artifact: ParsedSessionFixture['artifact'],
|
||||
sourceHeader: Readonly<Record<string, unknown>>,
|
||||
): 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<Record<string, unknown>>[]
|
||||
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<Record<string, unknown>>[]
|
||||
|
||||
const assigned = new Map<number, Readonly<Record<string, unknown>>[]>()
|
||||
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<Record<string, unknown>>,
|
||||
rows: readonly Readonly<Record<string, unknown>>[],
|
||||
): 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.
|
||||
*
|
||||
|
||||
@@ -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<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
|
||||
@@ -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<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
|
||||
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<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
|
||||
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<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
|
||||
@@ -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<typeof import('@deepseek-ai/dsh-session-format-catalog')>()
|
||||
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()
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -58,6 +58,51 @@ function validationHeader(value: unknown): unknown {
|
||||
return header
|
||||
}
|
||||
|
||||
function validationRow(source: Readonly<Record<string, unknown>>): Record<string, unknown> {
|
||||
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<string, unknown>).header
|
||||
if (header === null || typeof header !== 'object' || Array.isArray(header)) return { ...source }
|
||||
if ((header as Record<string, unknown>).tools !== '{{tools}}') return { ...source }
|
||||
return {
|
||||
...source,
|
||||
data: {
|
||||
...data,
|
||||
header: { ...header, tools: [] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function restoreRequestHeaderTokens(
|
||||
events: readonly SessionEvent[],
|
||||
rows: readonly Readonly<Record<string, unknown>>[],
|
||||
): 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<string, unknown>).header
|
||||
: undefined
|
||||
if (sourceHeader === null || typeof sourceHeader !== 'object' || Array.isArray(sourceHeader)
|
||||
|| (sourceHeader as Record<string, unknown>).tools !== '{{tools}}') return event
|
||||
return {
|
||||
...event,
|
||||
data: {
|
||||
...event.data,
|
||||
header: {
|
||||
...(event.data as unknown as { header: Record<string, unknown> }).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<string
|
||||
function parseFixtureRows(content: string, headerValue: unknown): SessionEvent[] {
|
||||
const rows: Record<string, unknown>[] = []
|
||||
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<typeof sessionFormatCatalog.createRestore>
|
||||
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<Omit<SessionEvent, 'seq' | 'time'>> {
|
||||
return events.map((event) => {
|
||||
const { seq: _seq, time: _time, ...projected } = event
|
||||
|
||||
Reference in new issue
Block a user