@opencode-ai/ai 0.0.0-dev-18562 → 0.0.0-dev-18564

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -214,22 +214,40 @@ the requests sent by code under test:
214
214
  import { Effect } from "effect"
215
215
  import { TestLLM } from "@opencode-ai/ai/testing"
216
216
 
217
- const testLLM = TestLLM.layer({
218
- fallback: TestLLM.text("Hello from the test model", "text-1"),
219
- })
220
-
221
- // TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
222
217
  const programWithTestClient = Effect.gen(function* () {
218
+ const test = yield* TestLLM.Test
219
+ yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
223
220
  const result = yield* program
224
- const test = yield* TestLLM.Service
225
- console.log(test.requests)
221
+ console.log(yield* test.requests())
226
222
  return result
227
- }).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
223
+ }).pipe(Effect.provide(TestLLM.testLayer()))
228
224
  ```
229
225
 
230
- `TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
231
- `TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
232
- available on the yielded `TestLLM.Service`.
226
+ `testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
227
+ normal client; tests use the additional controls. Each layer build has fresh state.
228
+
229
+ - `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
230
+ - `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
231
+ - `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
232
+ other without changing queued replies; queued replies take precedence.
233
+ - `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
234
+ `serve` receives the original canonical request.
235
+ - `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
236
+ - `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
237
+ unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
238
+ already provide Scope.
239
+
240
+ Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
241
+ Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
242
+ future reply.
243
+
244
+ Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
245
+ supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
246
+ it does not repair or truncate them.
247
+
248
+ The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
249
+ over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
250
+ `testLayer`.
233
251
 
234
252
  ## Caching
235
253
 
package/dist/testing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * as TestLLM from "./testing.js";
2
- import { type Interface as LLMClientShape } from "./route/client.js";
2
+ import { LLMClient } from "./route/client.js";
3
3
  import { LLMEvent, type FinishReasonDetails, type AIError, type LLMRequest, type ProviderMetadata, type UsageInput } from "./schema/index.js";
4
4
  import { Context, Effect, Layer, Scope, Stream } from "effect";
5
5
  export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>;
@@ -7,13 +7,31 @@ export type Gate = Readonly<{
7
7
  started: Effect.Effect<void>;
8
8
  release: Effect.Effect<void>;
9
9
  }>;
10
+ type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>;
11
+ export type Responder = (request: LLMRequest) => Response;
12
+ export interface TestInterface extends ClientInterface {
13
+ /** Returns a snapshot of requests observed at execution time. */
14
+ readonly requests: () => Effect.Effect<readonly LLMRequest[]>;
15
+ readonly push: (...responses: readonly Response[]) => Effect.Effect<void>;
16
+ /** Replaces the fallback without changing queued responses. */
17
+ readonly always: (response: Response) => Effect.Effect<void>;
18
+ /** Answers requests after the one-shot queue is exhausted; receives the original request. */
19
+ readonly serve: (responder: Responder) => Effect.Effect<void>;
20
+ /** Waits for request arrivals, not output or completion. */
21
+ readonly wait: (count: number) => Effect.Effect<void>;
22
+ readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>;
23
+ }
24
+ declare const Test_base: Context.ServiceClass<Test, "@opencode/ai/TestLLM/Test", TestInterface>;
25
+ export declare class Test extends Test_base {
26
+ }
27
+ /** @deprecated Use TestInterface through Test and testLayer. */
10
28
  export interface Interface {
11
29
  readonly requests: LLMRequest[];
12
30
  readonly push: (...responses: readonly Response[]) => Effect.Effect<void>;
13
31
  readonly always: (response: Response) => Effect.Effect<void>;
14
32
  readonly wait: (count: number) => Effect.Effect<void>;
15
33
  readonly gate: Effect.Effect<Gate, never, Scope.Scope>;
16
- readonly client: LLMClientShape;
34
+ readonly client: ClientInterface;
17
35
  }
