fix(snapshot): align packed refresh by logical event

This commit is contained in:
Tianyi Cui
2026-07-22 21:28:38 +08:00
parent 4529a22e85
commit 479e590029
5 changed files with 102 additions and 47 deletions

View File

@@ -42,6 +42,8 @@ const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
/** Stable session-log token standing in for the sidecar's initial schemas. */
const TOOLS_TOKEN = '{{tools}}'
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
/** A snapshot scenario and how its fixtures are produced. */
export interface Scenario {
name: string
@@ -398,6 +400,23 @@ function parseJsonlRecords(text: string): Record<string, unknown>[] {
.map(line => JSON.parse(line) as Record<string, unknown>)
}
/** One packed row's member times, or `undefined` for an ordinary record. */
function packedTimes(record: Record<string, unknown>): number[] | undefined {
if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined
const row = record as unknown as { time0: number; data: { dt: number[] } }
const times = [row.time0]
for (const gap of row.data.dt) times.push((times[times.length - 1] as number) + gap)
return times
}
/** Expand packed timing envelopes so refresh alignment follows logical events, not physical lines. */
function logicalRecords(records: Record<string, unknown>[]): Record<string, unknown>[] {
return records.flatMap((record) => {
const times = packedTimes(record)
return times === undefined ? [record] : times.map(time => ({ type: 'assistant/chunk', time }))
})
}
/**
* Find tool calls whose structured result reports `UNKNOWN_TOOL`.
*
@@ -451,25 +470,6 @@ function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Rec
return
}
if ('time' in record && 'time' in existing) record.time = existing.time
if (
(record.type === 'text-chunks' || record.type === 'reasoning-chunks' || record.type === 'tool-call-chunks')
&& 'time0' in record && 'time0' in existing
) {
record.time0 = existing.time0
const data = record.data
const existingData = existing.data
if (data !== null && typeof data === 'object' && existingData !== null && typeof existingData === 'object') {
const gaps = (data as { dt?: unknown }).dt
const existingGaps = (existingData as { dt?: unknown }).dt
// Equal arity means every preserved gap still belongs to the same fresh
// chunk position. Payload arrays remain fresh because their boundaries
// are meaningful replay behavior, not volatile timing.
if (Array.isArray(gaps) && Array.isArray(existingGaps) && gaps.length === existingGaps.length) {
const preservedGaps = existingGaps as unknown[]
(data as { dt: unknown[] }).dt = [...preservedGaps]
}
}
}
if (record.type !== 'hook/result') return
const data = record.data
const existingData = existing.data
@@ -482,12 +482,32 @@ function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Rec
}
}
/** Carry logical member times into a fresh packed row while leaving its fragment arrays untouched. */
function preservePackedMemberTimes(
record: Record<string, unknown>,
existingMembers: Record<string, unknown>[],
): void {
if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return
const row = record as unknown as { time0: number; data: { dt: number[] } }
const firstTime = existingMembers[0]?.time
if (!Number.isSafeInteger(firstTime)) return
row.time0 = firstTime as number
if (existingMembers.length !== row.data.dt.length + 1) return
const times = existingMembers.map(member => Number.isSafeInteger(member.time) ? member.time as number : undefined)
if (times.some(time => time === undefined)) return
const memberTimes = times as number[]
const gaps = memberTimes.slice(1).map((time, index) => time - (memberTimes[index] as number))
if (gaps.some(gap => !Number.isSafeInteger(gap))) return
row.data.dt = gaps
}
/**
* Rewrite a fresh replay-produced log so repeated refreshes do not churn
* volatile fixture fields. Meaningful event payloads come from `fresh`; the
* existing fixture lends session ids, cwd, creation times, event times,
* packed-run anchors and same-arity gaps, and hook durations where the record
* shape still matches.
* existing fixture lends session ids, cwd, creation times, logical event
* times, and hook durations where the record shape still matches. Packed
* timing envelopes expand for alignment, so packing does not shift later
* records; fresh fragment arrays remain authoritative.
*
* @param fresh The newly harvested session JSONL.
* @param existing The committed fixture JSONL being refreshed.
@@ -497,21 +517,23 @@ function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Rec
export function stabilizeRefreshLog(fresh: string, existing: string, replacements: FixtureReplacement[]): string {
let stable = fresh
for (const { from, to } of replacements) stable = stable.split(from).join(to)
const existingRecords = parseJsonlRecords(existing)
const existingRecords = logicalRecords(parseJsonlRecords(existing))
const records = parseJsonlRecords(stable)
let existingIndex = 0
let previousEventTime: unknown
for (let i = 0; i < records.length; i++) {
const record = records[i] as Record<string, unknown>
const existingRecord = existingRecords[existingIndex]
const memberCount = packedTimes(record)?.length ?? 1
const insertedTitle = record.type === 'session/title' && existingRecord?.type !== 'session/title'
if (insertedTitle) {
/* v8 ignore next -- a title is turn-enclosed, so a preceding event time exists in every valid fixture. */
if (typeof previousEventTime !== 'number') throw new Error('acp-snapshot: inserted title has no preceding event time')
record.time = previousEventTime
} else {
preservePackedMemberTimes(record, existingRecords.slice(existingIndex, existingIndex + memberCount))
preserveFixtureVolatiles(record, existingRecord)
existingIndex += 1
existingIndex += memberCount
}
if (typeof record.time === 'number') previousEventTime = record.time
}