Просмотр исходного кода

refactor(http-recorder): hide cassette format behind Cassette seam (#26725)

Kit Langton 3 месяцев назад
Родитель
Сommit
2bd3d9a696

+ 15 - 6
packages/http-recorder/README.md

@@ -160,9 +160,9 @@ const program = Effect.gen(function* () {
 
 
 ## Inspecting cassettes programmatically
 ## Inspecting cassettes programmatically
 
 
-`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and
-`scan` (re-running the secret detector over an existing cassette). Useful
-for CI checks:
+`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
+returns the recorded interactions for a name; the file format is hidden
+behind the seam. Useful for CI checks:
 
 
 ```ts
 ```ts
 import { HttpRecorder } from "@opencode-ai/http-recorder"
 import { HttpRecorder } from "@opencode-ai/http-recorder"
@@ -170,13 +170,22 @@ import { Effect } from "effect"
 
 
 const audit = Effect.gen(function* () {
 const audit = Effect.gen(function* () {
   const cassettes = yield* HttpRecorder.Cassette.Service
   const cassettes = yield* HttpRecorder.Cassette.Service
-  const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
-    cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
+  const entries = yield* cassettes.list()
+  const issues = yield* Effect.forEach(entries, (entry) =>
+    cassettes.read(entry.name).pipe(
+      Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) })),
+    ),
   )
   )
-  return findings.filter((r) => r.findings.length > 0)
+  return issues.filter((i) => i.findings.length > 0)
 })
 })
 ```
 ```
 
 
+`cassetteLayer` is the batteries-included entry point — it provides
+`Cassette.fileSystem({ directory })` automatically. If you want to provide
+your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
+own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
+`Cassette.memory` yourself.
+
 ## Options reference
 ## Options reference
 
 
 ```ts
 ```ts

+ 115 - 79
packages/http-recorder/src/cassette.ts

@@ -1,62 +1,80 @@
-import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
+import { Context, Data, Effect, FileSystem, Layer } from "effect"
+import * as fs from "node:fs"
 import * as path from "node:path"
 import * as path from "node:path"
