The session wrapper (internal/session) talks to a claude -p --input-format stream-json subprocess. Real subprocess interactions
are non-deterministic — they need credentials, the API can time out,
and responses vary between calls — which makes them unsuitable for
CI.
internal/session/cassette solves this with a VCR-style record/replay
layer: capture a real session once, replay its I/O deterministically
forever.
Architecture¶
Cassettes are subprocess-level, not HTTP-level. Claude makes its own API calls inside the subprocess; we never see the HTTP traffic. Cassettes therefore capture the observable I/O of the claude binary:
stdin — user messages the session wrote to claude
stdout — stream-json frames claude wrote back
timing — relative timestamps between frames
On-disk format is JSON so diffs are readable in code review:
{
"version": 1,
"recorded_at": "2026-04-12T10:15:00Z",
"args": ["--input-format", "stream-json"],
"events": [
{"t": 0.00, "dir": "in", "frame": {...}},
{"t": 1.23, "dir": "out", "frame": {...}}
]
}
Recording¶
Recording requires a working Claude Code install (so the real claude binary can authenticate via the operator’s credentials). Typical pattern inside a test:
rec, err := cassette.NewRecorder(cassettePath, realClaudeBin, args)
if err != nil { t.Fatal(err) }
defer rec.Close()
sess, _ := session.Spawn(session.Options{
ClaudeBin: rec.BinPath(), // recorder wraps the real binary
Args: args,
})
// drive the session normally; rec.Close() flushes the JSON
Replaying¶
The replayer is a tiny standalone binary at
internal/session/cassette/replayer. It reads the cassette and emits
the recorded stdout frames with their recorded timing. Point the
session at it and it looks like real claude, minus the credentials:
os.Setenv("RALPH_CASSETTE_PATH", cassettePath)
sess, _ := session.Spawn(session.Options{
ClaudeBin: cassette.ReplayerPath, // built test-side
Args: args,
})
When to re-record¶
Re-record when:
The stream-json schema changes (claude adds/removes fields)
The test scenario changes (you want a different conversation)
The recording has drifted from reality (unlikely — the cassette should be stable across claude versions for the same args)
Don’t re-record for minor assertion tweaks — those belong in test code, not cassette data.
Where cassettes live¶
Tests that use cassettes keep them alongside the test file:
internal/session/
├── session.go
├── session_test.go
└── testdata/
└── basic_ping.cassette.json
testdata/ is the Go convention — go test excludes it from build
paths but embed.FS can pull it in at test time. Tests that need a
cassette fail loudly (not skip) if the file is missing, so a stale
test suite is visible immediately.
Writing a new test with a cassette¶
Write the test using the replayer. Point it at a cassette path that doesn’t exist yet.
Run the test — it’ll fail with “cassette not found”.
Switch to the recorder wrapper, run once against real claude with credentials, commit the generated
.cassette.json.Switch back to the replayer. The test now runs hermetically.
The recorder + replayer share the same on-disk schema (see
cassette.go::Cassette), so steps 3 → 4 are a one-line flip in the
test.