Prompt scenario fixtures
Status summary: done — PR #2515 approved path: all checklist items shipped, review follow-ups (per-event snapshots + diffs, abridged renders) and bugbot findings addressed. All seven scenarios live as
markdown fixtures whose request outputs come from the real fold; the explainer
page is generated from them (mini-fold deleted, appendices byte-identical);
-u regenerates everything, plain runs assert freshness.
The interactive explainer (explainers/prompt-sections.html) carries its own
hand-written mini-fold and hand-written "rendered request" outputs. That was
right for designing the system; now that the real fold is merged (#2512) the
explainer's scenarios should become data fixtures managed by tests: the
scenario events are input data, the rendered provider requests are
snapshot-alike output data produced by the REAL fold
(reduceAgentEvents + buildAgentLlmRequestBody), and the explainer page is
generated from the fixtures. Direct inspiration: the sqlfu repo's generate
fixtures (~/src/sqlfu/packages/sqlfu/test/generate/fixtures/validators.md
parsed by fixture-helpers.ts, refreshed via a vitest updateSnapshots
provide flag).
Decisions (best-guess where Misha hasn't specified — marked ⚠️)#
- Location:
apps/os/src/domains/agents/prompt-scenarios/— one fixture.mdper scenario, plusfixture-helpers.ts,prompt-scenarios.test.ts, and the explainer generator. Fixtures sit next to the fold they exercise. - Fixture file format (sqlfu-style markdown):
- Intro prose at the top = the scenario's explainer intro, verbatim.
- One
<details><summary>events</summary>block holding a single```yaml (events.yaml)fence: the ordered event list. Each entry:off,t(elapsed-time label like"1.9s"),type(short form,agents/context-addedetc.),payload, andnote(the LHS card caption). A scenario can declarebase: <other-scenario>in the fence to prepend that scenario's events (scenario 1b/3b build on 1). - One
<details><summary>request @N</summary>block perllm-request-requestedevent, each holding a```yaml (request@N.yaml)fence: the rendered provider request at that offset, as produced by the real fold. These are the snapshot-writable outputs;-uregenerates them, plain runs assert byte-equality. ⚠️ requests-only (not request-so-far at every event) — the actual sends are the thing worth pinning; the page doesn't need perfect parity. - An optional
```yaml (annotations.yaml)fence inside the events block: a list of{request: "@26", find: "<substring of a rendered line>", comment: "<text>"}. The harness inserts# <text>comment lines above the first output line containing the substring — this is how the hand-written inline commentary survives regeneration: comments are declared against the expected output and re-applied on every render, never hand-edited inside the snapshot. An annotation whosefindmatches nothing fails the test (stale annotation = broken explainer).
- Harness (
fixture-helpers.ts): parse fixture → synthesizeStreamEvents → for each request offset run the REALbuildAgentLlmRequestBody({events, llmRequestOffset})→ render stable YAML (small deterministic printer, comments injected per annotations) → compare or rewrite the fixture's output fences. Update mode via the sqlfu pattern:provide: {updateSnapshots: process.argv.includes("-u") || …}inapps/os/vitest.config.ts, read withinject(). - Explainer page is generated, but by the test — not eslint-plugin-codegen.
⚠️ Deviation from the original sketch, for a mechanical reason: our lint
is oxlint and
codegen/codegenonly runs on files oxlint lints — it will never visit an.htmlfile, so lint can't keep the page fresh. Instead the same test suite owns it: a generator rendersexplainers/prompt-sections.htmlfrom (a) a static shell — header, opinion box, vocabulary tables, appendices A–E preserved byte-for-byte — and (b) embedded JSON scenario data derived from the fixtures. In-umode it writes the file; otherwise it asserts the committed page matches. Same enforcement property codegen would have given, one mechanism for fixture outputs and page both. - The page's mini-fold JS is deleted. The page keeps a thin renderer over precomputed data: clicking an event card shows the nearest covering pinned request (or the "no change / due to be sent" note derived from debounce data included in the embedded JSON). Slight interactivity loss vs. computing request-so-far per click — accepted per "doesn't need PERFECT parity, just needs to successfully explain".
- Scenario migration: port scenarios 1, 1b, 2, 3a, 3b, 4, 5 from the page's current JS data into fixtures. The real fold is the truth: where its output differs from the hand-written mini-fold's, keep the real output and adjust event payloads/notes only as needed to stay illustrative. 3a (the anti-pattern) must keep reading as an anti-pattern.
- Serving unchanged: the generated file keeps its path, so the prod
/explainers/prompt-sectionsroute and sync flow are untouched.
Checklist#
- vitest
updateSnapshotsprovide flag in apps/os/vitest.config.tstest.provide.updateSnapshotsfrom-u/--updatein process.argv; read viainject()(module augmentation in fixture-helpers.ts) - fixture format parser + YAML renderer + annotation applier (fixture-helpers.ts), with a focused unit test fixture-helpers.ts (parse/synthesize/validate/render/weave/regenerate) + fixture-helpers.test.ts; every synthesized event is validated against the contract's consumed-event schema so a typo'd payload fails loudly instead of silently dropping from the render
- scenario fixtures ported (1, 1b, 2, 3a, 3b, 4, 5) with outputs generated by the real fold and annotations carrying the existing commentary 1-birth.md … 5-unsay.md; outputs include a derived ✂ cache-cut comment marking divergence from the previous request in the chain
- prompt-scenarios.test.ts: one test per scenario asserting outputs +
annotations up to date;
-urewrites one test per fixture file + one for the generated page; both share the regenerate-in-memory-then-compare path - explainer generator: static shell + embedded fixture-derived JSON → explainers/prompt-sections.html; test asserts freshness; mini-fold deleted; appendices A–E byte-identical explainer-shell.html (thin renderer, no fold) + explainer-generator.ts (cards, badges, sched notes from real reduced state, pane titles); opinion box, vocab tables, and appendices verified byte-identical to the previous committed page
- page still explains: scenario intros, event cards with notes, request YAML with comments, anti-pattern warnings, deep links (#scenario@off) verified in a live browser: default lands on birth@26, card clicks update pane/hash, ⚠ rewrite badges, ✂ + comment line styling, shared base blocks, deep link #british@80 selects and renders on load
- docs breadcrumb: short note in apps/os/AGENTS.md or docs/testing.md
pointing at the fixture workflow (
pnpm vitest -uto refresh) docs/testing.md, "Data fixtures with regenerable outputs"
Implementation log#
- Sources studied: agent-prompt-fold.ts (+ its spec for event literals), agent-processor-contract.ts payload schemas, the capability-host contract (script-run payloads), sqlfu's fixture-helpers.ts + vitest.config.ts, and the full explainer page.
- The real fold forced scenario adjustments the mini-fold never needed
(spec decision 6 says the real fold wins; all deltas below keep each
scenario's teaching intact):
llm-request-settledevents between turns: the reduce ignores a new requested event while another request is open, so every scenario with two requests carries settlements (birth @29, script @35/@40, 3a/3b @75, legacy @38, unsay @94). The harness fails loudly when a pinned requested event reduces to nothing.llm-request-requestedrequiresexpiresAt: the harness derives it (event time + 30s) when a fixture payload omits it — expiry is turn-loop machinery, irrelevant to what the scenarios teach; an explicit value wins. Same forcapability-host/script-run-requested.- Script settlements carry no duration field; the rendered "(in 1.8s)" duration derives from the requested→settled createdAt gap, so 1b's timestamps were set to make that arithmetic true (@34 at 10m 14.2s, @36 at 10m 16s).
- Scenarios 2, 4, and 5 gained a closing user message + request pair so
each pins the render its intro tells the reader to look at (@64, @82,
@92/@97). Scenario 5 also pins the post-
deleterender before thedelete *lobotomy. - 3a: a
context-rewrittenreplace that CREATES a key lands at the collection's tail (temporal position), not in the standing document — the fold's applyContextRewritten appends first occurrences. The @80 replace then swaps content at @70's position, above the lowercase reply it contradicts, and the ✂ line visibly jumps up (cache bust). Intro and notes rewritten to describe that real behavior; scenario 4's @80 note likewise (a first-ever key with conversation present renders temporally, never joins the standing document).
- Page generation: the shell is the committed page minus the script block —
the header paragraph now describes pinned fixtures instead of a live
mini-fold (the orchestrating prompt scoped byte-for-byte preservation to
the opinion box, vocab tables, and appendices; the header had to change to
stay truthful). Scenario JSON embeds per-card precomputed pane titles and
scheduling notes (from the real fold's pendingLlmRequestTrigger + debounce
config), and request bodies keyed
<owningScenario>@<offset>so child scenarios reuse base renders.</is JSON-escaped so no content can close the embedding script tag. - ProvidedContext knock-on: augmenting it for
updateSnapshotsclosed the empty-interface loophole the e2e vitest config'sproviderelied on; that config now declares its four keys. - oxfmt ignores the fixture .md files, the shell, and the generated page (YAML fences and generated bytes must not be reflowed).
- Verified: unit + scenario + page tests green in assert mode and after a
full
-ucycle; apps/os typecheck, repo lint, knip clean; page exercised in a live browser (cards, pane, badges, sched lines, deep links). - Review follow-up (Misha): the pane now shows the request-so-far AS OF the
clicked event again — the generator computes a fold render per chain event
and embeds it in the page JSON (fixture files unchanged: they keep only
the per-request fences). At a requested offset the embedded snapshot IS
the pinned fence, byte for byte (asserted in prompt-scenarios.test.ts; the
client-side reconstruction was hash-verified against the committed fence
in a live browser). Line-diff highlighting restored via
import("https://esm.sh/diff@8.0.4")in the shell (page degrades to no-highlights if the import fails): each snapshot diffs against the PREVIOUS EVENT's — added lines green (--add), remove+add pairs yellow (--chg), bare removals a red strip (--del, new) on the first surviving line. Identical renders show the old "ⓘ no change to the rendered request" note (old wording for configure events; a turn-loop-machinery variant for settlements/script events, which the old page didn't have). Verified: birth @5/@9/@22/@25/@26, 3a @70/@80/@84 (one yellow in-place swap vs 3b @80's five green appended lines — the contrast), legacy @80, unsay @90 (red strip) and @95 (churn). - Embedding stays ~237KB (the old page was ~103KB): per-event renders are
suffix-encoded against the previous event's comment-free render (the
superset property makes suffixes small) with woven
#comments carried as [index, text] inserts, replayed exactly by the client. - ✂-vs-diff baselines differ by design: the harness-woven ✂ line marks divergence from the previous REQUEST (what the provider cache actually compares at a send), while the click-time highlights diff against the previous EVENT. At 3a's @84 the ✂ therefore sits up at the rewritten rule while the green lines mark only the stamp — both true, each labeled.
- Review follow-up (Misha): declared abridgements in the render layer — the printer replaces long fold-injected boilerplate with the explainer's short stand-in before printing, keyed on the imported constant itself (AGENT_CONTEXT_PROTOCOL_PROMPT, now exported from agent-prompt-fold) so the mapping breaks loudly rather than rotting. The protocol renders as the page's original one-liner ending in "…"; fences and page snapshots share the pass (renderMessageLines), so the byte-identity test still holds. Audit of all fences found no other long fold-injected text — every other long-ish line is a fixture-authored stand-in already ending in "…". #birth@5 renders as 6 lines. Page: 227KB.