-import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
-import type { Cassette, CassetteMetadata, Interaction } from "./schema"
-import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
+import { secretFindings, type SecretFinding } from "./redaction"
+import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
 
 
-export interface Entry {
-  readonly name: string
-  readonly path: string
+const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
+
+export class CassetteNotFoundError extends Data.TaggedError("CassetteNotFoundError")<{
+  readonly cassetteName: string
+}> {
+  override get message() {
+    return `Cassette "${this.cassetteName}" not found`
+  }
+}
+
+export interface AppendResult {
+  readonly findings: ReadonlyArray<SecretFinding>
 }
 }
 
 
 export interface Interface {
 export interface Interface {
-  readonly path: (name: string) => string
-  readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
-  readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
+  readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
   readonly append: (
   readonly append: (
     name: string,
     name: string,
     interaction: Interaction,
     interaction: Interaction,
-    metadata: CassetteMetadata | undefined,
-  ) => Effect.Effect<
-    {
-      readonly cassette: Cassette
-      readonly findings: ReadonlyArray<SecretFinding>
-    },
-    PlatformError.PlatformError
-  >
+    metadata?: CassetteMetadata,
+  ) => Effect.Effect<AppendResult>
   readonly exists: (name: string) => Effect.Effect<boolean>
   readonly exists: (name: string) => Effect.Effect<boolean>
-  readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
-  readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
+  readonly list: () => Effect.Effect<ReadonlyArray<string>>
 }
 }
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
 
 
-export const layer = (options: { readonly directory?: string } = {}) =>
+export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
+  fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
+
+const buildCassette = (
+  name: string,
+  interactions: ReadonlyArray<Interaction>,
+  metadata: CassetteMetadata | undefined,
+): Cassette => ({
+  version: 1,
+  metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
+  interactions,
+})
+
+const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
+
+const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
+
+export const fileSystem = (
+  options: { readonly directory?: string } = {},
+): Layer.Layer<Service, never, FileSystem.FileSystem> =>
   Layer.effect(
   Layer.effect(
     Service,
     Service,
     Effect.gen(function* () {
     Effect.gen(function* () {
-      const fileSystem = yield* FileSystem.FileSystem
+      const fs = yield* FileSystem.FileSystem
       const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
       const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
       const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
       const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
       const directoriesEnsured = new Set<string>()
       const directoriesEnsured = new Set<string>()
 
 
-      const pathFor = (name: string) => cassettePath(name, directory)
+      const cassettePath = (name: string) => path.join(directory, `${name}.json`)
 
 
-      const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
-        const dir = path.dirname(pathFor(name))
-        if (directoriesEnsured.has(dir)) return
-        yield* fileSystem.makeDirectory(dir, { recursive: true })
-        directoriesEnsured.add(dir)
-      })
+      const ensureDirectory = (name: string) =>
+        Effect.gen(function* () {
+          const dir = path.dirname(cassettePath(name))
+          if (directoriesEnsured.has(dir)) return
+          yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
+          directoriesEnsured.add(dir)
+        })
 
 
-      const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
+      const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
         Effect.gen(function* () {
         Effect.gen(function* () {
-          const entries = yield* fileSystem
-            .readDirectory(directory)
-            .pipe(Effect.catch(() => Effect.succeed([] as string[])))
+          const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
           const nested = yield* Effect.forEach(entries, (entry) => {
           const nested = yield* Effect.forEach(entries, (entry) => {
-            const full = path.join(directory, entry)
-            return fileSystem.stat(full).pipe(
+            const full = path.join(current, entry)
+            return fs.stat(full).pipe(
               Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
               Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
               Effect.catch(() => Effect.succeed([] as string[])),
               Effect.catch(() => Effect.succeed([] as string[])),
             )
             )
@@ -64,50 +82,68 @@ export const layer = (options: { readonly directory?: string } = {}) =>
           return nested.flat()
           return nested.flat()
         })
         })
 
 
-      const read = Effect.fn("Cassette.read")(function* (name: string) {
-        return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
+      return Service.of({
+        read: (name) =>
+          fs.readFileString(cassettePath(name)).pipe(
+            Effect.map((raw) => parseCassette(raw).interactions),
+            Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
+          ),
+        append: (name, interaction, metadata) =>
+          Effect.gen(function* () {
+            const entry = recorded.get(name) ?? { interactions: [], findings: [] }
+            if (!recorded.has(name)) recorded.set(name, entry)
+            entry.interactions.push(interaction)
+            entry.findings.push(...secretFindings(interaction))
+            const cassette = buildCassette(name, entry.interactions, metadata)
+            const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
+            if (findings.length === 0) {
+              yield* ensureDirectory(name)
+              yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
+            }
+            return { findings }
+          }),
+        exists: (name) =>
+          fs.access(cassettePath(name)).pipe(
+            Effect.as(true),
+            Effect.catch(() => Effect.succeed(false)),
+          ),
+        list: () =>
+          walk(directory).pipe(
+            Effect.map((files) =>
+              files
+                .filter((file) => file.endsWith(".json"))
+                .map((file) => path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, ""))
+                .toSorted((a, b) => a.localeCompare(b)),
+            ),
+          ),
       })
       })
-
-      const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
-        yield* ensureDirectory(name)
-        yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
-      })
-
-      const append = Effect.fn("Cassette.append")(function* (
-        name: string,
-        interaction: Interaction,
-        metadata: CassetteMetadata | undefined,
-      ) {
-        const entry = recorded.get(name) ?? { interactions: [], findings: [] }
-        entry.interactions.push(interaction)
-        entry.findings.push(...secretFindings(interaction))
-        recorded.set(name, entry)
-        const cassette = cassetteFor(name, entry.interactions, metadata)
-        const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
-        if (findings.length === 0) yield* write(name, cassette)
-        return { cassette, findings }
-      })
-
-      const exists = Effect.fn("Cassette.exists")(function* (name: string) {
-        return yield* fileSystem.access(pathFor(name)).pipe(
-          Effect.as(true),
-          Effect.catch(() => Effect.succeed(false)),
-        )
-      })
-
-      const list = Effect.fn("Cassette.list")(function* () {
-        return (yield* walk(directory))
-          .filter((file) => file.endsWith(".json"))
-          .map((file) => ({
-            name: path
-              .relative(directory, file)
-              .replace(/\\/g, "/")
-              .replace(/\.json$/, ""),
-            path: file,
-          }))
-          .toSorted((a, b) => a.name.localeCompare(b.name))
-      })
-
-      return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
     }),
     }),
   )
   )
