/** * `@juno-ai/bind/testing` — fixtures for testing an agent against the harness * without a provider, a network, or a credential. * * The loop's hardest behaviour to get right is also the hardest to test: what * happens across several turns, with several tools, when one of them fails or * the run is cut short. Reaching that state normally means mocking a streaming * chat-completions client, which is a lot of scaffolding to write before the * first assertion — so most consumers write it once, badly, and then only test * the happy path. * * These are the fixtures this package's own cross-module suites use, published * so a consumer does not rewrite them. They are ordinary values with no magic: * a scripted model is a queue of prepared turns, and a harness is a * {@link ToolLoopParams} you can override any field of. * * ```ts * import { loopHarness, toolCall, toolCallTurn, finalAnswer } from "@juno-ai/bind/testing"; * import { runToolLoop } from "@juno-ai/bind/loop"; * * const h = loopHarness([ * toolCallTurn([toolCall("search", { q: "bind" })]), * finalAnswer("Found it."), * ]); * const { stopReason, stats } = await runToolLoop(h.params); * expect(stopReason).toBe("done"); * expect(h.ran).toEqual(["search"]); * ``` * * This module ships in the published package rather than living beside the * tests, so it is held to the same portability fences as `src/`: no Node * builtins, no `process`, no framework, peer dependencies only. */ import type OpenAI from "openai"; import type { ToolCallOutcome, ToolLoopParams, ToolLoopState } from "../loop/tool-loop.js"; import type { TurnStreamEvent, TurnStreamSink } from "../completion/text-stream.js"; /** * A sink that records what it was handed. `retractable` is the whole decision * the text stream turns on, so it is the one required argument. */ export declare function recordingSink(retractable: boolean): TurnStreamSink & { readonly events: TurnStreamEvent[]; }; export declare function freshState(overrides?: Partial): ToolLoopState; /** An assistant message, with tool calls when given names. */ export declare function assistant(content: string | null, toolCalls?: ReadonlyArray<{ id: string; name: string; args?: string; }>): OpenAI.ChatCompletionMessage; /** * A successful tool result, encoded exactly as production encodes one. * * Routed through `toolResultMessage` rather than a bare `JSON.stringify` so a * fixture-built transcript has the same shape a real run produces. A fixture * that invents its own envelope reintroduces the "two formats in one * transcript" problem that encoder exists to remove, and any test asserting on * transcript shape would be pinning something production never emits. * (`tool-message` is type-only internally, so this pulls no zod into * `@juno-ai/bind/testing`.) */ export declare function toolOutcome(id: string, data?: unknown): ToolCallOutcome; /** One tool call in a scripted turn. */ export interface ScriptedToolCall { /** Defaults to the tool name — unique across the whole script, see * {@link scriptedModel}, which rejects a duplicate rather than letting it * produce a baffling transcript failure ten frames deep in the loop. */ readonly id: string; readonly name: string; readonly args: string; } /** * Declare one tool call. `args` is serialized for you; pass a string to * script malformed JSON on purpose (which is a case worth testing — models * emit it). */ export declare function toolCall(name: string, args?: unknown, id?: string): ScriptedToolCall; /** Per-turn accounting overrides. Defaults are small non-zero numbers so a * test asserting "usage was recorded" cannot pass on an all-zero fixture. */ export interface TurnCost { readonly inputTokens?: number; readonly outputTokens?: number; readonly costCents?: number; /** * Provider-reported cached input tokens. Omit it to script a transport that * cannot report one — the run's total then skips this turn rather than * counting a zero, which is the distinction `RunStats.cachedInputTokens` * turns on. Pass `null` for the same effect explicitly. */ readonly cachedInputTokens?: number | null; } /** One scripted model turn: the message to return, plus its usage. */ export interface ModelResponse extends TurnCost { readonly message: OpenAI.ChatCompletionMessage; } /** A turn where the model asks for tools, optionally alongside some text. */ export declare function toolCallTurn(calls: readonly ScriptedToolCall[], opts?: TurnCost & { readonly content?: string; }): ModelResponse; /** * A turn with text and no tool calls — which is how the loop *ends*. A script * that omits it runs to `maxIterations` (or exhausts the queue), so this is * the difference between testing `stopReason: "done"` and testing * `"iteration_limit"`. */ export declare function finalAnswer(content: string, opts?: TurnCost): ModelResponse; /** * Turn a script into a `callModel` implementation. * * Exhausting the queue throws rather than looping forever or returning an * empty turn: a script that ran out is a test that did not describe what it * meant to, and the loop's own `maxIterations` cutoff would otherwise absorb * the mistake and report a plausible-looking `iteration_limit`. */ export declare function scriptedModel(turns: readonly ModelResponse[]): NonNullable; export interface LoopHarness { params: ToolLoopParams; state: ToolLoopState; /** Ids the loop dispatched, in order. Recorded for you even if you override * `runToolCall`. */ ran: string[]; /** Ids whose tool actually reached its side effect. The default * `runToolCall` records one here on completion, so `ran` and `sideEffects` * match until an override makes them diverge — a tool that throws, hangs * past a deadline, or is torn down mid-flight. That divergence is the * question every cancellation test is really asking. */ sideEffects: string[]; } /** * A loop wired to a scripted model queue. `runToolCall` records dispatch and * completion separately, so a test can tell "the loop asked for this call" from * "this call's side effect happened" — the distinction every deadline and * cancellation question turns on. * * Everything is overridable: pass `{ runToolCall }` to make a tool fail, * `{ signal }` to abort mid-batch, `{ now }` to make timings deterministic. */ export declare function loopHarness(responses: readonly ModelResponse[], overrides?: Partial): LoopHarness; /** * A clock that advances a fixed amount on every read. Makes the model-time and * tool-time figures in `ToolLoopResult.stats` exactly predictable, which * `Date.now` cannot be. */ export declare function steppingClock(stepMs?: number, startMs?: number): () => number; /** * Resolve after `ms` of real time. Kept tiny so suites stay fast. * * Deliberately not cancellable — it is for driving the loop's own micro-timers * inside a test, not for long-lived waiting. Reach for your own scheduler if * you need a wait that outlives the run, so a torn-down run cannot leave a * timer resolving into nothing. */ export declare function sleep(ms: number): Promise;