From 4fb417d3b544a0d3cad864e3c2cf6e9e5d012cb2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:05:11 -0400 Subject: [PATCH] feat(http-recorder): default mode to "auto" (#26719) --- packages/http-recorder/README.md | 33 +++++------- packages/http-recorder/src/effect.ts | 14 +++-- packages/http-recorder/src/recorder.ts | 16 ++++++ packages/http-recorder/src/websocket.ts | 8 +-- .../http-recorder/test/record-replay.test.ts | 53 +++++++++++++++++++ packages/llm/test/recorded-websocket.ts | 5 +- 6 files changed, 99 insertions(+), 30 deletions(-) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index ed4cd2eb19..134c7b316b 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -16,8 +16,9 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" ## Quickstart Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`. -The first run records to `test/fixtures/recordings/.json`; subsequent -runs replay from it. +By default the layer records on first run and replays on subsequent runs — +no env-var ternary at the call site, and `CI=true` forces strict replay so +missing cassettes fail loudly in CI rather than silently re-recording. ```ts import { Effect } from "effect" @@ -30,28 +31,22 @@ const program = Effect.gen(function* () { return yield* response.json }) -// Replay (default). Fails if the cassette is missing. +// Records if the cassette is missing, replays if it exists. +// In CI (CI=true) always replays — fails loudly on missing fixtures. Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) -// Record. Hits the upstream and writes the cassette. +// Force a refresh — always hits upstream and overwrites. Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" })))) ``` -Set the mode from the environment in your test setup: - -```ts -HttpRecorder.cassetteLayer("users/get-one", { - mode: process.env.RECORD === "true" ? "record" : "replay", -}) -``` - ## Modes -| Mode | Behavior | -| ------------- | -------------------------------------------------------------------- | -| `replay` | Default. Match the request to a recorded interaction; error if none. | -| `record` | Execute upstream, append the interaction, write the cassette. | -| `passthrough` | Bypass the recorder entirely — just call upstream. | +| Mode | Behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. | +| `replay` | Strict — match the request to a recorded interaction; error if none. | +| `record` | Execute upstream, append the interaction, write the cassette. | +| `passthrough` | Bypass the recorder entirely — just call upstream. | ## Cassette format @@ -101,7 +96,6 @@ secrets escape. Redaction is configured by composing a `Redactor`: import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" HttpRecorder.cassetteLayer("anthropic/messages", { - mode: process.env.RECORD === "true" ? "record" : "replay", redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] }, url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") }, @@ -157,7 +151,6 @@ const program = Effect.gen(function* () { const cassette = yield* HttpRecorder.Cassette.Service const executor = yield* HttpRecorder.makeWebSocketExecutor({ name: "ws/subscribe", - mode: process.env.RECORD === "true" ? "record" : "replay", cassette, live: liveExecutor, }) @@ -188,7 +181,7 @@ const audit = Effect.gen(function* () { ```ts type RecordReplayOptions = { - mode?: "record" | "replay" | "passthrough" // default: "replay" + mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay") directory?: string // default: /test/fixtures/recordings metadata?: Record // merged into cassette.metadata redactor?: Redactor // default: Redactor.defaults() diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index 977a15755d..1e59b62beb 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -12,12 +12,12 @@ import { } from "effect/unstable/http" import * as CassetteService from "./cassette" import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" -import { appendOrFail, makeReplayState } from "./recorder" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" import { defaults, type Redactor } from "./redactor" import { redactUrl } from "./redaction" import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" -export type RecordReplayMode = "record" | "replay" | "passthrough" +export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" export interface RecordReplayOptions { readonly mode?: RecordReplayMode @@ -69,7 +69,8 @@ export const recordingLayer = ( const cassetteService = yield* CassetteService.Service const redactor = options.redactor ?? defaults() const match = options.match ?? defaultMatcher - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested const sequential = options.dispatch === "sequential" const replay = yield* makeReplayState(cassetteService, name, httpInteractions) @@ -114,7 +115,12 @@ export const recordingLayer = ( return Effect.gen(function* () { const incoming = yield* snapshotRequest(request) const interactions = yield* replay.load.pipe( - Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)), + Effect.mapError(() => + transportError( + request, + `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, + ), + ), ) const result = sequential ? selectSequential(interactions, incoming, match, yield* replay.cursor) diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index 30648cd6a7..64f20c519e 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -17,6 +17,22 @@ export class UnsafeCassetteError extends Error { } } +export type ResolvedMode = "record" | "replay" | "passthrough" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = ( + cassette: CassetteService.Interface, + name: string, +): Effect.Effect => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + export const appendOrFail = ( cassette: CassetteService.Interface, name: string, diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts index bf8a1fca06..f7529b4888 100644 --- a/packages/http-recorder/src/websocket.ts +++ b/packages/http-recorder/src/websocket.ts @@ -2,7 +2,8 @@ import { Effect, Option, Ref, Scope, Stream } from "effect" import type { Headers } from "effect/unstable/http" import * as CassetteService from "./cassette" import { canonicalizeJson, decodeJson } from "./matching" -import { appendOrFail, makeReplayState } from "./recorder" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import type { RecordReplayMode } from "./effect" import { defaults, type Redactor } from "./redactor" import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" @@ -23,7 +24,7 @@ export interface WebSocketExecutor { export interface WebSocketRecordReplayOptions { readonly name: string - readonly mode?: "record" | "replay" | "passthrough" + readonly mode?: RecordReplayMode readonly metadata?: CassetteMetadata readonly cassette: CassetteService.Interface readonly live: WebSocketExecutor @@ -71,7 +72,8 @@ export const makeWebSocketExecutor = ( options: WebSocketRecordReplayOptions, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested const redactor = options.redactor ?? defaults() const openSnapshot = (request: WebSocketRequest) => { const redacted = redactor.request({ diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index df3bbf308e..117620cc64 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -301,6 +301,59 @@ describe("http-recorder", () => { ) }) + test("auto mode replays when the cassette exists", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) + const cassettePath = path.join(directory, "auto-replay.json") + fs.writeFileSync( + cassettePath, + formatCassette( + cassetteFor( + "auto-replay", + [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, + }, + ], + undefined, + ), + ), + ) + + const result = await runWith( + "auto-replay", + { directory, mode: "auto" }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("auto mode forces replay when CI=true even if cassette is missing", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) + const previous = process.env.CI + process.env.CI = "true" + try { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + } finally { + if (previous === undefined) delete process.env.CI + else process.env.CI = previous + } + }) + test("mismatch diagnostics show closest redacted request differences", async () => { await run( Effect.gen(function* () { diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts index eeea9f1b78..b7ad380dad 100644 --- a/packages/llm/test/recorded-websocket.ts +++ b/packages/llm/test/recorded-websocket.ts @@ -1,14 +1,13 @@ -import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder" +import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder" import { Effect, Layer } from "effect" import { WebSocketExecutor } from "../src/route" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" const liveWebSocket = WebSocketExecutor.open -type Mode = "record" | "replay" | "passthrough" export const webSocketCassetteLayer = ( cassette: string, - input: { readonly metadata?: Record; readonly mode: Mode }, + input: { readonly metadata?: Record; readonly mode: RecordReplayMode }, ): Layer.Layer => Layer.effect( WebSocketExecutor.Service,