فهرست منبع

feat(http-recorder): default mode to "auto" (#26719)

Kit Langton 3 ماه پیش
والد
کامیت
4fb417d3b5

+ 13 - 20
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/<name>.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: <cwd>/test/fixtures/recordings
   metadata?: Record<string, unknown> // merged into cassette.metadata
   redactor?: Redactor // default: Redactor.defaults()

+ 10 - 4
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)

+ 16 - 0
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<ResolvedMode> =>
+  Effect.gen(function* () {
+    if (isCI()) return "replay"
+    return (yield* cassette.exists(name)) ? "replay" : "record"
+  })
+
 export const appendOrFail = (
   cassette: CassetteService.Interface,
   name: string,

+ 5 - 3
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<E> {
 
 export interface WebSocketRecordReplayOptions<E> {
   readonly name: string
-  readonly mode?: "record" | "replay" | "passthrough"
+  readonly mode?: RecordReplayMode
   readonly metadata?: CassetteMetadata
   readonly cassette: CassetteService.Interface
   readonly live: WebSocketExecutor<E>
@@ -71,7 +72,8 @@ export const makeWebSocketExecutor = <E>(
   options: WebSocketRecordReplayOptions<E>,
 ): Effect.Effect<WebSocketExecutor<E>, 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({

+ 53 - 0
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* () {

+ 2 - 3
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<string, unknown>; readonly mode: Mode },
+  input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
 ): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
   Layer.effect(
     WebSocketExecutor.Service,