+
+export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
+  Layer.sync(Service, () => {
+    const stored = new Map<string, Interaction[]>(
+      Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
+    )
+    const accumulatedFindings = new Map<string, SecretFinding[]>()
+
+    return Service.of({
+      read: (name) =>
+        stored.has(name)
+          ? Effect.succeed(stored.get(name) ?? [])
+          : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
+      append: (name, interaction, metadata) =>
+        Effect.sync(() => {
+          const existing = stored.get(name)
+          if (existing) existing.push(interaction)
+          else stored.set(name, [interaction])
+          const findings = accumulatedFindings.get(name)
+          if (findings) findings.push(...secretFindings(interaction))
+          else accumulatedFindings.set(name, [...secretFindings(interaction)])
+          if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
+          return { findings: accumulatedFindings.get(name) ?? [] }
+        }),
+      exists: (name) => Effect.sync(() => stored.has(name)),
+      list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
+    })
+  })

+ 1 - 1
packages/http-recorder/src/effect.ts

@@ -138,7 +138,7 @@ export const recordingLayer = (
 
 
 export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
 export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
   recordingLayer(name, options).pipe(
   recordingLayer(name, options).pipe(
-    Layer.provide(CassetteService.layer({ directory: options.directory })),
+    Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodeFileSystem.layer),
   )
   )

+ 2 - 2
packages/http-recorder/src/index.ts

@@ -7,9 +7,9 @@ export type {
   WebSocketFrame,
   WebSocketFrame,
   WebSocketInteraction,
   WebSocketInteraction,
 } from "./schema"
 } from "./schema"
-export { hasCassetteSync } from "./storage"
+export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
 export { defaultMatcher, type RequestMatcher } from "./matching"
 export { defaultMatcher, type RequestMatcher } from "./matching"
