Two safe-integer timestamps can differ by more than 2^53-1 (e.g. MIN_SAFE_INTEGER to MAX_SAFE_INTEGER-1), so the dt subtraction rounds and the packed row decodes to a timestamp one off the original -- violating the codec's lossless contract. Unreachable from a real clock (the gap needs ~285k years) but reachable from hand-written fixtures, and the decoder accepts hand-written rows. continues() now refuses to extend a run across such a gap (the check is exact both ways: an in-range true gap subtracts without rounding and passes; an out-of-range one rounds to an out-of-range value and fails), splitting the run instead -- whitelist philosophy, compression lost, data never. The decoder tightens to match the encoder's image: time0 and dt must be safe integers, and reconstructed member seqs/times must stay in safe range, so float reconstruction is exact wherever validation passes. The round-trip property now draws times from the full safe-integer range (it previously generated only small gaps, which is how this escaped); the bot's counterexample is pinned as an example test. Found by ds-review-bot on #338.
237 lines
13 KiB
TypeScript
237 lines
13 KiB
TypeScript
/**
|
|
* Chunk-row codec tests: pack/expand round-trip losslessness (example-based and
|
|
* property-based), run-boundary rules, whitelist fall-through, and decoder
|
|
* validation failures.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import fc from 'fast-check'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
|
|
import type { ChunkRow, SessionEvent, StorageRecord } from '@deepseek-ai/dsh-session'
|
|
|
|
/** Build an `assistant/chunk` event with the exact live-append shape. */
|
|
function chunkEvent(seq: number, time: number, chunk: StreamChunk, turn = 1, step = 1): SessionEvent {
|
|
return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk } }
|
|
}
|
|
|
|
/** Sequential delta events (contiguous seqs, fixed 10ms gaps) of one kind. */
|
|
function deltaRun(kind: 'text-delta' | 'reasoning-delta', count: number, seq0 = 0, index = 0): SessionEvent[] {
|
|
return Array.from({ length: count }, (_, k) =>
|
|
chunkEvent(seq0 + k, 1000 + 10 * k, { type: kind, index, text: `t${k}` }))
|
|
}
|
|
|
|
/** Decode a packed record list back to a flat event list. */
|
|
function decodeAll(records: readonly StorageRecord[]): SessionEvent[] {
|
|
return records.flatMap(record => decodeStorageRecord(JSON.parse(JSON.stringify(record))))
|
|
}
|
|
|
|
describe('packChunkRuns', () => {
|
|
it('packs a text-delta run into one text-chunks row and round-trips it', () => {
|
|
const events = deltaRun('text-delta', 5)
|
|
const packed = packChunkRuns(events)
|
|
expect(packed).toHaveLength(1)
|
|
const row = packed[0] as ChunkRow
|
|
expect(row.type).toBe('text-chunks')
|
|
expect(row.seq0).toBe(0)
|
|
expect(row.time0).toBe(1000)
|
|
expect(row.data).toMatchObject({ turn: 1, step: 1, index: 0, dt: [10, 10, 10, 10], texts: ['t0', 't1', 't2', 't3', 't4'] })
|
|
expect(decodeAll(packed)).toStrictEqual(events)
|
|
})
|
|
|
|
it('packs reasoning and tool-call runs under their own tags', () => {
|
|
const reasoning = deltaRun('reasoning-delta', 3)
|
|
const toolCall = [4, 5, 6].map(seq =>
|
|
chunkEvent(seq, 1000 + seq, { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'write', argumentsDelta: `a${seq}` }))
|
|
const packed = packChunkRuns([...reasoning, ...toolCall])
|
|
expect(packed.map(r => (r as ChunkRow).type)).toStrictEqual(['reasoning-chunks', 'tool-call-chunks'])
|
|
const row = packed[1] as ChunkRow & { type: 'tool-call-chunks' }
|
|
expect(row.data).toMatchObject({ id: 'c1', name: 'write', args: ['a4', 'a5', 'a6'] })
|
|
expect(decodeAll(packed)).toStrictEqual([...reasoning, ...toolCall])
|
|
})
|
|
|
|
it('packs a name-less tool-call run and round-trips field absence', () => {
|
|
const events = [0, 1, 2].map(seq =>
|
|
chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: CallId('c1'), argumentsDelta: `a${seq}` }))
|
|
const packed = packChunkRuns(events)
|
|
expect(packed).toHaveLength(1)
|
|
expect(Object.hasOwn((packed[0] as ChunkRow).data, 'name')).toBe(false)
|
|
const decoded = decodeAll(packed)
|
|
expect(decoded).toStrictEqual(events)
|
|
expect(decoded.every(e => !Object.hasOwn((e.data as { chunk: object }).chunk, 'name'))).toBe(true)
|
|
})
|
|
|
|
it('leaves runs shorter than three events verbatim', () => {
|
|
const events = deltaRun('text-delta', 2)
|
|
expect(packChunkRuns(events)).toStrictEqual(events)
|
|
})
|
|
|
|
it('leaves non-delta chunks and non-chunk events verbatim between runs', () => {
|
|
const events: SessionEvent[] = [
|
|
chunkEvent(0, 1000, { type: 'block-start', index: 0, blockType: 'text' }),
|
|
...deltaRun('text-delta', 3, 1),
|
|
chunkEvent(4, 1040, { type: 'block-end', index: 0, block: { type: 'text', text: 't0t1t2' } }),
|
|
{ type: 'step/end', seq: 5, time: 1050, data: { turn: 1, step: 1 } },
|
|
]
|
|
const packed = packChunkRuns(events)
|
|
expect(packed).toHaveLength(4)
|
|
expect((packed[1] as ChunkRow).type).toBe('text-chunks')
|
|
expect(decodeAll(packed)).toStrictEqual(events)
|
|
})
|
|
|
|
it.each([
|
|
['a seq gap', deltaRun('text-delta', 3).map((e, k) => ({ ...e, seq: k === 2 ? 9 : e.seq }))],
|
|
['a kind switch', [...deltaRun('text-delta', 2), ...deltaRun('reasoning-delta', 1, 2)]],
|
|
['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]],
|
|
['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(e.seq, e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)],
|
|
])('breaks a run on %s (both halves too short to pack)', (_label, events) => {
|
|
expect(packChunkRuns(events as SessionEvent[])).toStrictEqual(events)
|
|
})
|
|
|
|
it('breaks a tool-call run on call-id or name change', () => {
|
|
const call = (seq: number, id: string, name?: string): SessionEvent =>
|
|
chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: CallId(id), ...name !== undefined ? { name } : {}, argumentsDelta: 'a' })
|
|
const idSwitch = [call(0, 'c1', 'w'), call(1, 'c1', 'w'), call(2, 'c2', 'w')]
|
|
expect(packChunkRuns(idSwitch)).toStrictEqual(idSwitch)
|
|
const namePresence = [call(0, 'c1', 'w'), call(1, 'c1', 'w'), call(2, 'c1')]
|
|
expect(packChunkRuns(namePresence)).toStrictEqual(namePresence)
|
|
})
|
|
|
|
it('stores an off-whitelist delta verbatim (extra field, bad type, fractional time)', () => {
|
|
const extraField = { ...chunkEvent(0, 1000, { type: 'text-delta', index: 0, text: 'x' }), surfaceOp: 'append' }
|
|
const badText = chunkEvent(1, 1001, { type: 'text-delta', index: 0, text: 7 as unknown as string })
|
|
const fractionalTime = chunkEvent(2, 1001.5, { type: 'text-delta', index: 0, text: 'y' })
|
|
const events = [extraField, badText, fractionalTime] as SessionEvent[]
|
|
expect(packChunkRuns(events)).toStrictEqual(events)
|
|
})
|
|
|
|
it('breaks a run on a time gap beyond safe-integer range (subtraction would round)', () => {
|
|
// Both endpoints are safe integers, but their true difference (~2^54)
|
|
// exceeds exact double range: b - a rounds, so a + (b - a) !== b and a
|
|
// packed row would decode to a different timestamp.
|
|
const a = Number.MIN_SAFE_INTEGER
|
|
const b = Number.MAX_SAFE_INTEGER - 1
|
|
expect(a + (b - a)).not.toBe(b) // the rounding this guard exists for
|
|
const events = [
|
|
chunkEvent(0, a, { type: 'text-delta', index: 0, text: 'x' }),
|
|
chunkEvent(1, b, { type: 'text-delta', index: 0, text: 'y' }),
|
|
chunkEvent(2, b + 1, { type: 'text-delta', index: 0, text: 'z' }),
|
|
]
|
|
expect(packChunkRuns(events)).toStrictEqual(events) // split at the gap; halves too short
|
|
expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
|
|
})
|
|
|
|
it('stores a delta with an off-whitelist data envelope verbatim (parsed-fixture shapes)', () => {
|
|
const mk = (seq: number, data: unknown): SessionEvent =>
|
|
({ type: 'assistant/chunk', seq, time: 1000, data } as SessionEvent)
|
|
const events = [
|
|
mk(0, 'not-an-object'),
|
|
mk(1, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' }, extra: 1 }),
|
|
mk(2, { turn: 'x', step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
|
|
mk(3, { turn: 1, step: 1, chunk: 'not-an-object' }),
|
|
mk(4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 'x', text: 'a' } }),
|
|
mk(5, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 7, argumentsDelta: 'a' } }),
|
|
mk(6, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'c', name: 7, argumentsDelta: 'a' } }),
|
|
]
|
|
expect(packChunkRuns(events)).toStrictEqual(events)
|
|
})
|
|
})
|
|
|
|
describe('decodeStorageRecord', () => {
|
|
it('passes non-row values through as single events, unvalidated', () => {
|
|
const event = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
|
|
expect(decodeStorageRecord(event)).toStrictEqual([event])
|
|
expect(decodeStorageRecord('junk')).toStrictEqual(['junk'])
|
|
expect(decodeStorageRecord(null)).toStrictEqual([null])
|
|
})
|
|
|
|
it('reconstructs timestamps through negative dt gaps (clock stepped back)', () => {
|
|
const events = [
|
|
chunkEvent(0, 1000, { type: 'text-delta', index: 0, text: 'a' }),
|
|
chunkEvent(1, 990, { type: 'text-delta', index: 0, text: 'b' }),
|
|
chunkEvent(2, 995, { type: 'text-delta', index: 0, text: 'c' }),
|
|
]
|
|
expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
|
|
})
|
|
|
|
it.each([
|
|
['a non-object data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'x' }],
|
|
['an envelope with extra keys', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] }, extra: 1 }],
|
|
['a negative seq0', { type: 'text-chunks', seq0: -1, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
|
|
['a non-finite time0', { type: 'text-chunks', seq0: 0, time0: Infinity, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
|
|
['a fractional time0', { type: 'text-chunks', seq0: 0, time0: 1.5, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
|
|
['a data shape mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }],
|
|
['a non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: [7] } }],
|
|
['an empty member list', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: [] } }],
|
|
['a dt arity mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [1, 2], texts: ['a', 'b'] } }],
|
|
['a non-finite dt gap', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [NaN], texts: ['a', 'b'] } }],
|
|
['a fractional dt gap', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0.5], texts: ['a', 'b'] } }],
|
|
['a member seq leaving safe range', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
|
|
['a member time leaving safe range', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1], texts: ['a', 'b'] } }],
|
|
['a non-numeric turn', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 'x', step: 1, index: 0, dt: [], texts: ['a'] } }],
|
|
['a tool-call row without id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }],
|
|
['a tool-call row with non-string id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 7, dt: [], args: ['a'] } }],
|
|
['a tool-call row with non-string name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'c', name: 7, dt: [], args: ['a'] } }],
|
|
])('throws on %s', (_label, row) => {
|
|
expect(() => decodeStorageRecord(row)).toThrow(/malformed .* storage row/)
|
|
})
|
|
})
|
|
|
|
// --- Property: pack∘decode is the identity over arbitrary event batches ---
|
|
|
|
const deltaChunkArb: fc.Arbitrary<StreamChunk> = fc.oneof(
|
|
fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
|
|
fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
|
|
fc.record({
|
|
type: fc.constant<'tool-call-delta'>('tool-call-delta'),
|
|
index: fc.nat(2),
|
|
id: fc.constantFrom(CallId('c1'), CallId('c2')),
|
|
argumentsDelta: fc.string(),
|
|
}),
|
|
fc.record({
|
|
type: fc.constant<'tool-call-delta'>('tool-call-delta'),
|
|
index: fc.nat(2),
|
|
id: fc.constantFrom(CallId('c1'), CallId('c2')),
|
|
name: fc.constantFrom('write', 'read'),
|
|
argumentsDelta: fc.string(),
|
|
}),
|
|
)
|
|
|
|
const boundaryChunkArb: fc.Arbitrary<StreamChunk> = fc.oneof(
|
|
fc.record({ type: fc.constant<'block-start'>('block-start'), index: fc.nat(2), blockType: fc.constant<'text'>('text') }),
|
|
fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
|
|
)
|
|
|
|
/**
|
|
* Batches with contiguous seqs, arbitrary timestamps, mixed chunk kinds and
|
|
* turn/step placement. Times draw from the FULL safe-integer range (not just
|
|
* realistic clocks) so the property exercises the gap-overflow guard: two safe
|
|
* endpoints can differ by more than a double subtracts exactly.
|
|
*/
|
|
const batchArb: fc.Arbitrary<SessionEvent[]> = fc.array(
|
|
fc.record({
|
|
chunk: fc.oneof({ weight: 4, arbitrary: deltaChunkArb }, { weight: 1, arbitrary: boundaryChunkArb }),
|
|
time: fc.oneof(
|
|
{ weight: 4, arbitrary: fc.integer({ min: 995, max: 9000 }) },
|
|
{ weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
|
|
),
|
|
turn: fc.nat(1),
|
|
step: fc.nat(1),
|
|
}),
|
|
{ maxLength: 40 },
|
|
// JSON round-trip normalizes fast-check's null-prototype records into the
|
|
// plain objects real log events are (the log is JSON), so equality compares
|
|
// values, not prototypes.
|
|
).map(entries => JSON.parse(JSON.stringify(
|
|
entries.map((entry, k) => chunkEvent(k, entry.time, entry.chunk, entry.turn, entry.step)),
|
|
)) as SessionEvent[])
|
|
|
|
describe('chunk-row codec properties', () => {
|
|
it('JSON-serialized pack∘decode reproduces every batch exactly', () => {
|
|
fc.assert(fc.property(batchArb, (events) => {
|
|
expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
|
|
}))
|
|
})
|
|
})
|