chore(http-recorder): remove content-matching dispatch mode (#26792)

This commit is contained in:
Kit Langton 2026-05-11 11:10:18 -04:00 committed by GitHub
commit f240bba8e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 20 additions and 72 deletions

View file

@ -70,19 +70,15 @@ Cassettes are normal source files — review them, diff them, commit them.
## Request matching ## Request matching
By default, requests match on canonicalized method, URL, headers, and JSON Replay walks the cassette in record order via an internal cursor: the Nth
body (object keys sorted). Two dispatch strategies are available: request executed at runtime is served by the Nth recorded interaction, and
each one is validated as the cursor advances. Request equality is computed
on canonicalized method, URL, headers, and JSON body (object keys sorted).
- **`match`** (default) — find the first recorded interaction whose request This is deliberately strict — content-based dispatch was removed because
matches the incoming request. Same request twice returns the same response. it silently returns the first recorded response for repeated identical
- **`sequential`** — return interactions in the order they were recorded, requests, masking state changes that retry/polling/cache-hit tests need to
validating each one matches as the cursor advances. Use for ordered flows observe. If you reorder requests in a test, re-record the cassette.
where the same URL is hit multiple times with meaningful state changes
(pagination, retries, polling).
```ts
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
```
Supply your own matcher via `match: (incoming, recorded) => boolean` for Supply your own matcher via `match: (incoming, recorded) => boolean` for
custom equivalence (e.g. ignoring a timestamp field in the body). custom equivalence (e.g. ignoring a timestamp field in the body).
@ -194,7 +190,6 @@ type RecordReplayOptions = {
directory?: string // default: <cwd>/test/fixtures/recordings directory?: string // default: <cwd>/test/fixtures/recordings
metadata?: Record<string, unknown> // merged into cassette.metadata metadata?: Record<string, unknown> // merged into cassette.metadata
redactor?: Redactor // default: Redactor.defaults() redactor?: Redactor // default: Redactor.defaults()
dispatch?: "match" | "sequential" // default: "match"
match?: (incoming, recorded) => boolean // custom matcher match?: (incoming, recorded) => boolean // custom matcher
} }
``` ```
@ -211,4 +206,4 @@ type RecordReplayOptions = {
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | | `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. | | `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | | `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | | `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. |

View file

@ -11,7 +11,7 @@ import {
UrlParams, UrlParams,
} from "effect/unstable/http" } from "effect/unstable/http"
import * as CassetteService from "./cassette" import * as CassetteService from "./cassette"
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import { defaults, type Redactor } from "./redactor" import { defaults, type Redactor } from "./redactor"
import { redactUrl } from "./redaction" import { redactUrl } from "./redaction"
@ -24,7 +24,6 @@ export interface RecordReplayOptions {
readonly directory?: string readonly directory?: string
readonly metadata?: CassetteMetadata readonly metadata?: CassetteMetadata
readonly redactor?: Redactor readonly redactor?: Redactor
readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher readonly match?: RequestMatcher
} }
@ -71,7 +70,6 @@ export const recordingLayer = (
const match = options.match ?? defaultMatcher const match = options.match ?? defaultMatcher
const requested = options.mode ?? "auto" const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
const sequential = options.dispatch === "sequential"
const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
@ -119,14 +117,12 @@ export const recordingLayer = (
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
), ),
) )
const result = sequential const result = selectSequential(interactions, incoming, match, yield* replay.cursor)
? selectSequential(interactions, incoming, match, yield* replay.cursor)
: selectMatch(interactions, incoming, match)
if (!result.interaction) if (!result.interaction)
return yield* Effect.fail( return yield* Effect.fail(
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
) )
if (sequential) yield* replay.advance yield* replay.advance
return HttpClientResponse.fromWeb( return HttpClientResponse.fromWeb(
request, request,
new Response(decodeResponseBody(result.interaction.response), result.interaction.response), new Response(decodeResponseBody(result.interaction.response), result.interaction.response),

View file

@ -92,24 +92,6 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot
return lines return lines
} }
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const selectMatch = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
}
export const selectSequential = ( export const selectSequential = (
interactions: ReadonlyArray<HttpInteraction>, interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot, incoming: RequestSnapshot,

View file

@ -230,19 +230,10 @@ describe("http-recorder", () => {
) )
}) })
test("default matcher dispatches multi-interaction cassettes by request shape", async () => { test("replay returns recorded responses in order for identical requests", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
await runWith( await runWith(
"record-replay/retry", "record-replay/retry",
{ dispatch: "sequential" }, {},
Effect.gen(function* () { Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
@ -250,21 +241,8 @@ describe("http-recorder", () => {
) )
}) })
test("default matcher returns the first match for identical requests", async () => { test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith( await run(
"record-replay/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
}),
)
})
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () { Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 }) yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 }) yield* post("https://example.test/echo", { step: 2 })
@ -274,10 +252,8 @@ describe("http-recorder", () => {
) )
}) })
test("sequential dispatch still validates each recorded request", async () => { test("replay validates each recorded request in order", async () => {
await runWith( await run(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () { Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 }) yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
@ -331,14 +307,13 @@ describe("http-recorder", () => {
} }
}) })
test("mismatch diagnostics show closest redacted request differences", async () => { test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Effect.exit( const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
) )
const message = failureText(exit) const message = failureText(exit)
expect(message).toContain("closest interaction: #1")
expect(message).toContain("url:") expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:") expect(message).toContain("body:")

View file

@ -289,6 +289,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re
**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text. **Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text.
**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `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.** 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.
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. 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.