feat(http-recorder): sync recorder v0.3 (#35619)
This commit is contained in:
parent
05e8d5be73
commit
3dd29094f4
47 changed files with 2697 additions and 2207 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue