fix(snapshot): preserve packed chunk timing on refresh
This commit is contained in:
@@ -7,7 +7,7 @@ Four layers, importable separately:
|
||||
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
|
||||
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by record position, including each packed run's anchor and, when its arity is unchanged, member gaps, without replacing fresh chunk-fragment arrays; a newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
|
||||
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
|
||||
|
||||
|
||||
@@ -451,6 +451,25 @@ 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
|
||||
@@ -466,8 +485,9 @@ function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Rec
|
||||
/**
|
||||
* 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, and
|
||||
* hook durations where the record shape still matches.
|
||||
* 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.
|
||||
*
|
||||
* @param fresh The newly harvested session JSONL.
|
||||
* @param existing The committed fixture JSONL being refreshed.
|
||||
|
||||
@@ -451,6 +451,43 @@ describe('refreshFixtureReplacements', () => {
|
||||
})
|
||||
|
||||
describe('stabilizeRefreshLog', () => {
|
||||
it('preserves packed member times without flattening fresh chunk boundaries', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":200,"data":{"turn":1,"step":1,"index":0,"dt":[5,7],"texts":["new",""," split"]}}',
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["old","chunk","shape"]}}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['fresh data is null', null, { dt: [1, 2] }],
|
||||
['existing data is null', { dt: [5, 7] }, null],
|
||||
['fresh gaps are not an array', { dt: 'fresh' }, { dt: [1, 2] }],
|
||||
['existing gaps are not an array', { dt: [5, 7] }, { dt: 'existing' }],
|
||||
['the chunk arity changed', { dt: [5, 7, 9] }, { dt: [1, 2] }],
|
||||
])('keeps fresh packed gaps when %s', (_case, freshData, existingData) => {
|
||||
const freshRow = { type: 'reasoning-chunks', seq0: 2, time0: 200, data: freshData }
|
||||
const existingRow = { type: 'reasoning-chunks', seq0: 2, time0: 100, data: existingData }
|
||||
const output = stabilizeRefreshLog(
|
||||
`${JSON.stringify({ type: 'session', id: 'same', createdAt: 200 })}\n${JSON.stringify(freshRow)}\n`,
|
||||
`${JSON.stringify({ type: 'session', id: 'same', createdAt: 100 })}\n${JSON.stringify(existingRow)}\n`,
|
||||
[],
|
||||
).trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
|
||||
expect(output[1]).toStrictEqual({ ...freshRow, time0: 100 })
|
||||
})
|
||||
|
||||
it('aligns volatile times across a newly inserted log event', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200}',
|
||||
|
||||
Reference in New Issue
Block a user