opencode/packages/llm/test/recorded-test.ts
Kit Langton e101318f92 refactor(http-recorder): hide cassette format behind Cassette seam
The Cassette interface used to expose its JSON wrapper type (Cassette =
{ version, metadata, interactions }) through read/write/scan, plus a
path() method. Consumers had to know the file format to use the service.

New surface — domain types only:

  interface Interface {
    read:   (name) => Effect<readonly Interaction[], CassetteNotFoundError>
    append: (name, interaction, metadata?) => Effect<{ findings }>
    exists: (name) => Effect<boolean>
    list:   () => Effect<readonly string[]>
  }

What's gone from the public surface: path(), write(), scan(), the
Cassette wrapper type, Entry.path, the format helpers (cassetteFor,
formatCassette, parseCassette, cassettePath). All file-private to the
new fileSystem adapter.

Two adapters:
- Cassette.fileSystem({ directory? }) — file-backed (today's behavior,
  was Cassette.layer)
- Cassette.memory(initial?) — in-memory, used by the package's own
  tests to exercise replay without disk IO. Proves the seam works;
  not user-facing.

Drive-bys from the simplify pass:
- Errors converted to Data.TaggedError (CassetteNotFoundError,
  UnsafeCassetteError) for Effect-native equality + integration.
- storage.ts (5 LOC stub) merged into cassette.ts. hasCassetteSync
  is now exported from cassette.ts directly.
- Entry { name } collapsed to plain string.
- recorder.ts re-export indirection cleaned up; index.ts pulls
  CassetteNotFoundError from cassette.ts directly.
- httpInteractions / webSocketInteractions now take ReadonlyArray<Interaction>
  rather than Cassette (matches the new read shape).

External consumer in packages/llm/test/recorded-test.ts updated
(Cassette.layer → Cassette.fileSystem).
2026-05-10 12:21:48 -04:00

76 lines
2.9 KiB
TypeScript

import { NodeFileSystem } from "@effect/platform-node"
import { HttpRecorder } from "@opencode-ai/http-recorder"
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 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"
import {
recordedEffectGroup,
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")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecordReplayOptions
}
type RecordedCaseOptions = RunnerCaseOptions & {
readonly options?: HttpRecorder.RecordReplayOptions
}
const mergeOptions = (
base: HttpRecorder.RecordReplayOptions | undefined,
override: HttpRecorder.RecordReplayOptions | undefined,
) => {
if (!base) return override
if (!override) return base
return {
...base,
...override,
metadata: base.metadata || override.metadata ? { ...base.metadata, ...override.metadata } : undefined,
}
}
export const recordedTests = (options: RecordedTestsOptions) =>
recordedEffectGroup<RecordedEnv, never, RecordedTestsOptions, RecordedCaseOptions>({
duplicateLabel: "recorded cassette",
options,
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 = recorderOptions?.mode ?? (recording ? "record" : "replay")
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer),
)
const requestExecutor = RequestExecutor.layer.pipe(
Layer.provide(
HttpRecorder.recordingLayer(cassette, {
...recorderOptions,
mode,
metadata: recorderMetadata,
}).pipe(Layer.provide(FetchHttpClient.layer)),
),
)
const deps = Layer.mergeAll(
requestExecutor,
webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }),
)
return Layer.mergeAll(deps, LLMClient.layerWithWebSocket.pipe(Layer.provide(deps))).pipe(
Layer.provide(cassetteService),
)
},
})