Record once.
Replay forever.
Your agent tests hit a paid, non-deterministic API. cassette records a whole run once — every LLM call AND every tool call — then replays it offline, deterministically, with no API key. The vcrpy of the agent era.
from cendor import cassette @cassette.use("tests/triage.json") # records once, replays forever def test_triage(): out = my_agent.run("My card was charged twice") assert cassette.semantic_match(out, "offers a refund") # whole-run capture: LLM calls AND tool calls, in order # replay is served through core's interceptor — no monkey-patching
Four modes.
One decorator, four behaviors. The default, auto, records when the cassette file is missing and replays when it exists — so the first run is live and every run after is free.
Matching hashes a normalized request (provider, model, messages, stream) — a pluggable normalizer decides what makes two requests "the same". Secrets are redacted on write, but matching hashes the UN-redacted request, so redaction never collapses two distinct calls. promote() turns a production JSONL trace into a replayable regression test.
Same test, two worlds.
The exact same test_triage. Flip the switch, take the network away, and run pytest — then notice which world even looks at the wifi.
▸ flip live ↔ replay · toggle the network · replay never touches the wire
Replay is served through core's interceptor seam before any network client is touched.
Meaning, not string equality.
LLM output rewords itself on every run, so exact-string asserts flake. semantic_match(actual, expected) scores meaning instead. The offline lexical default is recall-oriented — documented as negation-blind — and real semantic backends plug in: local model2vec static embeddings (the [embeddings] extra, no torch), your provider's embeddings, or an LLM judge.
semantic_drift() filters rerecord noise down to real regressions.
Where the edges are — by design.
It accepts negations and supersets — "will NOT refund" matches "refund". For adversarial checks, use the embeddings backend or an LLM judge.
Recording is scoped to the active context; with pytest-xdist, key cassette paths per worker rather than sharing one file.
v1 cassettes still replay (a compatibility read path hashes the old way); new recordings include stream in the match key.
If your prompt or tool contract changes, rerecord and review drift(); replay tests prove stability, not correctness of a new behavior.
Your agent test suite, off the meter.
cassette docs → · pytest + xdist cookbook → · composes with the Cendor stack