@skybridge/test runs a real conversation against your app, in process, and hands you the tool calls the model made so you can assert on them.
Set Up
Add the test runtime, vitest, the AI SDK, and the provider you want to drive the conversation:evals in the Vite plugin. It registers the expect.chat matchers, picks up evals/**/*.eval.ts, raises the per-scenario timeout to two minutes, and loads your .env so the provider key is available:
vite.config.ts
package.json
Write a Scenario
A scenario opens a conversation against your app, sends a prompt, and asserts on the tools the model called:evals/search.eval.ts
start serves the app in process: no port, no fixture. Each send is one turn, during which the model can call several tools before it answers. The session closes when the test finishes.
This is why the app lives in src/server.ts and run() in src/index.ts: importing the app starts nothing.
Assert on Tool Calls
expect.chat(chat) is typed against your app, so tool names autocomplete and arguments are checked against each tool’s input schema.
toHaveCalledToolsInOrder matches a subsequence, so unrelated calls in between do not break it. Like the other positive matchers it only counts calls that succeeded, so a failed call never satisfies a step.
Every matcher supports .not and sees the whole conversation, not just the last send. On failure, the message lists the calls the model actually made, arguments included. chat.toolCalls and chat.assistantTurns are also available for custom assertions.
Judge the Answer
Sometimes it’s not easy to test an agent behavior using on deterministic matchers. For example, did the assistant reply using the right tone? Was it polite?toPassJudgment sends the conversation to model, asking it to judge if the conversation meets the criteria:
evals/trip.eval.ts
model to use another one:
Judge With Something Other Than a Model
Passjudge to grade the conversation yourself. It receives the criteria and the rendered transcript, and returns a verdict. This is how you plug in an evaluation model, which answers a typed question with a probability instead of writing text, or any scoring service you already run:
Define it once in its own module, so every scenario that wants it imports the same judge:
evals/typesafe-judge.ts
evals/trip.eval.ts
Judgment and Verdict are exported from @skybridge/test, so a judge written this way is typed without repeating the shape.
reasoning is optional, and the failure message drops the line when there is none. A judge that returns no prose is cheaper and faster, but a red eval then tells you only that it failed, so it is worth returning the score.
By default a judgment is a live model call: it costs money, and it is not reproducible the way a tool-call assertion is. A custom judge costs and varies only as much as whatever it calls, and a local heuristic is free and deterministic. Either way, use it only when no other matcher fits, and keep the criteria narrow so a failure points at one thing.
When the judge throws, the assertion throws judge unavailable: <error> instead of failing. That covers a broken provider and a bug in your own callback alike, so neither reads as an app regression.
Stub a Tool Result
A scenario that depends on today’s date or on a live catalogue passes today and fails next month, with no change to your code. Passstubs to answer a tool from the scenario instead of the app:
evals/flights.eval.ts
chat.toolCalls, so every matcher works on it. Return a string or any JSON value: the runner wraps it the way a real tool result reaches the model. Naming a tool your app does not expose fails the scenario rather than passing silently. Return undefined to fall through to the real server, which is how you pin one set of arguments and leave the rest live:
Test Authenticated Tools
For an app behind OAuth, claim an identity for the session:evals/checkout.eval.ts
extra reaches your handlers as extra.http.authInfo.extra. Omit authInfo to exercise the anonymous path, auth challenges included.
setup and oauth still resolve on the first request, so an app wired to an identity provider needs its .env to run evals, the same as dev.Tune the Run
Defaults every scenario starts from go in the plugin option, and any of them can be overridden perstart:
vite.config.ts
0 when your model supports it (some recent models reject the parameter), assert on the calls rather than on exact phrasing, and reach for toHaveSaid with a loose pattern when the answer itself matters.
Go Further
Skybridge
The app your scenarios import
Playground
Chat with a real model running your app
Register Tools
Write descriptions the model can act on
Attach Input Files
chat.send accepts AI SDK UserContent: either a string or an array of text and file parts. Use the AI SDK’s FilePart directly, and keep that same object for assertions:
evals/resize.eval.ts
FileRef and list their names in _meta["openai/fileParams"]. Your handler or stub receives the attached file, and its download URL remains available until the conversation closes.
expect.inputFile(part) matches the file object minted for that exact FilePart. Two parts with identical bytes do not match each other.
Using it under a parameter not listed in openai/fileParams throws a declaration error before calls are compared, including nested matchers, .not assertions, and conversations with no calls.
File data can be bytes, base64, a URL, or AI SDK tagged data, URL or text content. Provider file references are rejected because the app under test cannot download them.