18
36
  export interface LayerOptions {
19
37
  readonly transformRequest?: (request: LLMRequest) => LLMRequest;
@@ -21,6 +39,7 @@ export interface LayerOptions {
21
39
  readonly fallback?: Response;
22
40
  }
23
41
  declare const Service_base: Context.ServiceClass<Service, "@opencode/ai/TestLLM", Interface>;
42
+ /** @deprecated Use Test and testLayer for normal client methods and test controls. */
24
43
  export declare class Service extends Service_base {
25
44
  }
26
45
  export declare const complete: (options: {
@@ -1507,7 +1526,11 @@ export declare const hangAfter: (...events: readonly LLMEvent[]) => Stream.Strea
1507
1526
  } | undefined;
1508
1527
  readonly classification?: "context-overflow" | "payload-too-large" | undefined;
1509
1528
  }, never, never>;
1529
+ /** Provides one shared implementation under the normal client and test-control tags. */
1530
+ export declare const testLayer: (options?: LayerOptions) => Layer.Layer<import("./route/client.js").Service | Test, never, never>;
1531
+ /** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
1510
1532
  export declare const layer: (options?: LayerOptions) => Layer.Layer<Service, never, never>;
1533
+ /** @deprecated testLayer provides LLMClient.Service directly. */
1511
1534
  export declare const clientLayer: Layer.Layer<import("./route/client.js").Service, never, Service>;
1512
1535
  export declare const push: (...responses: readonly Response[]) => Effect.Effect<void, never, Service>;
1513
1536
  export declare const always: (response: Response) => Effect.Effect<void, never, Service>;
package/dist/testing.js CHANGED
@@ -2,6 +2,9 @@ export * as TestLLM from "./testing.js";
2
2
  import { LLMClient } from "./route/client.js";
3
3
  import { LLMEvent, LLMResponse, } from "./schema/index.js";
4
4
  import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect";
5
+ export class Test extends Context.Service()("@opencode/ai/TestLLM/Test") {
6
+ }
7
+ /** @deprecated Use Test and testLayer for normal client methods and test controls. */
5
8
  export class Service extends Context.Service()("@opencode/ai/TestLLM") {
6
9
  }
7
10
  export const complete = (options, ...events) => [
@@ -28,28 +31,33 @@ export const tool = (id, name, input) => toolCalls(LLMEvent.toolCall({ id, name,
28
31
  export const failAfter = (error, ...events) => Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)));
29
32
  export const hangAfter = (...events) => Stream.concat(Stream.fromIterable(events), Stream.never);
30
33
  const toStream = (response) => (Stream.isStream(response) ? response : Stream.fromIterable(response));
31
- export const layer = (options = {}) => Layer.effect(Service, Effect.gen(function* () {
34
+ const make = (options) => Effect.sync(() => {
32
35
  const requests = [];
33
36
  const responses = [];
34
37
  let started = Deferred.makeUnsafe();
35
38
  let fallback = options.fallback;
36
39
  let activeGate;
37
40
  const wait = (count) => Effect.suspend(() => requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))));
38
- const stream = ((request) => {
39
- requests.push(options.transformRequest?.(request) ?? request);
41
+ const stream = (request) => Stream.suspend(() => {
42
+ const count = requests.push(options.transformRequest?.(request) ?? request);
40
43
  const waiting = started;
41
44
  started = Deferred.makeUnsafe();
42
- Deferred.doneUnsafe(waiting, Effect.void);
43
- const response = responses.shift() ?? fallback;
44
- if (!response)
45
- return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`));
46
- const streamed = toStream(response);
47
45
  const gate = activeGate;
48
- if (!gate)
49
- return streamed;
50
- return Stream.unwrap(Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)));
46
+ try {
47
+ const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback);
48
+ if (!response)
49
+ return Stream.die(new Error(`TestLLM has no response for request ${count}`));
50
+ const streamed = toStream(response);
51
+ if (!gate)
52
+ return streamed;
53
+ return Stream.unwrap(Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)));
54
+ }
55
+ finally {
56
+ // Waiters can resume synchronously; assign the reply and gate before notifying them.
57
+ Deferred.doneUnsafe(waiting, Effect.void);
58
+ }
51
59
  });
52
- const client = LLMClient.Service.of({
60
+ const test = Test.of({
53
61
  stream,
54
62
  generate: (request) => stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce), Effect.flatMap((state) => {
55
63
  const response = LLMResponse.complete(state);
@@ -57,17 +65,18 @@ export const layer = (options = {}) => Layer.effect(Service, Effect.gen(function
57
65
  return Effect.succeed(response);
58
66
  return Effect.die("TestLLM response ended without a terminal finish event");
59
67
  })),
60
- });
61
- return Service.of({
62
- requests,
68
+ requests: () => Effect.sync(() => [...requests]),
63
69
  push: (...input) => Effect.sync(() => {
64
70
  responses.push(...input);
65
71
  }),
66
72
  always: (response) => Effect.sync(() => {
67
73
  fallback = response;
68
74
  }),
75
+ serve: (responder) => Effect.sync(() => {
76
+ fallback = responder;
77
+ }),
69
78
  wait,
70
- gate: Effect.gen(function* () {
79
+ gate: () => Effect.gen(function* () {
71
80
  const gate = {
72
81
  started: yield* Effect.acquireRelease(Queue.unbounded(), Queue.shutdown),
73
82
  release: yield* Latch.make(),
@@ -83,9 +92,21 @@ export const layer = (options = {}) => Layer.effect(Service, Effect.gen(function
83
92
  release,
84
93
  };
85
94
  }),
86
- client,
87
95
  });
88
- }));
96
+ return { test, requests };
97
+ });
98
+ /** Provides one shared implementation under the normal client and test-control tags. */
99
+ export const testLayer = (options = {}) => Layer.effectContext(Effect.map(make(options), (implementation) => Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test))));
100
+ /** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
101
+ export const layer = (options = {}) => Layer.effect(Service, Effect.map(make(options), (implementation) => Service.of({
102
+ requests: implementation.requests,
103
+ push: implementation.test.push,
104
+ always: implementation.test.always,
105
+ wait: implementation.test.wait,
106
+ gate: implementation.test.gate(),
107
+ client: implementation.test,
108
+ })));
109
+ /** @deprecated testLayer provides LLMClient.Service directly. */
89
110
  export const clientLayer = Layer.effect(LLMClient.Service, Effect.map(Service, (service) => service.client));
90
111
  export const push = (...responses) => Service.use((service) => service.push(...responses));
91
112
  export const always = (response) => Service.use((service) => service.always(response));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "0.0.0-dev-18562",
3
+ "version": "0.0.0-dev-18564",
4
4
  "name": "@opencode-ai/ai",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@clack/prompts": "1.0.0-alpha.1",
32
32
  "@effect/platform-node": "4.0.0-rc.112",
33
- "@opencode-ai/http-recorder": "0.0.0-dev-18562",
33
+ "@opencode-ai/http-recorder": "0.0.0-dev-18564",
34
34
  "@tsconfig/bun": "1.0.9",
35
35
  "@types/bun": "1.3.13",
36
36
  "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@smithy/eventstream-codec": "4.2.14",
41
41
  "@smithy/util-utf8": "4.2.2",
42
- "@opencode-ai/schema": "0.0.0-dev-18562",
42
+ "@opencode-ai/schema": "0.0.0-dev-18564",
43
43
  "aws4fetch": "1.0.20",
44
44
  "effect": "4.0.0-rc.112",
45
45
  "google-auth-library": "10.5.0"