Skip to content

Commit 3364bf5

Browse files
authored
feat(ai): add test clients for image, video, speech, transcription and decisions (#59992)
1 parent dfa6329 commit 3364bf5

13 files changed

Lines changed: 455 additions & 85 deletions

File tree

‎packages/ai/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,7 +1167,7 @@ Everything below is exported from `@opencode/ai/effect` unless noted.
11671167
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
11681168
- **`Decision.request` / `Decision.generate`** — typed predicate, choice, and score questions over text, JSON, and media input; `DecisionClient` is its Effect service and layer.
11691169
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
1170-
- **`@opencode/ai`** (Promise root) — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, `transcription`, and `decision` as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
1170+
- **`@opencode/ai`** (Promise root) — `AI.make({ layer?, clients? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, `transcription`, and `decision` as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
11711171
- **`@opencode/ai/experimental`** — APIs that may still change: `Extraction` (typed values read out of text) and `Realtime` (`Realtime.connect` starts an OpenAI, xAI, or Gemini voice session and returns a WebRTC answer SDP or a short-lived WebSocket URL and token).
11721172

11731173
## Testing
@@ -1214,6 +1214,10 @@ For explicit compaction, script a `CompactionResponse` through `push`, `always`,
12141214

12151215
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
12161216

1217+
`TestImage`, `TestVideo`, `TestSpeech`, `TestTranscription`, and `TestDecision` follow the same pattern: `testLayer()` provides the modality's client and `Test`, with the same `push`, `always`, `serve`, `requests`, `wait`, and `gate` controls. Script an `ImageResponse` (or the modality's response) or an `AIError`. `stream` expands the response into its events, and `start` returns an already finished generation that `resume` accepts by token. Decision responses are returned as scripted, without checking the answers against the questions.
1218+
1219+
The Promise client accepts the same layers as `AI.make({ clients: TestImage.testLayer() })`; `ai.run(TestImage.Test.use(...))` reaches the controls.
1220+
12171221
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
12181222
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
12191223
`testLayer`.

‎packages/ai/src/index.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } fro
3030
* transcription and decision clients over a request executor; every method runs the corresponding Effect API and rethrows
3131
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
3232
*/
33-
export interface Options {
33+
export interface Options<R = never> {
3434
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
3535
readonly layer?: Layer.Layer<RequestExecutor.Service>
36+
/** Replaces default clients, for example with `TestImage.testLayer()`; its other services are available to `run`. */
37+
readonly clients?: Layer.Layer<R>
3638
}
3739

3840
export interface RunOptions {
@@ -71,14 +73,20 @@ const abortEffect = (signal: AbortSignal | undefined) =>
7173
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
7274
})
7375

74-
export const make = (options: Options = {}) => {
75-
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
76+
export const make = <R = never>(options: Options<R> = {}) => {
77+
const runtime = ManagedRuntime.make(
78+
Layer.merge(
79+
AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer),
80+
// `R` is only inferred from `clients`, so it is `never` whenever `clients` is omitted.
81+
(options.clients ?? Layer.empty) as Layer.Layer<R>,
82+
),
83+
)
7684

7785
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
78-
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
86+
const run = <A, E>(effect: Effect.Effect<A, E, Services | R>, options?: RunOptions) =>
7987
runtime.runPromise(Effect.raceFirst(effect, abortEffect(options?.signal)))
8088

81-
const iterate = <A, E>(stream: Stream.Stream<A, E, Services>, options?: RunOptions): AsyncIterable<A> =>
89+
const iterate = <A, E>(stream: Stream.Stream<A, E, Services | R>, options?: RunOptions): AsyncIterable<A> =>
8290
Stream.toAsyncIterable(
8391
Stream.unwrap(
8492
runtime.contextEffect.pipe(
@@ -234,7 +242,7 @@ export const make = (options: Options = {}) => {
234242
}
235243
}
236244

237-
export type Client = ReturnType<typeof make>
245+
export type Client<R = never> = ReturnType<typeof make<R>>
238246

239247
const fileError = (message: string, cause: unknown) =>
240248
new AIError({ reason: new InvalidRequestError({ message, cause }) })

‎packages/ai/src/media-client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const make = <Self, Req extends RoutedRequest<Req, Event, Response>, Even
2626
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
2727
},
2828
) => ({
29+
responseEvents: input.responseEvents,
2930
layer: Layer.effect(
3031
service,
3132
Effect.gen(function* () {

‎packages/ai/src/testing.ts‎

Lines changed: 14 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export * as TestLLM from "./testing.js"
22
export { compileRequest } from "./route/client.js"
3+
export { TestDecision } from "./testing/decision.js"
4+
export { TestImage } from "./testing/image.js"
5+
export { TestSpeech } from "./testing/speech.js"
6+
export { TestTranscription } from "./testing/transcription.js"
7+
export { TestVideo } from "./testing/video.js"
38

49
import {
510
LLMClient,
@@ -19,40 +24,26 @@ import {
1924
type ProviderMetadata,
2025
type UsageInput,
2126
} from "./schema/index.js"
22-
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
27+
import { Context, Effect, Layer, Stream } from "effect"
28+
import { TestScript } from "./testing/script.js"
2329

2430
export type Response =
2531
| readonly LLMEvent[]
2632
| Stream.Stream<LLMEvent, AIError>
2733
| CompactionResponse
2834
| CompactionCheckpointResponse
2935

30-
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
36+
export type Gate = TestScript.Gate
3137

3238
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>
3339

3440
export type Responder = (request: LLMRequest) => Response
3541

36-
export interface TestInterface extends ClientInterface {
37-
/** Returns a snapshot of requests observed at execution time. */
38-
readonly requests: () => Effect.Effect<readonly LLMRequest[]>
39-
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>
40-
/** Replaces the fallback without changing queued responses. */
41-
readonly always: (response: Response) => Effect.Effect<void>
42-
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
43-
readonly serve: (responder: Responder) => Effect.Effect<void>
44-
/** Waits for request arrivals, not output or completion. */
45-
readonly wait: (count: number) => Effect.Effect<void>
46-
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>
47-
}
42+
export interface TestInterface extends ClientInterface, TestScript.Controls<LLMRequest, Response> {}
4843

4944
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestLLM/Test") {}
5045

51-
export interface LayerOptions {
52-
readonly transformRequest?: (request: LLMRequest) => LLMRequest
53-
/** Used after the one-shot response queue is exhausted. Omit to defect on unexpected requests. */
54-
readonly fallback?: Response
55-
}
46+
export type LayerOptions = TestScript.Options<LLMRequest, Response>
5647

5748
export const complete = (
5849
options: {
@@ -101,35 +92,10 @@ export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Strea
10192

10293
const make = (options: LayerOptions) =>
10394
Effect.sync(() => {
104-
const requests: LLMRequest[] = []
105-
const responses: Response[] = []
106-
let started = Deferred.makeUnsafe<void>()
107-
let fallback: Response | Responder | undefined = options.fallback
108-
let activeGate: { readonly started: Queue.Queue<void>; readonly release: Latch.Latch } | undefined
109-
const wait = (count: number): Effect.Effect<void> =>
110-
Effect.suspend(() =>
111-
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
112-
)
113-
114-
const take = (request: LLMRequest) =>
115-
Effect.suspend(() => {
116-
const count = requests.push(options.transformRequest?.(request) ?? request)
117-
const waiting = started
118-
started = Deferred.makeUnsafe()
119-
const gate = activeGate
120-
try {
121-
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
122-
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
123-
if (!gate) return Effect.succeed(response)
124-
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
125-
} finally {
126-
// Waiters can resume synchronously; assign the reply and gate before notifying them.
127-
Deferred.doneUnsafe(waiting, Effect.void)
128-
}
129-
})
95+
const script = TestScript.make<LLMRequest, Response>("TestLLM", options)
13096
const stream: ClientInterface["stream"] = (request) =>
13197
Stream.unwrap(
132-
take(request).pipe(
98+
script.take(request).pipe(
13399
Effect.map((response) => {
134100
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
135101
return Stream.die("TestLLM generation requires an event response")
@@ -149,7 +115,7 @@ const make = (options: LayerOptions) =>
149115
request: LLMRequest,
150116
options?: EndpointCompactOptions | TriggerCompactOptions,
151117
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
152-
return take(request).pipe(
118+
return script.take(request).pipe(
153119
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
154120
if (options?.mechanism === "trigger")
155121
return response instanceof CompactionCheckpointResponse
@@ -162,6 +128,7 @@ const make = (options: LayerOptions) =>
162128
)
163129
}
164130
return Test.of({
131+
...script.controls,
165132
compact,
166133
stream,
167134
generate: (request) =>
@@ -173,36 +140,6 @@ const make = (options: LayerOptions) =>
173140
return Effect.die("TestLLM response ended without a terminal finish event")
174141
}),
175142
),
176-
requests: () => Effect.sync(() => [...requests]),
177-
push: (...input) =>
178-
Effect.sync(() => {
179-
responses.push(...input)
180-
}),
181-
always: (response) =>
182-
Effect.sync(() => {
183-
fallback = response
184-
}),
185-
serve: (responder) =>
186-
Effect.sync(() => {
187-
fallback = responder
188-
}),
189-
wait,
190-
gate: () =>
191-
Effect.gen(function* () {
192-
const gate = {
193-
started: yield* Effect.acquireRelease(Queue.unbounded<void>(), Queue.shutdown),
194-
release: yield* Latch.make(),
195-
}
196-
activeGate = gate
197-
const release = Effect.sync(() => {
198-
if (activeGate === gate) activeGate = undefined
199-
}).pipe(Effect.andThen(gate.release.open), Effect.asVoid)
200-
yield* Effect.addFinalizer(() => release)
201-
return {
202-
started: Queue.take(gate.started),
203-
release,
204-
}
205-
}),
206143
})
207144
})
208145

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Context, Effect, Layer } from "effect"
2+
import { DecisionClient, type Interface } from "../decision-client.js"
3+
import type { DecisionRequestFor, DecisionResponse, DecisionResponseFor } from "../decision.js"
4+
import { AIError } from "../schema/index.js"
5+
import { TestScript } from "./script.js"
6+
7+
/** Returned as scripted, without checking its answers against the request's questions. */
8+
export type Response = DecisionResponse | AIError
9+
10+
export interface TestInterface extends Interface, TestScript.Controls<DecisionRequestFor, Response> {}
11+
12+
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestDecision/Test") {}
13+
14+
export type LayerOptions = TestScript.Options<DecisionRequestFor, Response>
15+
16+
/** Provides one shared implementation under `DecisionClient.Service` and `TestDecision.Test`. */
17+
export const testLayer = (options: LayerOptions = {}) =>
18+
Layer.effectContext(
19+
Effect.sync(() => {
20+
const script = TestScript.make("TestDecision", options)
21+
const client = Test.of({
22+
...script.controls,
23+
generate: (request) =>
24+
script
25+
.take(request)
26+
.pipe(
27+
Effect.flatMap((response) =>
28+
response instanceof AIError
29+
? Effect.fail(response)
30+
: Effect.succeed(response as DecisionResponseFor<typeof request.questions>),
31+
),
32+
),
33+
})
34+
return Context.make(DecisionClient.Service, client).pipe(Context.add(Test, client))
35+
}),
36+
)
37+
38+
export * as TestDecision from "./decision.js"

‎packages/ai/src/testing/image.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Context } from "effect"
2+
import { ImageClient } from "../image-client.js"
3+
import type { ImageEvent, ImageRequestFor, ImageResponse } from "../image.js"
4+
import { TestMedia } from "./media.js"
5+
import type { TestScript } from "./script.js"
6+
7+
export type Response = TestMedia.Response<ImageResponse>
8+
9+
export interface TestInterface extends TestMedia.TestInterface<ImageRequestFor, ImageEvent, ImageResponse> {}
10+
11+
export class Test extends Context.Service<Test, TestInterface>()("@opencode/ai/TestImage/Test") {}
12+
13+
export type LayerOptions = TestScript.Options<ImageRequestFor, Response>
14+
15+
/** Provides one shared implementation under `ImageClient.Service` and `TestImage.Test`. */
16+
export const testLayer = (options: LayerOptions = {}) =>
17+
TestMedia.testLayer(ImageClient.Service, Test, {
18+
name: "TestImage",
19+
responseEvents: ImageClient.responseEvents,
20+
options,
21+
})
22+
23+
export * as TestImage from "./image.js"

‎packages/ai/src/testing/media.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Context, Effect, Layer, Stream } from "effect"
2+
import { Generation } from "../generation.js"
3+
import type { MediaClient } from "../media-client.js"
4+
import { AIError } from "../schema/index.js"
5+
import { TestScript } from "./script.js"
6+
7+
/** A scripted reply: the response every operation returns, or the error it fails with. */
8+
export type Response<Value> = Value | AIError
9+
10+
export interface TestInterface<Req extends MediaClient.RoutedRequest<Req, Event, Value>, Event, Value>
11+
extends MediaClient.Interface<Req, Event, Value>,
12+
TestScript.Controls<Req, Response<Value>> {}
13+
14+
/**
15+
* Builds one media test client. `stream` expands the response like an inline route, and `start` returns an already
16+
* finished generation whose token `resume` accepts.
17+
*/
18+
export const testLayer = <Self, Test, Req extends MediaClient.RoutedRequest<Req, Event, Value>, Event, Value>(
19+
service: Context.Service<Self, MediaClient.Interface<Req, Event, Value>>,
20+
test: Context.Service<Test, TestInterface<Req, Event, Value>>,
21+
input: {
22+
readonly name: string
23+
readonly responseEvents: (response: Value) => ReadonlyArray<Event>
24+
readonly options: TestScript.Options<Req, Response<Value>>
25+
},
26+
) =>
27+
Layer.effectContext(
28+
Effect.sync(() => {
29+
const script = TestScript.make(input.name, input.options)
30+
const generations = new Map<string, Response<Value>>()
31+
const result = (response: Response<Value>) =>
32+
response instanceof AIError ? Effect.fail(response) : Effect.succeed(response)
33+
const completed = (id: string, response: Response<Value>) => {
34+
const snapshot = { id, status: response instanceof AIError ? "failed" : "completed" } as const
35+
return new Generation({ status: Effect.succeed(snapshot), result: result(response) }, id, snapshot)
36+
}
37+
const generate = (request: Req) => script.take(request).pipe(Effect.flatMap(result))
38+
const client = test.of({
39+
...script.controls,
40+
generate,
41+
stream: (request) => Stream.fromIterableEffect(Effect.map(generate(request), input.responseEvents)),
42+
start: (request) =>
43+
script.take(request).pipe(
44+
Effect.map((response) => {
45+
const id = `${input.name}-${generations.size + 1}`
46+
generations.set(id, response)
47+
return completed(id, response)
48+
}),
49+
),
50+
resume: (_model, token) => {
51+
const response = typeof token === "string" ? generations.get(token) : undefined
52+
if (response === undefined) return Effect.die(new Error(`${input.name} has no generation ${String(token)}`))
53+
return Effect.succeed(completed(String(token), response))
54+
},
55+
})
56+
return Context.make(service, client).pipe(Context.add(test, client))
57+
}),
58+
)
59+
60+
export * as TestMedia from "./media.js"

0 commit comments

Comments
 (0)