Siftline
Packages

@siftline/core/testing

Three clients that judge without the network.

The Judge takes any SystemOneClient. This entry ships three that never reach the network, so a test of code that judges is fast, free and deterministic: createScriptedClient, createReplayClient and createRecordingClient.

Scripted

createScriptedClient(script) answers in call order from a list of steps, each a { response } or an { error } to throw. It exposes the calls it received and throws when the script runs out. The docs' own examples use it:

scripted.ts
import { createJudge } from "@siftline/core";
import { createScriptedClient } from "@siftline/core/testing";

import { recipe } from "./recipe";

// A test double: one recorded answer per call, in call order, never the network.
export const client = createScriptedClient([
  {
    response: {
      model: "jev-1.13.0",
      answers: {
        category: {
          type: "choice",
          choice: "complaint",
          confidence: 0.97,
          probabilities: { complaint: 0.97, question: 0.02, other: 0.01 },
        },
        wants_human: { type: "noul", noul: 0.99 },
        urgency: {
          type: "score",
          score: 1.9,
          confidence: 0.9,
          probabilities: { 0: 0.01, 1: 0.09, 2: 0.9 },
        },
      },
      usage: { input_tokens: 412, output_tokens: 60 },
    },
  },
]);

export async function judgeScripted() {
  // `now` and `id` pin the Decision's clock and id, so the output is byte-stable.
  const judge = createJudge({ client, now: () => new Date("2026-09-22T09:00:00Z") });

  return judge({ id: "msg-1", state: "Refund me now and get me a human." }, recipe, {
    id: "0192f3c2-7b1e-7c4a-9f0e-000000000001",
  });
}

With now and id pinned, the Decision that produces is the same bytes every run:

the Decision
{"format":1,"id":"0192f3c2-7b1e-7c4a-9f0e-000000000001","recordId":"msg-1","recipe":{"name":"support-inbox","version":2},"model":"jev-1.13.0","judgedAt":"2026-09-22T09:00:00.000Z","trimmed":false,"answers":{"category":"complaint","wants_human":true,"urgency":2},"questions":{"category":{"confidence":0.97,"probabilities":{"complaint":0.97,"question":0.02,"other":0.01}},"wants_human":{"probability":0.99,"confidence":0.98},"urgency":{"score":1.9,"confidence":0.9,"probabilities":{"0":0.01,"1":0.09,"2":0.9}}},"confidence":0.9,"review":false,"rule":null,"action":null,"usage":{"inputTokens":412,"outputTokens":60}}

Replay

createReplayClient(lines) matches each request deep-equal against recorded lines and returns the recorded answer, or throws the recorded error as an Error carrying its name, status, retryAfterMs and body. A request with no match throws. The siftline test output in the guide comes from a replay of real jev-1.13.0 answers.

Recording

createRecordingClient(inner, sink, options?) wraps a real client and writes one replay line per call to sink. options.id and options.now pin the line id and clock so a recording is byte-stable. replayLineSchema and parseReplayLines are the lines' JSONL door.

Record once against the real API, commit the lines, and replay them in every test after that.

On this page