Skip to content

Commit 4fb417d

Browse files
authored
feat(http-recorder): default mode to "auto" (#26719)
1 parent 11030c6 commit 4fb417d

6 files changed

Lines changed: 99 additions & 30 deletions

File tree

packages/http-recorder/README.md

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
1616
## Quickstart
1717

1818
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
19-
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
20-
runs replay from it.
19+
By default the layer records on first run and replays on subsequent runs —
20+
no env-var ternary at the call site, and `CI=true` forces strict replay so
21+
missing cassettes fail loudly in CI rather than silently re-recording.
2122

2223
```ts
2324
import { Effect } from "effect"
@@ -30,28 +31,22 @@ const program = Effect.gen(function* () {
3031
return yield* response.json
3132
})
3233

33-
// Replay (default). Fails if the cassette is missing.
34+
// Records if the cassette is missing, replays if it exists.
35+
// In CI (CI=true) always replays — fails loudly on missing fixtures.
3436
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
3537

36-
// Record. Hits the upstream and writes the cassette.
38+
// Force a refresh — always hits upstream and overwrites.
3739
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
3840
```
3941

40-
Set the mode from the environment in your test setup:
41-
42-
```ts
43-
HttpRecorder.cassetteLayer("users/get-one", {
44-
mode: process.env.RECORD === "true" ? "record" : "replay",
45-
})
46-
```
47-
4842
## Modes
4943

50-
| Mode | Behavior |
51-
| ------------- | -------------------------------------------------------------------- |
52-
| `replay` | Default. Match the request to a recorded interaction; error if none. |
53-
| `record` | Execute upstream, append the interaction, write the cassette. |
54-
| `passthrough` | Bypass the recorder entirely — just call upstream. |
44+
| Mode | Behavior |
45+
| ------------- | ----------------------------------------------------------------------------------- |
46+
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
47+
| `replay` | Strict — match the request to a recorded interaction; error if none. |
48+
| `record` | Execute upstream, append the interaction, write the cassette. |
49+
| `passthrough` | Bypass the recorder entirely — just call upstream. |
5550

5651
## Cassette format
5752

@@ -101,7 +96,6 @@ secrets escape. Redaction is configured by composing a `Redactor`:
10196
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
10297

10398
HttpRecorder.cassetteLayer("anthropic/messages", {
104-
mode: process.env.RECORD === "true" ? "record" : "replay",
10599
redactor: Redactor.defaults({
106100
requestHeaders: { allow: ["content-type", "anthropic-version"] },
107101
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
@@ -157,7 +151,6 @@ const program = Effect.gen(function* () {
157151
const cassette = yield* HttpRecorder.Cassette.Service
158152
const executor = yield* HttpRecorder.makeWebSocketExecutor({
159153
name: "ws/subscribe",
160-
mode: process.env.RECORD === "true" ? "record" : "replay",
161154
cassette,
162155
live: liveExecutor,
163156
})
@@ -188,7 +181,7 @@ const audit = Effect.gen(function* () {
188181

189182
```ts
190183
type RecordReplayOptions = {
191-
mode?: "record" | "replay" | "passthrough" // default: "replay"
184+
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
192185
directory?: string // default: <cwd>/test/fixtures/recordings
193186
metadata?: Record<string, unknown> // merged into cassette.metadata
194187
redactor?: Redactor // default: Redactor.defaults()

packages/http-recorder/src/effect.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {
1212
} from "effect/unstable/http"
1313
import * as CassetteService from "./cassette"
1414
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
15-
import { appendOrFail, makeReplayState } from "./recorder"
15+
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
1616
import { defaults, type Redactor } from "./redactor"
1717
import { redactUrl } from "./redaction"
1818
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
1919

20-
export type RecordReplayMode = "record" | "replay" | "passthrough"
20+
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
2121

2222
export interface RecordReplayOptions {
2323
readonly mode?: RecordReplayMode
@@ -69,7 +69,8 @@ export const recordingLayer = (
6969
const cassetteService = yield* CassetteService.Service
7070
const redactor = options.redactor ?? defaults()
7171
const match = options.match ?? defaultMatcher
72-
const mode = options.mode ?? "replay"
72+
const requested = options.mode ?? "auto"
73+
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
7374
const sequential = options.dispatch === "sequential"
7475
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
7576

@@ -114,7 +115,12 @@ export const recordingLayer = (
114115
return Effect.gen(function* () {
115116
const incoming = yield* snapshotRequest(request)
116117
const interactions = yield* replay.load.pipe(
117-
Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)),
118+
Effect.mapError(() =>
119+
transportError(
120+
request,
121+
`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`,
122+
),
123+
),
118124
)
119125
const result = sequential
120126
? selectSequential(interactions, incoming, match, yield* replay.cursor)

packages/http-recorder/src/recorder.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ export class UnsafeCassetteError extends Error {
1717
}
1818
}
1919

20+
export type ResolvedMode = "record" | "replay" | "passthrough"
21+
22+
const isCI = () => {
23+
const value = process.env.CI
24+
return value !== undefined && value !== "" && value !== "false" && value !== "0"
25+
}
26+
27+
export const resolveAutoMode = (
28+
cassette: CassetteService.Interface,
29+
name: string,
30+
): Effect.Effect<ResolvedMode> =>
31+
Effect.gen(function* () {
32+
if (isCI()) return "replay"
33+
return (yield* cassette.exists(name)) ? "replay" : "record"
34+
})
35+
2036
export const appendOrFail = (
2137
cassette: CassetteService.Interface,
2238
name: string,

packages/http-recorder/src/websocket.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
22
import type { Headers } from "effect/unstable/http"
33
import * as CassetteService from "./cassette"
44
import { canonicalizeJson, decodeJson } from "./matching"
5-
import { appendOrFail, makeReplayState } from "./recorder"
5+
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
6+
import type { RecordReplayMode } from "./effect"
67
import { defaults, type Redactor } from "./redactor"
78
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
89

@@ -23,7 +24,7 @@ export interface WebSocketExecutor<E> {
2324

2425
export interface WebSocketRecordReplayOptions<E> {
2526
readonly name: string
26-
readonly mode?: "record" | "replay" | "passthrough"
27+
readonly mode?: RecordReplayMode
2728
readonly metadata?: CassetteMetadata
2829
readonly cassette: CassetteService.Interface
2930
readonly live: WebSocketExecutor<E>
@@ -71,7 +72,8 @@ export const makeWebSocketExecutor = <E>(
7172
options: WebSocketRecordReplayOptions<E>,
7273
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
7374
Effect.gen(function* () {
74-
const mode = options.mode ?? "replay"
75+
const requested = options.mode ?? "auto"
76+
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
7577
const redactor = options.redactor ?? defaults()
7678
const openSnapshot = (request: WebSocketRequest) => {
7779
const redacted = redactor.request({

packages/http-recorder/test/record-replay.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,59 @@ describe("http-recorder", () => {
301301
)
302302
})
303303

304+
test("auto mode replays when the cassette exists", async () => {
305+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
306+
const cassettePath = path.join(directory, "auto-replay.json")
307+
fs.writeFileSync(
308+
cassettePath,
309+
formatCassette(
310+
cassetteFor(
311+
"auto-replay",
312+
[
313+
{
314+
transport: "http",
315+
request: {
316+
method: "POST",
317+
url: "https://example.test/echo",
318+
headers: { "content-type": "application/json" },
319+
body: JSON.stringify({ step: 1 }),
320+
},
321+
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
322+
},
323+
],
324+
undefined,
325+
),
326+
),
327+
)
328+
329+
const result = await runWith(
330+
"auto-replay",
331+
{ directory, mode: "auto" },
332+
post("https://example.test/echo", { step: 1 }),
333+
)
334+
expect(result).toBe('{"reply":"hi"}')
335+
})
336+
337+
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
338+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
339+
const previous = process.env.CI
340+
process.env.CI = "true"
341+
try {
342+
const exit = await Effect.runPromise(
343+
Effect.exit(
344+
post("https://example.test/echo", { step: 1 }).pipe(
345+
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
346+
),
347+
),
348+
)
349+
expect(Exit.isFailure(exit)).toBe(true)
350+
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
351+
} finally {
352+
if (previous === undefined) delete process.env.CI
353+
else process.env.CI = previous
354+
}
355+
})
356+
304357
test("mismatch diagnostics show closest redacted request differences", async () => {
305358
await run(
306359
Effect.gen(function* () {

packages/llm/test/recorded-websocket.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
1+
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
22
import { Effect, Layer } from "effect"
33
import { WebSocketExecutor } from "../src/route"
44
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
55

66
const liveWebSocket = WebSocketExecutor.open
7-
type Mode = "record" | "replay" | "passthrough"
87

98
export const webSocketCassetteLayer = (
109
cassette: string,
11-
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
10+
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
1211
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
1312
Layer.effect(
1413
WebSocketExecutor.Service,

0 commit comments

Comments
 (0)