refactor: derive snapshot session fixtures from disk
This commit is contained in:
@@ -6,7 +6,7 @@ Three layers, importable separately:
|
||||
|
||||
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo).
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/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 golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) 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-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time.
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) 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-scrubbed, non-pinning fixtures fully header-scrubbed). 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:
|
||||
|
||||
|
||||
@@ -80,14 +80,6 @@ export interface Scenario {
|
||||
* false (replay derives from the fixture's `assistant/chunk` events).
|
||||
*/
|
||||
overridden?: boolean
|
||||
/**
|
||||
* How many SUBAGENT child sessions this scenario records beyond the top-level
|
||||
* one (0 for a single-session scenario). Each child rides in a sibling fixture
|
||||
* `session.<n>.jsonl` (1-based); replay forwards them to `dsh-llm-replay` so
|
||||
* each child session replays from its own script, and record mode writes the
|
||||
* harvested child logs back to those files. Defaults to 0.
|
||||
*/
|
||||
childSessions?: number
|
||||
/**
|
||||
* Whether THIS scenario pins its header class's model-facing request-header
|
||||
* content. Its actual composed prompt is maintained as a readable
|
||||
@@ -150,14 +142,40 @@ export interface SnapshotSuiteOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sibling child-fixture paths for a scenario (`session.1.jsonl` …).
|
||||
* Validate and order a scenario directory's session-fixture filenames.
|
||||
*
|
||||
* @param dir The scenario's snapshots directory (`<snapshotsDir>/<name>`).
|
||||
* @param childSessions How many subagent child sessions the scenario records.
|
||||
* @returns One path per child, 1-based, in fixture order.
|
||||
* The primary fixture is always `session.jsonl`; child sessions are discovered
|
||||
* from contiguous `session.1.jsonl` … filenames. The directory is the source of
|
||||
* truth, so scenario tables do not duplicate a child count that can drift from
|
||||
* the files. A session-like JSONL with any other suffix fails loud.
|
||||
*
|
||||
* @param names File names in one scenario directory.
|
||||
* @returns The primary and child fixture names in replay/harvest order.
|
||||
*/
|
||||
export function childFixturePaths(dir: string, childSessions: number): string[] {
|
||||
return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`))
|
||||
export function sessionFixtureNames(names: readonly string[]): string[] {
|
||||
if (!names.includes('session.jsonl')) throw new Error('missing session.jsonl')
|
||||
const children: { name: string; index: number }[] = []
|
||||
for (const name of names) {
|
||||
if (name === 'session.jsonl') continue
|
||||
if (!name.startsWith('session.') || !name.endsWith('.jsonl')) continue
|
||||
const match = /^session\.([1-9]\d*)\.jsonl$/.exec(name)
|
||||
if (match === null) throw new Error(`invalid child session fixture name: ${name}`)
|
||||
children.push({ name, index: Number(match[1]) })
|
||||
}
|
||||
children.sort((a, b) => a.index - b.index)
|
||||
for (const [offset, child] of children.entries()) {
|
||||
const expected = offset + 1
|
||||
if (child.index !== expected) {
|
||||
throw new Error(`child session fixtures must be contiguous: expected session.${expected}.jsonl, found ${child.name}`)
|
||||
}
|
||||
}
|
||||
return ['session.jsonl', ...children.map(child => child.name)]
|
||||
}
|
||||
|
||||
/** Read one scenario directory's validated session-fixture inventory. */
|
||||
async function sessionFixtures(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
return sessionFixtureNames(entries.filter(entry => entry.isFile()).map(entry => entry.name))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,7 +410,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
const workspaceDir = join(dir, 'workspace')
|
||||
const childSessions = scenario.childSessions ?? 0
|
||||
const fixtureFiles = await sessionFixtures(dir)
|
||||
const childFixtureFiles = fixtureFiles.slice(1)
|
||||
const childSessions = childFixtureFiles.length
|
||||
const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn
|
||||
const result = await runScenario(input, {
|
||||
agent,
|
||||
@@ -401,7 +421,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
...existsSync(overrideFile) ? { overrideFile } : {},
|
||||
// In REPLAY, forward the recorded child fixtures so each subagent session
|
||||
// replays from its own script. In RECORD they are harvested, not read.
|
||||
...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {},
|
||||
...!RECORDING && childSessions > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {},
|
||||
...existsSync(workspaceDir) ? { workspaceDir } : {},
|
||||
// A scenario booting an overlay tree passes its own live config; the
|
||||
// bin's replay swap derives the sibling `*cordis.snapshot.yml` from it.
|
||||
@@ -432,7 +452,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
const scrub = scenario.pinsHeader === true
|
||||
? scrubSystemPrompts
|
||||
: scrubRequestHeaders
|
||||
const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)]
|
||||
const existingFixtures = REFRESHING
|
||||
? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8')))
|
||||
: []
|
||||
@@ -543,7 +562,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
expect(onDisk).toEqual(registered)
|
||||
})
|
||||
|
||||
it('every registered scenario has its required fixture files', () => {
|
||||
it('every registered scenario has its required fixture files', async () => {
|
||||
// Every scenario has an input script and an stdout golden. EVERY scenario
|
||||
// also needs `session.jsonl`: the suite boots `llm-replay` with that path
|
||||
// as the replay source for ALL scenarios (the factory passes
|
||||
@@ -556,7 +575,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
// `overridden` flag: required when set, forbidden when not — the harness
|
||||
// forwards the file purely on existence, so an unregistered stray sidecar
|
||||
// would silently replace the derived script.
|
||||
for (const { name, overridden, childSessions, pinsHeader } of scenarios) {
|
||||
for (const { name, overridden, pinsHeader } of scenarios) {
|
||||
const dir = join(snapshotsDir, name)
|
||||
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
|
||||
expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true)
|
||||
@@ -565,11 +584,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
.toBe(overridden === true)
|
||||
expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``)
|
||||
.toBe(pinsHeader === true)
|
||||
// A nested-agent scenario ships one child fixture per recorded subagent
|
||||
// session (`session.1.jsonl` …), the replay source for that child session.
|
||||
for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) {
|
||||
expect(existsSync(childFixture), childFixture).toBe(true)
|
||||
}
|
||||
await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -615,10 +630,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
// all header bulk. Fixed-point checks make both storage rules fail loud.
|
||||
for (const scenario of scenarios) {
|
||||
const dir = join(snapshotsDir, scenario.name)
|
||||
const files = [
|
||||
'session.jsonl',
|
||||
...Array.from({ length: scenario.childSessions ?? 0 }, (_, i) => `session.${i + 1}.jsonl`),
|
||||
]
|
||||
const files = await sessionFixtures(dir)
|
||||
for (const file of files) {
|
||||
const fixture = await readFile(join(dir, file), 'utf8')
|
||||
expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`)
|
||||
|
||||
@@ -6,13 +6,13 @@ import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts'
|
||||
import {
|
||||
childFixturePaths,
|
||||
fixtureContext,
|
||||
formatSystemPromptSnapshot,
|
||||
headerChangeCount,
|
||||
normalizedHeaders,
|
||||
normalizedSystemPrompts,
|
||||
refreshFixtureReplacements,
|
||||
sessionFixtureNames,
|
||||
stabilizeRefreshLog,
|
||||
} from '../src/suite.ts'
|
||||
|
||||
@@ -51,7 +51,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.
|
||||
// example's code-mode scenarios).
|
||||
const REPLAY_SCENARIOS: Scenario[] = [
|
||||
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' },
|
||||
{ name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath },
|
||||
{ name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath },
|
||||
{ name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' },
|
||||
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' },
|
||||
{ name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' },
|
||||
@@ -59,7 +59,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
|
||||
|
||||
const RECORD_SCENARIOS: Scenario[] = [
|
||||
{ name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
|
||||
{ name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
{ name: 'rec-child', hasModelTurn: true, recorded: true },
|
||||
// recorded:false in record mode → registered but skipped (never re-recorded).
|
||||
{ name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
|
||||
]
|
||||
@@ -180,13 +180,41 @@ describe('defineAcpSnapshotSuite: registration contract', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('childFixturePaths', () => {
|
||||
it('yields one sibling path per child, 1-based', () => {
|
||||
expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl'])
|
||||
describe('sessionFixtureNames', () => {
|
||||
it('orders the primary and contiguous child fixtures while ignoring other files', () => {
|
||||
expect(sessionFixtureNames([
|
||||
'stdout.golden.jsonl',
|
||||
'session.2.jsonl',
|
||||
'session.jsonl',
|
||||
'session.1.jsonl',
|
||||
'input.json',
|
||||
])).toEqual(['session.jsonl', 'session.1.jsonl', 'session.2.jsonl'])
|
||||
})
|
||||
|
||||
it('yields nothing for a single-session scenario', () => {
|
||||
expect(childFixturePaths('/snap/s', 0)).toEqual([])
|
||||
it('accepts a primary-only scenario', () => {
|
||||
expect(sessionFixtureNames(['session.jsonl'])).toEqual(['session.jsonl'])
|
||||
})
|
||||
|
||||
it('rejects a directory without the primary fixture', () => {
|
||||
expect(() => sessionFixtureNames(['session.1.jsonl'])).toThrow('missing session.jsonl')
|
||||
})
|
||||
|
||||
it('rejects gapped child fixtures', () => {
|
||||
expect(() => sessionFixtureNames(['session.jsonl', 'session.2.jsonl']))
|
||||
.toThrow('expected session.1.jsonl, found session.2.jsonl')
|
||||
})
|
||||
|
||||
it.each(['session.0.jsonl', 'session.child.jsonl', 'session.01.jsonl'])(
|
||||
'rejects invalid child fixture name %s',
|
||||
(name) => {
|
||||
expect(() => sessionFixtureNames(['session.jsonl', name]))
|
||||
.toThrow(`invalid child session fixture name: ${name}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects duplicate child indexes', () => {
|
||||
expect(() => sessionFixtureNames(['session.jsonl', 'session.1.jsonl', 'session.1.jsonl']))
|
||||
.toThrow('expected session.2.jsonl, found session.1.jsonl')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user