@evoclock/pi-agentic-driver 0.9.2 → 0.9.3

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.
@@ -0,0 +1,358 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Unit tests: audit log contents and rotation/retention, daily cost breaker,
5
+ // per-request timeout budget, single-flight coalescing through the Evaluator,
6
+ // and the provider failover policy. NO live provider calls — the chain and
7
+ // adapters are fully mocked.
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { Evaluator } from "../janus/evaluate.js";
15
+ import { SingleFlight } from "../janus/single_flight.js";
16
+ import { DailyBudget } from "../janus/budget_breaker.js";
17
+ import { AuditLog } from "../janus/audit.js";
18
+ import { ProviderChain, ProviderTransportError, type TransportRequest } from "../janus/providers.js";
19
+ import { busyError, gatewayUnavailable, timeoutError, validationError } from "../janus/errors.js";
20
+ import { testConfig, testProviders, mockChain, fakeKeyReader, mockAdapter } from "./helpers.js";
21
+
22
+ function tempDir(): string {
23
+ return mkdtempSync(join(tmpdir(), "janus-audit-test-"));
24
+ }
25
+
26
+ function makeEvaluator(
27
+ overrides: Parameters<typeof testConfig>[0] = {},
28
+ chainOptions: Parameters<typeof mockChain>[0] = { answers: { q: { type: "boolean", probability: 0.95 } } },
29
+ ) {
30
+ const config = testConfig(overrides);
31
+ const audit = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
32
+ audit.start();
33
+ const evaluator = new Evaluator({
34
+ config,
35
+ singleFlight: new SingleFlight(overrides.maxConcurrent ?? 4),
36
+ budget: new DailyBudget(
37
+ overrides.dailyTokenCeiling ?? 0,
38
+ overrides.dailyCostCeilingUsd ?? 0,
39
+ 0.5,
40
+ ),
41
+ audit,
42
+ chain: mockChain(chainOptions) as never,
43
+ });
44
+ return { config, audit, evaluator };
45
+ }
46
+
47
+ test("audit log records request id, timestamps, question names, probabilities, usage — never state or question text", async () => {
48
+ const dir = tempDir();
49
+ try {
50
+ const { config, evaluator } = makeEvaluator(
51
+ { audit: { path: join(dir, "audit"), maxBytes: 1024 * 1024, retentionDays: 90 } },
52
+ {
53
+ answers: { q: { type: "boolean", probability: 0.97 } },
54
+ usage: { inputTokens: 100, outputTokens: 10, totalTokens: 110 },
55
+ },
56
+ );
57
+ const response = await evaluator.handle({
58
+ state: { secret: "AKIAIOSFODNN7EXAMPLE", body: "refund details" },
59
+ questions: [{ type: "boolean", name: "q", question: "Was a refund issued?" }],
60
+ });
61
+ assert.equal(response.status, 200);
62
+
63
+ const lines = readFileSync(`${config.audit.path}.jsonl`, "utf8").trim().split("\n");
64
+ assert.equal(lines.length, 1);
65
+ const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
66
+ assert.equal(entry.event, "evaluate_result");
67
+ assert.ok(typeof entry.requestId === "string" && entry.requestId.length > 0);
68
+ assert.ok(typeof entry.timestamp === "string");
69
+ // Question NAMES (non-sensitive identifiers), never question text.
70
+ assert.deepEqual(entry.questionNames, ["q"]);
71
+ assert.deepEqual(entry.verdictProbabilities, { q: 0.97 });
72
+ assert.deepEqual(entry.tokenUsage, { inputTokens: 100, outputTokens: 10, totalTokens: 110 });
73
+ const serialized = lines[0]!;
74
+ assert.ok(!serialized.includes("AKIAIOSFODNN7EXAMPLE"), "audit must never contain state contents");
75
+ assert.ok(!serialized.includes("refund details"), "audit must never contain state contents");
76
+ assert.ok(!serialized.includes("Was a refund issued?"), "audit must never contain question text");
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test("audit log errors are recorded with the error code", async () => {
83
+ const dir = tempDir();
84
+ try {
85
+ const { config, evaluator } = makeEvaluator(
86
+ { audit: { path: join(dir, "audit"), maxBytes: 1024 * 1024, retentionDays: 90 } },
87
+ { answers: {} },
88
+ );
89
+ await evaluator.handle({ state: "s", questions: [{ type: "boolean", name: "q", question: "q" }] });
90
+ const lines = readFileSync(`${config.audit.path}.jsonl`, "utf8").trim().split("\n");
91
+ const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
92
+ assert.equal(entry.event, "evaluate_error");
93
+ assert.equal(entry.errorCode, "validation_error");
94
+ } finally {
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test("audit log rotates oldest-first when size-capped", () => {
100
+ const dir = tempDir();
101
+ try {
102
+ const config = testConfig({
103
+ audit: { path: join(dir, "audit"), maxBytes: 400, retentionDays: 90 },
104
+ });
105
+ const audit = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
106
+ audit.start();
107
+ for (let i = 0; i < 5; i += 1) {
108
+ audit.append({
109
+ requestId: `req-${i}-${"x".repeat(80)}`,
110
+ timestamp: new Date().toISOString(),
111
+ event: "evaluate_result",
112
+ questionNames: ["q"],
113
+ verdictProbabilities: { q: 0.9 },
114
+ tokenUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
115
+ });
116
+ }
117
+ audit.stop();
118
+ assert.ok(existsSync(`${config.audit.path}.jsonl.1`), "rotation must produce .1");
119
+ assert.ok(!existsSync(`${config.audit.path}.jsonl.6`), "no more than 5 rotated files");
120
+ } finally {
121
+ rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("audit retention deletes rotated files older than retentionDays", async () => {
126
+ const dir = tempDir();
127
+ try {
128
+ const config = testConfig({
129
+ audit: { path: join(dir, "audit"), maxBytes: 1024 * 1024, retentionDays: 90 },
130
+ });
131
+ const audit = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
132
+ audit.start();
133
+ audit.append({
134
+ requestId: "r1",
135
+ timestamp: new Date().toISOString(),
136
+ event: "evaluate_result",
137
+ questionNames: [],
138
+ });
139
+ audit.stop();
140
+
141
+ // Fabricate an ancient rotated file.
142
+ const { utimesSync, writeFileSync } = await import("node:fs");
143
+ const oldPath = `${config.audit.path}.jsonl.1`;
144
+ writeFileSync(oldPath, "{}\n");
145
+ const ancient = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000);
146
+ utimesSync(oldPath, ancient, ancient);
147
+
148
+ const audit2 = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
149
+ audit2.start();
150
+ audit2.stop();
151
+ assert.ok(!existsSync(oldPath), "rotated file older than retention must be deleted");
152
+ } finally {
153
+ rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("audit retention purges expired records from the ACTIVE file (review fix #7)", async () => {
158
+ const dir = tempDir();
159
+ try {
160
+ const config = testConfig({
161
+ audit: { path: join(dir, "audit"), maxBytes: 1024 * 1024, retentionDays: 90 },
162
+ });
163
+ const audit = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
164
+ audit.start();
165
+
166
+ const fresh = new Date().toISOString();
167
+ const ancientTimestamp = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString();
168
+ audit.append({
169
+ requestId: "old-record",
170
+ timestamp: ancientTimestamp,
171
+ event: "evaluate_result",
172
+ questionNames: [],
173
+ });
174
+ audit.append({
175
+ requestId: "new-record",
176
+ timestamp: fresh,
177
+ event: "evaluate_result",
178
+ questionNames: [],
179
+ });
180
+ audit.append({
181
+ requestId: "another-old-record",
182
+ timestamp: ancientTimestamp,
183
+ event: "evaluate_error",
184
+ questionNames: [],
185
+ errorCode: "timeout",
186
+ });
187
+
188
+ // Force a sweep by running retention again.
189
+ audit.stop();
190
+ const audit2 = new AuditLog(config.audit.path, config.audit.maxBytes, config.audit.retentionDays);
191
+ audit2.start();
192
+ audit2.stop();
193
+
194
+ const serialized = readFileSync(`${config.audit.path}.jsonl`, "utf8");
195
+ assert.ok(!serialized.includes("old-record"), "expired active-file records must be purged");
196
+ assert.ok(!serialized.includes("another-old-record"), "expired active-file records must be purged");
197
+ assert.ok(serialized.includes("new-record"), "fresh records must survive");
198
+ } finally {
199
+ rmSync(dir, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ test("daily cost breaker: busy with budget_exhausted on token ceiling", async () => {
204
+ const { evaluator } = makeEvaluator(
205
+ { dailyTokenCeiling: 100 },
206
+ {
207
+ answers: { q: { type: "boolean", probability: 0.95 } },
208
+ usage: { inputTokens: 60, outputTokens: 60, totalTokens: 120 },
209
+ },
210
+ );
211
+ const first = await evaluator.handle({
212
+ state: "s",
213
+ questions: [{ type: "boolean", name: "q", question: "q" }],
214
+ });
215
+ assert.equal(first.status, 200);
216
+ const second = await evaluator.handle({
217
+ state: "s2",
218
+ questions: [{ type: "boolean", name: "q", question: "q" }],
219
+ });
220
+ assert.equal(second.status, 429);
221
+ assert.equal(second.payload.error, "busy");
222
+ const detail = (second.payload.detail as { budget_exhausted?: { kind: string } })?.budget_exhausted;
223
+ assert.equal(detail?.kind, "tokens");
224
+ });
225
+
226
+ test("daily cost breaker: window resets on day change", () => {
227
+ let now = new Date("2026-09-16T23:59:00Z");
228
+ const budget = new DailyBudget(100, 0, 0.5, () => now);
229
+ budget.record({ totalTokens: 100 });
230
+ assert.equal(budget.status().exhausted, true);
231
+ now = new Date("2026-09-17T00:00:01Z");
232
+ assert.equal(budget.status().exhausted, false);
233
+ assert.equal(budget.status().tokensUsed, 0);
234
+ });
235
+
236
+ test("per-request timeout: client budget capped at 30s and honored", async () => {
237
+ const { evaluator } = makeEvaluator({}, { answers: { q: { type: "boolean", probability: 0.95 } }, delayMs: 200 });
238
+ const response = await evaluator.handle({
239
+ state: "s",
240
+ timeoutMs: 50,
241
+ questions: [{ type: "boolean", name: "q", question: "q" }],
242
+ });
243
+ assert.equal(response.status, 504);
244
+ assert.equal(response.payload.error, "timeout");
245
+ // Fixed public message (review fix #5).
246
+ assert.equal(response.payload.message, "evaluation timed out");
247
+ });
248
+
249
+ test("single-flight through the Evaluator: identical requests coalesce; usage charged once (review fix #11)", async () => {
250
+ const calls: unknown[] = [];
251
+ const audit = { append: () => {} } as unknown as AuditLog;
252
+ const evaluator = new Evaluator({
253
+ config: testConfig(),
254
+ singleFlight: new SingleFlight(4),
255
+ budget: new DailyBudget(10_000, 0, 0.5),
256
+ audit,
257
+ chain: mockChain({
258
+ answers: { q: { type: "boolean", probability: 0.95 } },
259
+ delayMs: 60,
260
+ calls: calls as never,
261
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
262
+ }) as never,
263
+ });
264
+ const body = { state: "shared state", questions: [{ type: "boolean", name: "q", question: "q" }] };
265
+ const [a, b, c] = await Promise.all([
266
+ evaluator.handle(body),
267
+ evaluator.handle(body),
268
+ evaluator.handle(body),
269
+ ]);
270
+ assert.equal(a.status, 200);
271
+ assert.equal(b.status, 200);
272
+ assert.equal(c.status, 200);
273
+ assert.equal(calls.length, 1, "identical requests must coalesce to one provider call");
274
+ assert.equal(
275
+ JSON.stringify(a.payload.verdict) === JSON.stringify(b.payload.verdict) &&
276
+ JSON.stringify(b.payload.verdict) === JSON.stringify(c.payload.verdict),
277
+ true,
278
+ "coalesced requests share the same verdict",
279
+ );
280
+ // Usage is charged exactly once: 3 callers × 120 tokens would be 360.
281
+ assert.equal(a.payload.usage && (a.payload.usage as { totalTokens: number }).totalTokens, 120);
282
+ });
283
+
284
+ test("readiness not ready only when NO provider credential resolves", async () => {
285
+ const chain = new ProviderChain(
286
+ testProviders(),
287
+ [mockAdapter({ id: "typesafe-direct" })],
288
+ fakeKeyReader(new Set(["typesafe-ai"])),
289
+ );
290
+ const readiness = await chain.readiness();
291
+ assert.equal(readiness.ready, false);
292
+ assert.match(readiness.reason ?? "", /no provider credential resolvable/);
293
+ assert.ok(!/test-key|secret value/i.test(readiness.reason ?? ""), "no key fragments in the reason");
294
+ });
295
+
296
+ test("busy from concurrency cap surfaces through the evaluator", async () => {
297
+ const audit = { append: () => {} } as unknown as AuditLog;
298
+ const evaluator = new Evaluator({
299
+ config: testConfig({ maxConcurrent: 1 }),
300
+ singleFlight: new SingleFlight(1),
301
+ budget: new DailyBudget(0, 0, 0.5),
302
+ audit,
303
+ chain: mockChain({
304
+ answers: { q: { type: "boolean", probability: 0.9 } },
305
+ delayMs: 150,
306
+ }) as never,
307
+ });
308
+ const first = evaluator.handle({ state: "s1", questions: [{ type: "boolean", name: "q", question: "q" }] });
309
+ await new Promise((r) => setTimeout(r, 20));
310
+ const second = await evaluator.handle({
311
+ state: "s2",
312
+ questions: [{ type: "boolean", name: "q", question: "q" }],
313
+ });
314
+ assert.equal(second.status, 429);
315
+ assert.equal(second.payload.error, "busy");
316
+ assert.equal((await first).status, 200);
317
+ });
318
+
319
+ test("JanusError from inside the flight is preserved (not re-classified)", async () => {
320
+ const audit = { append: () => {} } as unknown as AuditLog;
321
+ const evaluator = new Evaluator({
322
+ config: testConfig(),
323
+ singleFlight: new SingleFlight(4),
324
+ budget: new DailyBudget(0, 0, 0.5),
325
+ audit,
326
+ chain: mockChain({
327
+ answers: { q: { type: "boolean", probability: 0.95 } },
328
+ fail: () => busyError("injected"),
329
+ }) as never,
330
+ });
331
+ const response = await evaluator.handle({
332
+ state: "s",
333
+ questions: [{ type: "boolean", name: "q", question: "q" }],
334
+ });
335
+ assert.equal(response.payload.error, "busy");
336
+ assert.equal(response.payload.message, "injected", "JanusError messages are janus-authored, not provider text");
337
+ });
338
+
339
+ test("audit entry records provider attempts and coalescing marker (review fix #11)", async () => {
340
+ const dir = tempDir();
341
+ try {
342
+ const { config, evaluator } = makeEvaluator(
343
+ { audit: { path: join(dir, "audit"), maxBytes: 1024 * 1024, retentionDays: 90 } },
344
+ { answers: { q: { type: "boolean", probability: 0.97 } }, attempts: 1, provider: "typesafe-direct" },
345
+ );
346
+ await evaluator.handle({
347
+ state: "s",
348
+ questions: [{ type: "boolean", name: "q", question: "q" }],
349
+ });
350
+ const lines = readFileSync(`${config.audit.path}.jsonl`, "utf8").trim().split("\n");
351
+ const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
352
+ assert.equal(entry.providerAttempts, 1);
353
+ assert.equal(entry.provider, "typesafe-direct");
354
+ assert.equal(entry.coalesced, false);
355
+ } finally {
356
+ rmSync(dir, { recursive: true, force: true });
357
+ }
358
+ });
@@ -0,0 +1,217 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Shared test fixtures: janus config plus provider-chain mocks.
5
+ // NO live provider calls anywhere in the test suite — adapters are fully
6
+ // mocked and no real credentials are read or used.
7
+
8
+ import type { JanusConfig, ProviderConfig } from "../janus/config.js";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type {
12
+ ChainEvaluateOutcome,
13
+ KeyReader,
14
+ ProviderAdapter,
15
+ TransportRequest,
16
+ TransportResult,
17
+ TransportUsage,
18
+ } from "../janus/providers.js";
19
+
20
+ /** Sole configured provider in tests: direct TypeSafe. */
21
+ export function testProviders(overrides: Partial<ProviderConfig>[] = []): ProviderConfig[] {
22
+ const base: ProviderConfig[] = [
23
+ {
24
+ id: "typesafe-direct",
25
+ model: "jev-latest",
26
+ keychain: { account: "typesafe-ai", service: "Typesafe AI" },
27
+ },
28
+ ];
29
+ return base.map((provider, index) => ({ ...provider, ...(overrides[index] ?? {}) }));
30
+ }
31
+
32
+ /**
33
+ * Two provider entries with the sole id (typesafe-direct) — used to exercise
34
+ * the retry/failover slot now that no fallback provider exists.
35
+ */
36
+ export function testRetryProviders(overrides: Partial<ProviderConfig>[] = []): ProviderConfig[] {
37
+ const base: ProviderConfig[] = [
38
+ {
39
+ id: "typesafe-direct",
40
+ model: "jev-latest",
41
+ keychain: { account: "typesafe-ai", service: "Typesafe AI" },
42
+ },
43
+ {
44
+ id: "typesafe-direct",
45
+ model: "jev-latest",
46
+ keychain: { account: "typesafe-ai-retry", service: "Typesafe AI" },
47
+ },
48
+ ];
49
+ return base.map((provider, index) => ({ ...provider, ...(overrides[index] ?? {}) }));
50
+ }
51
+
52
+ export function testConfig(overrides: Partial<JanusConfig> = {}): JanusConfig {
53
+ return {
54
+ host: "127.0.0.1",
55
+ port: 0,
56
+ providers: testProviders(),
57
+ defaultModel: "jev-latest",
58
+ maxConcurrent: 4,
59
+ timeoutMs: 30_000,
60
+ requestBudgetTokens: 30_000,
61
+ dailyTokenCeiling: 2_000_000,
62
+ dailyCostCeilingUsd: 10,
63
+ costPerMtokenUsd: 0.5,
64
+ audit: {
65
+ path: join(tmpdir(), `janus-test-audit-${Math.random().toString(36).slice(2)}`),
66
+ maxBytes: 10 * 1024 * 1024,
67
+ retentionDays: 90,
68
+ },
69
+ redactSecrets: true,
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ export interface MockAnswer {
75
+ type: "boolean" | "choice" | "score";
76
+ probability?: number;
77
+ choice?: string;
78
+ score?: number;
79
+ probabilities?: Record<string, number>;
80
+ }
81
+
82
+ export interface MockCall {
83
+ state: unknown;
84
+ questions: unknown;
85
+ abortSignal?: AbortSignal;
86
+ /** The adapter that received this call. */
87
+ adapterId?: string;
88
+ /** The API key passed to the adapter (test-only in-memory fake). */
89
+ apiKey?: string;
90
+ }
91
+
92
+ export interface MockAdapterOptions {
93
+ /** Provider id this fake adapter stands in for. */
94
+ id: string;
95
+ answers?: Record<string, MockAnswer>;
96
+ usage?: TransportUsage;
97
+ delayMs?: number;
98
+ /** Return an error to fail the call (raw error → adapter classification). */
99
+ fail?: (call: MockCall) => Error | undefined;
100
+ /** Record every call here. */
101
+ calls?: MockCall[];
102
+ /** Result override for full control (e.g. malformed answers). */
103
+ result?: (call: MockCall) => TransportResult;
104
+ }
105
+
106
+ /** Build a fake ProviderAdapter recording calls and returning canned answers. */
107
+ export function mockAdapter(options: MockAdapterOptions): ProviderAdapter {
108
+ return {
109
+ id: options.id as ProviderAdapter["id"],
110
+ async evaluate(request: TransportRequest, key: string, signal: AbortSignal): Promise<TransportResult> {
111
+ const call: MockCall = {
112
+ state: request.state,
113
+ questions: request.questions,
114
+ abortSignal: signal,
115
+ adapterId: options.id,
116
+ apiKey: key,
117
+ };
118
+ options.calls?.push(call);
119
+ const failure = options.fail?.(call);
120
+ if (failure) throw failure;
121
+ if (options.delayMs !== undefined) {
122
+ await new Promise<void>((resolvePromise, rejectPromise) => {
123
+ const timer = setTimeout(resolvePromise, options.delayMs);
124
+ signal.addEventListener("abort", () => {
125
+ clearTimeout(timer);
126
+ const error = new Error("This operation was aborted");
127
+ error.name = "AbortError";
128
+ rejectPromise(error);
129
+ });
130
+ });
131
+ }
132
+ if (options.result) return options.result(call);
133
+ const usage: TransportUsage = options.usage ?? {
134
+ inputTokens: 100,
135
+ outputTokens: 20,
136
+ totalTokens: 120,
137
+ };
138
+ const answers: Record<string, unknown> = {};
139
+ for (const [name, answer] of Object.entries(options.answers ?? {})) {
140
+ answers[name] = answer;
141
+ }
142
+ return { answers, usage, modelId: `${options.id}-model` };
143
+ },
144
+ };
145
+ }
146
+
147
+ /** A key reader that resolves every configured keychain ref to a fake key. */
148
+ export function fakeKeyReader(failFor: Set<string> = new Set()): KeyReader {
149
+ return async (ref) => {
150
+ if (failFor.has(ref.service) || failFor.has(ref.account)) {
151
+ return { status: "missing", reason: "missing (test)" };
152
+ }
153
+ return { status: "resolved", key: `test-key-for-${ref.account}` };
154
+ };
155
+ }
156
+
157
+ export interface MockChainCall extends MockCall {
158
+ providers?: string[];
159
+ }
160
+
161
+ /** Build a ProviderChain replacement that records calls and returns canned outcomes. */
162
+ export function mockChain(options: {
163
+ answers?: Record<string, MockAnswer>;
164
+ usage?: TransportUsage;
165
+ delayMs?: number;
166
+ fail?: (call: MockChainCall) => Error | undefined;
167
+ calls?: MockChainCall[];
168
+ provider?: string;
169
+ attempts?: number;
170
+ /** Usage already observed from failed attempts before the successful outcome. */
171
+ failedAttemptUsage?: TransportUsage[];
172
+ }) {
173
+ return {
174
+ async evaluate(
175
+ request: TransportRequest,
176
+ signal: AbortSignal,
177
+ ): Promise<ChainEvaluateOutcome> {
178
+ const call: MockChainCall = {
179
+ state: request.state,
180
+ questions: request.questions,
181
+ abortSignal: signal,
182
+ };
183
+ options.calls?.push(call);
184
+ const failure = options.fail?.(call);
185
+ if (failure) throw failure;
186
+ if (options.delayMs !== undefined) {
187
+ await new Promise<void>((resolvePromise, rejectPromise) => {
188
+ const timer = setTimeout(resolvePromise, options.delayMs);
189
+ signal.addEventListener("abort", () => {
190
+ clearTimeout(timer);
191
+ const error = new Error("This operation was aborted");
192
+ error.name = "AbortError";
193
+ rejectPromise(error);
194
+ });
195
+ });
196
+ }
197
+ const usage: TransportUsage = options.usage ?? {
198
+ inputTokens: 100,
199
+ outputTokens: 20,
200
+ totalTokens: 120,
201
+ };
202
+ const answers: Record<string, unknown> = {};
203
+ for (const [name, answer] of Object.entries(options.answers ?? {})) {
204
+ answers[name] = answer;
205
+ }
206
+ return {
207
+ result: { answers, usage, modelId: `${options.provider ?? "typesafe-direct"}-model` },
208
+ provider: (options.provider ?? "typesafe-direct") as ChainEvaluateOutcome["provider"],
209
+ attempts: options.attempts ?? 1,
210
+ failedAttemptUsage: options.failedAttemptUsage ?? [],
211
+ };
212
+ },
213
+ async readiness(): Promise<{ ready: boolean; reason?: string; providers: { id: string; ready: boolean }[] }> {
214
+ return { ready: true, providers: [{ id: "typesafe-direct", ready: true }] };
215
+ },
216
+ };
217
+ }