feat(http-recorder): sync recorder v0.3 (#35619)

This commit is contained in:
Kit Langton 2026-07-08 11:41:59 -04:00 committed by GitHub
commit 3dd29094f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2697 additions and 2207 deletions

View file

@ -320,7 +320,7 @@ recorded.effect("streams text", () =>
)
```
Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable.
Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable.
Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file:
@ -333,6 +333,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re
**Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip.
**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.

View file

@ -12,7 +12,6 @@ const openAI = OpenAI.configure({
})
const openAIChat = openAI.chat("gpt-4o-mini")
const openAIResponses = openAI.responses("gpt-5.5")
const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini")
const anthropic = Anthropic.configure({
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
})
@ -89,14 +88,6 @@ describeRecordedGoldenScenarios([
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
name: "OpenAI Responses WebSocket gpt-4.1-mini",
prefix: "openai-responses-websocket",
model: openAIResponsesWebSocket,
transport: "websocket",
requires: ["OPENAI_API_KEY"],
scenarios: ["tool-loop"],
},
{
name: "Anthropic Haiku 4.5",
prefix: "anthropic-messages",

View file

@ -28,7 +28,7 @@ type TargetInput = {
readonly transport?: Transport
readonly prefix?: string
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly metadata?: HttpRecorder.CassetteMetadata
readonly options?: HttpRecorder.RecorderOptions
readonly scenarios: ReadonlyArray<ScenarioInput>
}
@ -43,7 +43,7 @@ const defaultPrefix = (target: TargetInput) => {
const metadata = (target: TargetInput) => ({
provider: target.model.provider,
protocol: target.protocol,
...(target.protocol ? { protocol: target.protocol } : {}),
route: target.model.route.id,
transport: target.transport ?? "http",
model: target.model.id,

View file

@ -1,3 +1,4 @@
import type { HttpRecorder } from "@opencode-ai/http-recorder"
import { test, type TestOptions } from "bun:test"
import { Effect, type Layer } from "effect"
import { testEffect } from "./lib/effect"
@ -11,7 +12,7 @@ export type RecordedGroupOptions = {
readonly protocol?: string
readonly requires?: ReadonlyArray<string>
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly metadata?: HttpRecorder.CassetteMetadata
}
export type RecordedCaseOptions = {
@ -21,7 +22,7 @@ export type RecordedCaseOptions = {
readonly protocol?: string
readonly requires?: ReadonlyArray<string>
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly metadata?: HttpRecorder.CassetteMetadata
}
export const recordedEffectGroup = <
@ -36,7 +37,7 @@ export const recordedEffectGroup = <
readonly layer: (input: {
readonly cassette: string
readonly tags: ReadonlyArray<string>
readonly metadata: Record<string, unknown>
readonly metadata: HttpRecorder.CassetteMetadata
readonly recording: boolean
readonly options: Options
readonly caseOptions: CaseOptions

View file

@ -1,11 +1,8 @@
import { NodeFileSystem } from "@effect/platform-node"
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
import type { Service as LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@ -14,7 +11,6 @@ import {
type RecordedCaseOptions as RunnerCaseOptions,
type RecordedGroupOptions,
} from "./recorded-runner"
import { webSocketCassetteLayer } from "./recorded-websocket"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
@ -64,31 +60,27 @@ export const recordedTests = (options: RecordedTestsOptions) =>
recordedEffectGroup<RecordedEnv, never, RecordedTestsOptions, RecordedCaseOptions>({
duplicateLabel: "recorded cassette",
options,
cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }),
cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }),
layer: ({ cassette, metadata, options, caseOptions, recording }) => {
const recorderOptions = mergeOptions(options.options, caseOptions.options)
const recorderMetadata = {
...recorderOptions?.metadata,
...metadata,
}
const mode = recording ? "record" : "replay"
const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer),
)
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
Layer.provide(
HttpRecorderInternal.recordingLayer(cassette, {
mode,
HttpRecorder.layerFetch(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact),
match: recorderOptions?.match,
}).pipe(Layer.provide(FetchHttpClient.layer)),
}),
),
)
const deps = Layer.mergeAll(
requestExecutor,
webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }),
)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService))
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
},
})

View file

@ -1,26 +0,0 @@
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { Effect, Layer } from "effect"
import { WebSocketExecutor } from "../src/route"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
const liveWebSocket = WebSocketExecutor.open
export const webSocketCassetteLayer = (
cassette: string,
input: { readonly metadata?: Record<string, unknown>; readonly mode: HttpRecorderInternal.RecordReplayMode },
): Layer.Layer<WebSocketExecutorService, never, HttpRecorderInternal.Cassette.Service> =>
Layer.effect(
WebSocketExecutor.Service,
Effect.gen(function* () {
const cassetteService = yield* HttpRecorderInternal.Cassette.Service
const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({
name: cassette,
mode: input.mode,
metadata: input.metadata,
cassette: cassetteService,
live: { open: liveWebSocket },
compareClientMessagesAsJson: true,
})
return WebSocketExecutor.Service.of(executor)
}),
)