-export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction"
+export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
 export { UnsafeCassetteError } from "./recorder"
 export { UnsafeCassetteError } from "./recorder"
 export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
 export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
 export {
 export {

+ 18 - 19
packages/http-recorder/src/recorder.ts

@@ -1,19 +1,17 @@
-import { Effect, PlatformError, Ref, Scope } from "effect"
+import { Data, Effect, Ref, Scope } from "effect"
 import type * as CassetteService from "./cassette"
 import type * as CassetteService from "./cassette"
+import type { CassetteNotFoundError } from "./cassette"
 import type { SecretFinding } from "./redaction"
 import type { SecretFinding } from "./redaction"
-import type { Cassette, CassetteMetadata, Interaction } from "./schema"
+import type { CassetteMetadata, Interaction } from "./schema"
 
 
-export class UnsafeCassetteError extends Error {
-  readonly _tag = "UnsafeCassetteError"
-  constructor(
-    readonly cassetteName: string,
-    readonly findings: ReadonlyArray<SecretFinding>,
-  ) {
-    super(
-      `Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings
-        .map((finding) => `${finding.path} (${finding.reason})`)
-        .join(", ")}`,
-    )
+export class UnsafeCassetteError extends Data.TaggedError("UnsafeCassetteError")<{
+  readonly cassetteName: string
+  readonly findings: ReadonlyArray<SecretFinding>
+}> {
+  override get message() {
+    return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
+      .map((finding) => `${finding.path} (${finding.reason})`)
+      .join(", ")}`
   }
   }
 }
 }
 
 
@@ -35,16 +33,17 @@ export const appendOrFail = (
   name: string,
   name: string,
   interaction: Interaction,
   interaction: Interaction,
   metadata: CassetteMetadata | undefined,
   metadata: CassetteMetadata | undefined,
-): Effect.Effect<Cassette, UnsafeCassetteError> =>
+): Effect.Effect<void, UnsafeCassetteError> =>
   cassette.append(name, interaction, metadata).pipe(
   cassette.append(name, interaction, metadata).pipe(
-    Effect.orDie,
-    Effect.flatMap(({ cassette: result, findings }) =>
-      findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)),
+    Effect.flatMap(({ findings }) =>
+      findings.length === 0
+        ? Effect.void
+        : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
     ),
     ),
   )
   )
 
 
 export interface ReplayState<T> {
 export interface ReplayState<T> {
-  readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError>
+  readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
   readonly cursor: Effect.Effect<number>
   readonly cursor: Effect.Effect<number>
   readonly advance: Effect.Effect<void>
   readonly advance: Effect.Effect<void>
 }
 }
@@ -52,7 +51,7 @@ export interface ReplayState<T> {
 export const makeReplayState = <T>(
 export const makeReplayState = <T>(
   cassette: CassetteService.Interface,
   cassette: CassetteService.Interface,
   name: string,
   name: string,
-  project: (cassette: Cassette) => ReadonlyArray<T>,
+  project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
 ): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
 ): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
     const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))

+ 0 - 3
packages/http-recorder/src/redaction.ts

@@ -1,5 +1,3 @@
-import type { Cassette } from "./schema"
-
 export const REDACTED = "[REDACTED]"
 export const REDACTED = "[REDACTED]"
 
 
 const DEFAULT_REDACT_HEADERS = [
 const DEFAULT_REDACT_HEADERS = [
@@ -113,4 +111,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
       .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
       .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
   ])
   ])
 
 
-export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)

+ 3 - 2
packages/http-recorder/src/schema.ts

@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
 
 
 export const isWebSocketInteraction = InteractionSchema.guards.websocket
 export const isWebSocketInteraction = InteractionSchema.guards.websocket
 
 
-export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
+export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
 
 
-export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
+export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
+  interactions.filter(isWebSocketInteraction)
 
 
 export const CassetteSchema = Schema.Struct({
 export const CassetteSchema = Schema.Struct({
   version: Schema.Literal(1),
   version: Schema.Literal(1),

+ 0 - 28
packages/http-recorder/src/storage.ts

@@ -1,28 +0,0 @@
-import { Option } from "effect"
-import * as fs from "node:fs"
-import * as path from "node:path"
-import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
-
-export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
-
-export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
-
-export const cassetteFor = (
-  name: string,
-  interactions: ReadonlyArray<Interaction>,
-  metadata: CassetteMetadata | undefined,
-): Cassette => ({
-  version: 1,
-  metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
-  interactions,
-})
-
-export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
-
-export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
-
-export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
-  const file = cassettePath(name, options.directory)
-  if (!fs.existsSync(file)) return false
-  return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
-}

+ 69 - 89
packages/http-recorder/test/record-replay.test.ts

@@ -7,7 +7,18 @@ import * as os from "node:os"
 import * as path from "node:path"
 import * as path from "node:path"
 import { HttpRecorder } from "../src"
 import { HttpRecorder } from "../src"
 import { redactedErrorRequest } from "../src/effect"
 import { redactedErrorRequest } from "../src/effect"
-import { cassetteFor, formatCassette, parseCassette } from "../src/storage"
+import type { Interaction } from "../src/schema"
+
+const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
+  Effect.runPromise(
+    Effect.gen(function* () {
+      const cassette = yield* HttpRecorder.Cassette.Service
+      yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
+    }).pipe(
+      Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })),
+      Effect.provide(NodeFileSystem.layer),
+    ),
+  )
 
 
 const post = (url: string, body: object) =>
 const post = (url: string, body: object) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -34,7 +45,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
     Effect.scoped(
     Effect.scoped(
       effect.pipe(
       effect.pipe(
         Effect.provide(
         Effect.provide(
-          HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
+          HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
         ),
         ),
         Effect.provide(NodeFileSystem.layer),
         Effect.provide(NodeFileSystem.layer),
       ),
       ),
@@ -109,7 +120,7 @@ describe("http-recorder", () => {
 
 
   test("detects secret-looking values without returning the secret", () => {
   test("detects secret-looking values without returning the secret", () => {
     expect(
     expect(
-      HttpRecorder.cassetteSecretFindings({
+      HttpRecorder.secretFindings({
         version: 1,
         version: 1,
         interactions: [
         interactions: [
           {
           {
@@ -137,7 +148,7 @@ describe("http-recorder", () => {
 
 
   test("detects secret-looking values inside metadata", () => {
   test("detects secret-looking values inside metadata", () => {
     expect(
     expect(
-      HttpRecorder.cassetteSecretFindings({
+      HttpRecorder.secretFindings({
         version: 1,
         version: 1,
         metadata: { token: "sk-123456789012345678901234" },
         metadata: { token: "sk-123456789012345678901234" },
         interactions: [],
         interactions: [],
@@ -145,60 +156,42 @@ describe("http-recorder", () => {
     ).toEqual([{ path: "metadata.token", reason: "API key" }])
     ).toEqual([{ path: "metadata.token", reason: "API key" }])
   })
   })
 
 
-  test("formats websocket cassettes with shared metadata", () => {
-    const cassette = cassetteFor(
-      "websocket/basic",
-      [
-        {
-          transport: "websocket",
-          open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
-          client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
-          server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
-        },
-      ],
-      { provider: "openai" },
-    )
-
-    expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
-    expect(parseCassette(formatCassette(cassette))).toEqual(cassette)
-  })
+  test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
+    await Effect.runPromise(
+      Effect.scoped(
+        Effect.gen(function* () {
+          const cassette = yield* HttpRecorder.Cassette.Service
+          const executor = yield* HttpRecorder.makeWebSocketExecutor({
+            name: "websocket/replay",
+            cassette,
+            compareClientMessagesAsJson: true,
+            live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
+          })
+          const connection = yield* executor.open({
+            url: "wss://example.test/realtime",
+            headers: Headers.fromInput({ "content-type": "application/json" }),
+          })
+          yield* connection.sendText(JSON.stringify({ type: "response.create" }))
+          const messages: Array<string | Uint8Array> = []
+          yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
+          yield* connection.close
 
 
-  test("replays websocket interactions from the shared cassette service", async () => {
-    await runRecorder(
-      Effect.gen(function* () {
-        const cassette = yield* HttpRecorder.Cassette.Service
-        yield* cassette.write(
-          "websocket/replay",
-          cassetteFor(
-            "websocket/replay",
-            [
-              {
-                transport: "websocket",
-                open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
-                client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
-                server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
-              },
-            ],
-            undefined,
+          expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
+        }).pipe(
+          Effect.provide(
+            HttpRecorder.Cassette.memory({
+              "websocket/replay": [
+                {
+                  transport: "websocket",
+                  open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
+                  client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
+                  server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
+                },
+              ],
+            }),
           ),
           ),
-        )
-        const executor = yield* HttpRecorder.makeWebSocketExecutor({
-          name: "websocket/replay",
-          cassette,
-          compareClientMessagesAsJson: true,
-          live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
-        })
-        const connection = yield* executor.open({
-          url: "wss://example.test/realtime",
-          headers: Headers.fromInput({ "content-type": "application/json" }),
-        })
-        yield* connection.sendText(JSON.stringify({ type: "response.create" }))
-        const messages: Array<string | Uint8Array> = []
-        yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
-        yield* connection.close
-
-        expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
-      }),
+        ),
+      ),
     )
     )
   })
   })
 
 
@@ -228,17 +221,14 @@ describe("http-recorder", () => {
         yield* connection.messages.pipe(Stream.runDrain)
         yield* connection.messages.pipe(Stream.runDrain)
         yield* connection.close
         yield* connection.close
 
 
-        expect(yield* cassette.read("websocket/record")).toMatchObject({
-          metadata: { name: "websocket/record", provider: "test" },
-          interactions: [
-            {
-              transport: "websocket",
-              open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
-              client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
-              server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
-            },
-          ],
-        })
+        expect(yield* cassette.read("websocket/record")).toMatchObject([
+          {
+            transport: "websocket",
+            open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
+            client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
+            server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
+          },
+        ])
       }),
       }),
     )
     )
   })
   })
@@ -303,28 +293,18 @@ describe("http-recorder", () => {
 
 
   test("auto mode replays when the cassette exists", async () => {
   test("auto mode replays when the cassette exists", async () => {
     const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
     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,
-        ),
-      ),
-    )
+    await seedCassetteDirectory(directory, "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"}' },
+      },
+    ])
 
 
     const result = await runWith(
     const result = await runWith(
       "auto-replay",
       "auto-replay",

+ 1 - 1
packages/llm/test/recorded-test.ts

@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
         ...metadata,
         ...metadata,
       }
       }
       const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
       const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
-      const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
+      const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
         Layer.provide(NodeFileSystem.layer),
         Layer.provide(NodeFileSystem.layer),
       )
       )
       const requestExecutor = RequestExecutor.layer.pipe(
       const requestExecutor = RequestExecutor.layer.pipe(