refactor(acp-example): derive llm-replay script from the session JSONL

Per a design revision, the per-scenario snapshot fixture becomes EXACTLY the
persisted session JSONL (<scenario>/session.jsonl) rather than a hand-authored
llm.json. The log already holds all LLM behavior (assistant/chunk carries every
StreamChunk) AND all harness behavior (tool/call, tool/result, turn/*, usage),
so one artifact drives replay and doubles as a behavioral golden.

llm-replay becomes replay-only (the record-tee is removed; recording is now
"run the real agent once and harvest the .jsonl", done by the harness in a
later commit). deriveReplayScript(events) groups assistant/chunk by (turn,step)
in log order — exact because the loop makes one ctx.llm.stream() call per step
and tags each chunk with the current (turn,step). The two failure modes the log
can't express (a thrown stream — no terminal finish; cancel/hang — timing) use
an optional replay.override.json sidecar.

Hardens against a Codex review finding: a derived group is only valid if it
ends in a `finish` chunk. A group without one is the fingerprint of a thrown
stream() and is NOT silently replayed as a clean stop — deriveReplayScript
throws, naming the (turn,step), so a missing sidecar override fails loud.

Updates the unit tests (parse/derive/load helpers, sidecar override, finish-
terminated grouping, HMR), the example README, and the RFC prose to the JSONL
format. Two goldens (stdout transcript + re-persisted JSONL) and the harness
wiring land in the next commit.
This commit is contained in:
Tianyi Cui
2026-06-19 02:44:33 +08:00
parent 1a1ce734ba
commit c182543dd5
4 changed files with 352 additions and 262 deletions

View File

@@ -1,12 +1,25 @@
/**
* Record/replay LLM plugin for snapshot tests.
* Replay LLM plugin for snapshot tests.
*
* Installs a single `llm/stream` waterfall listener that, in `record` mode,
* tees the real model's streamed {@link StreamChunk}s into a fixture file, and
* in `replay` mode short-circuits the waterfall (never calls `next()`) to yield
* previously-recorded streams deterministically. This is the seam that lets a
* snapshot test boot the real agent against a fixed model transcript with no
* API key — see docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md.
* Installs a single `llm/stream` waterfall listener that short-circuits the
* waterfall (never calls `next()`) and yields model streams reconstructed from
* a recorded **session JSONL** fixture — so a snapshot test can boot the real
* agent against a fixed model transcript with no API key. See
* docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md.
*
* The fixture IS the persisted session log (`<scenario>/session.jsonl`): its
* `assistant/chunk` events carry every {@link StreamChunk}, so grouping them by
* `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model
* call per loop step — see packages/agent-loop/src/loop.ts). Recording is
* therefore "run the real agent once and harvest the `.jsonl`", done by the
* snapshot harness — this plugin does not record.
*
* Two failure modes are NOT reconstructable from `assistant/chunk` alone — a
* pure throw before any chunk (e.g. an HTTP 401: the log holds only a
* `turn/end {error}`, no chunks) and a cancel/hang (timing, not chunk content).
* A scenario that needs those supplies an optional sidecar
* (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the
* derived script.
*
* It lives in the example (not packages/) because it is example/test
* infrastructure with one consumer, exactly like echo-agent's `mock-llm.ts`;
@@ -18,8 +31,9 @@
* so a stray default would drop the namespace — see docs/postmortem/0001).
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
@@ -34,6 +48,11 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
* a mid-stream transport failure (partial output then `STREAM_CLOSED`) replays
* the partial chunks first and only then throws — exactly what the agent loop
* saw live (it may already have emitted partial assistant chunks).
*
* The normal/finish-terminated cases are DERIVED from the session JSONL
* ({@link deriveReplayScript}); only the throw and hang cases need a
* hand-authored sidecar entry (a thrown stream leaves no terminal `finish` in
* the log, so it cannot be derived as `chunks`).
*/
export type ReplayEntry =
| { kind: 'chunks'; chunks: StreamChunk[] }
@@ -42,38 +61,103 @@ export type ReplayEntry =
/** Resolved plugin configuration. */
export interface ReplayConfig {
/** `record` tees the real model to `file`; `replay` serves `file` back. */
mode: 'record' | 'replay'
/** Path to the per-scenario `llm.json` fixture. */
/** Path to the per-scenario `session.jsonl` fixture (the recorded log). */
file: string
/**
* Optional path to a `ReplayEntry[]` sidecar that REPLACES the derived
* script. Used by the two scenarios not expressible as `assistant/chunk`
* (pure throw-before-chunk, cancel/hang). Absent for normal scenarios.
*/
overrideFile?: string
}
/**
* Read and validate a fixture file. Throws a clear, fail-loud error when the
* file is missing (the scenario was never recorded) or malformed — never
* Parse a session `.jsonl` buffer into its event list. Line 0 is the session
* header (a `{type:'session',…}` record), every subsequent non-empty line is a
* {@link SessionEvent}. The header is skipped; malformed lines fail loud.
*/
export function parseSessionLog(text: string): SessionEvent[] {
const lines = text.split('\n').filter(line => line.trim().length > 0)
const events: SessionEvent[] = []
// Skip line 0 (the header). A reader distinguishes it by its `type:'session'`
// tag; we simply drop the first line, which the JSONL backend guarantees is
// the header.
for (let i = 1; i < lines.length; i++) {
const parsed: unknown = JSON.parse(lines[i] as string)
events.push(parsed as SessionEvent)
}
return events
}
/**
* Reconstruct the per-`stream()` replay script from a recorded session log.
*
* The agent loop makes exactly one `ctx.llm.stream()` call per step and appends
* every chunk as an `assistant/chunk` event tagged with the current
* `(turn, step)`. Grouping those events by `(turn, step)` in log order
* therefore yields one `{kind:'chunks'}` entry per model call, in call order.
*
* A group is only valid if it ends in a `finish` chunk — the adapter contract
* guarantees a successful (or finish-error) stream terminates with `finish`,
* and the loop relies on it. A group WITHOUT a terminal `finish` is the
* fingerprint of a *thrown* `stream()` (the loop recorded the prefix chunks,
* then an `error`/`turn/end`, but no `finish`): such a stream cannot be
* faithfully replayed as `{kind:'chunks'}` (that would look like a clean stop),
* so deriving it is an error — the scenario must supply a `replay.override.json`
* sidecar with an explicit `throw` (or `hang`) entry instead. {@link
* deriveReplayScript} throws, naming the offending `(turn, step)`, so a missing
* override fails loud rather than silently replaying a thrown call as success.
*/
export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
const script: ReplayEntry[] = []
let currentKey: string | undefined
let current: StreamChunk[] = []
const close = (key: string | undefined, chunks: StreamChunk[]): void => {
if (chunks.length === 0) return
if (chunks[chunks.length - 1]?.type !== 'finish') {
throw new Error(
`llm-replay: model call ${key} ended without a finish chunk (a thrown stream); `
+ 'this scenario needs a replay.override.json sidecar',
)
}
script.push({ kind: 'chunks', chunks })
}
for (const event of events) {
if (event.type !== 'assistant/chunk') continue
const { turn, step, chunk } = event.data
const key = `${turn}/${step}`
if (key !== currentKey) {
// A new (turn, step) — i.e. a new stream() call. Close the previous one
// (skip the initial empty buffer before any chunk has been seen).
close(currentKey, current)
currentKey = key
current = []
}
current.push(chunk)
}
close(currentKey, current)
return script
}
/**
* Build the replay script for a scenario: the sidecar override if present,
* otherwise the script derived from the recorded session JSONL. Fail-loud if
* the JSONL fixture is missing (the scenario was never recorded) — never
* silently returns an empty script, so a coverage hole can't masquerade as a
* passing replay.
*/
export function loadFixture(file: string): ReplayEntry[] {
if (!existsSync(file)) {
throw new Error(`llm-replay: fixture not found: ${file} — run \`pnpm run test:snapshot:record\` first`)
export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
if (config.overrideFile !== undefined && existsSync(config.overrideFile)) {
const parsed: unknown = JSON.parse(readFileSync(config.overrideFile, 'utf8'))
if (!Array.isArray(parsed)) {
throw new Error(`llm-replay: override is not a JSON array: ${config.overrideFile}`)
}
return parsed as ReplayEntry[]
}
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
if (!Array.isArray(parsed)) {
throw new Error(`llm-replay: fixture is not a JSON array: ${file}`)
if (!existsSync(config.file)) {
throw new Error(`llm-replay: fixture not found: ${config.file} — run \`pnpm run test:snapshot:record\` first`)
}
// The fixture round-trips StreamChunk through JSON; branded CallId fields
// deserialize as plain strings, which are structurally StreamChunk. We trust
// the file shape (it is committed and produced by record mode) rather than
// deep-validating every chunk.
return parsed as ReplayEntry[]
}
/** Atomically write the recorded entries to `file` (temp write + rename). */
function flushFixture(file: string, entries: ReplayEntry[]): void {
const tmp = `${file}.tmp-${process.pid}`
writeFileSync(tmp, `${JSON.stringify(entries, null, 2)}\n`, { encoding: 'utf8' })
renameSync(tmp, file)
return deriveReplayScript(parseSessionLog(readFileSync(config.file, 'utf8')))
}
/** Yield a recorded stream back, honoring abort like a real adapter. */
@@ -107,70 +191,35 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
return
default:
// Closed local union: an unknown kind means malformed (hand-edited or
// drifted) fixture data — fail loud with a runtime diagnostic.
return assertNever(entry, 'llm-replay fixture entry')
// drifted) sidecar data — fail loud with a runtime diagnostic.
return assertNever(entry, 'llm-replay replay entry')
}
}
/**
* Install the record/replay `llm/stream` listener on `ctx`. Returns the
* listener disposer (so a fiber dispose removes it — HMR safety). Exported
* separately from {@link apply} so unit tests can drive it without the Loader
* or env vars.
* Install the replay `llm/stream` listener on `ctx`. Returns the listener
* disposer (so a fiber dispose removes it — HMR safety). Exported separately
* from {@link apply} so unit tests can drive it without the Loader or env vars.
*
* Replay is POSITIONAL: the Nth `stream()` call serves the Nth fixture entry.
* Replay is POSITIONAL: the Nth `stream()` call serves the Nth script entry.
* This is deterministic only with at most one model stream in flight at a time;
* the snapshot harness runs one ACP session per scenario to guarantee that. The
* cursor is advanced synchronously at listener-invocation time (not lazily
* inside the generator) so call ORDER, not iteration order, fixes the mapping.
*/
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
if (config.mode === 'replay') {
const entries = loadFixture(config.file)
let cursor = 0
return ctx.on('llm/stream', (_options: GenerateOptions, _next) => {
const index = cursor++
const entry: ReplayEntry | undefined = entries[index]
return (async function* () {
if (entry === undefined) {
throw new Error(
`llm-replay: fixture exhausted — requested model call #${index + 1} but ${config.file} has only ${entries.length}; re-record the scenario`,
)
}
yield* replayEntry(entry, _options.signal)
})()
})
}
// Record mode: delegate to the real adapter via next(), tee each chunk, and
// flush atomically after EACH completed stream — the subprocess is SIGKILLed
// by the test teardown and start.ts has no disposal path, so a dispose-time
// flush would never run (see the RFC).
const recorded: ReplayEntry[] = []
return ctx.on('llm/stream', (_options: GenerateOptions, next) => {
const inner = next()
const entries = loadReplayScript(config)
let cursor = 0
return ctx.on('llm/stream', (options: GenerateOptions, _next) => {
const index = cursor++
const entry: ReplayEntry | undefined = entries[index]
return (async function* () {
const chunks: StreamChunk[] = []
try {
for await (const chunk of inner) {
chunks.push(chunk)
yield chunk
}
} catch (error) {
const code = error instanceof LlmError ? error.code : 'UNKNOWN'
const status = error instanceof LlmError ? error.status : undefined
const message = error instanceof Error ? error.message : String(error)
// Record the chunks emitted before the throw alongside the error, so
// replay reproduces the same partial output + failure.
const entry: ReplayEntry = status === undefined
? { kind: 'throw', chunks, message, code }
: { kind: 'throw', chunks, message, code, status }
recorded.push(entry)
flushFixture(config.file, recorded)
throw error
if (entry === undefined) {
throw new Error(
`llm-replay: script exhausted — requested model call #${index + 1} but the fixture has only ${entries.length}; re-record the scenario`,
)
}
recorded.push({ kind: 'chunks', chunks })
flushFixture(config.file, recorded)
yield* replayEntry(entry, options.signal)
})()
})
}
@@ -179,17 +228,17 @@ export const name = 'llm-replay'
export const inject = ['llm']
export interface Config {
/** Override the mode; defaults to `$DSH_SNAPSHOT` (`record`) else `replay`. */
mode?: 'record' | 'replay'
/** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */
file?: string
/** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */
overrideFile?: string
}
export function apply(ctx: Context, config: Config = {}): void {
const mode = config.mode ?? (process.env.DSH_SNAPSHOT === 'record' ? 'record' : 'replay')
const file = config.file ?? process.env.DSH_SNAPSHOT_FILE
if (file === undefined || file.length === 0) {
throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)')
}
installLlmReplay(ctx, { mode, file })
const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE
installLlmReplay(ctx, overrideFile === undefined || overrideFile.length === 0 ? { file } : { file, overrideFile })
}