llm-emulator
Version:
Enterprise-grade LLM mock server for local and CI: scenarios, faults, latency, contracts, VCR. Supports standalone server and Express middleware.
238 lines (203 loc) • 5.9 kB
JavaScript
import { log } from "./log.js";
import { sleep } from "./util.js";
import { extractVarsLoosely } from "./patterns.js";
/**
* A single ScenarioRunner instance, created per server (in middleware.js).
* It reads from config.useScenario + config.scenarios.
*
* Supports:
* - Linear scenarios: { id, steps: [ { kind, reply, delayMs? }, ... ] }
* - Graph scenarios: { id, start, steps: { stateId: { branches: [...] , final? }, ... } }
*/
export class ScenarioRunner {
constructor(config) {
this.config = config;
// One global session for now; can be extended to per-conversation later.
this.session = null; // { scenarioId, mode, index?, stateId?, done? }
}
/**
* Resolve the currently configured scenario from config.useScenario.
*/
activeScenario() {
const id = this.config.useScenario;
if (!id) return null;
return (this.config.scenarios || []).find((s) => s.id === id) || null;
}
/**
* Determine if a scenario spec is graph-style (branching) or linear.
*/
isGraphScenario(sc) {
return (
sc &&
typeof sc.start === "string" &&
sc.steps &&
!Array.isArray(sc.steps)
);
}
isLinearScenario(sc) {
return sc && Array.isArray(sc.steps);
}
/**
* Ensure we have a session for the active scenario, resetting if the id changed.
*/
ensureSessionFor(sc) {
if (!this.session || this.session.scenarioId !== sc.id) {
const mode = this.isGraphScenario(sc)
? "graph"
: this.isLinearScenario(sc)
? "linear"
: "none";
this.session = {
scenarioId: sc.id,
mode,
index: 0,
stateId: this.isGraphScenario(sc) ? sc.start : null,
done: mode === "none",
};
}
return this.session;
}
/**
* Main entry point used by middleware:
*
* const step = await scenarios.nextStep({ provider, model, text, headers, params });
*
* Returns:
* - a step object: { kind: "chat" | "tools", reply?, result? }
* - or null if no scenario / no matching branch / scenario finished
*/
async nextStep(ctx = {}) {
const sc = this.activeScenario();
if (!sc) return null;
const session = this.ensureSessionFor(sc);
if (session.done || session.mode === "none") {
return null;
}
if (session.mode === "graph") {
return this.nextGraphStep(sc, session, ctx);
}
if (session.mode === "linear") {
return this.nextLinearStep(sc, session);
}
return null;
}
/**
* Original linear behavior: walk sc.steps[index++].
* This assumes config.scenarios[i].steps is an array of step objects:
* { kind: "chat" | "tools", reply?, result?, delayMs? }
*/
async nextLinearStep(sc, session) {
const steps = sc.steps || [];
const idx = session.index ?? 0;
if (idx >= steps.length) {
session.done = true;
return null;
}
const step = steps[idx];
// Optional per-step delay (for more realistic demos)
if (step.delayMs && step.delayMs > 0) {
await sleep(step.delayMs);
}
log("scenario.step", {
scenarioId: sc.id,
index: idx,
kind: step.kind,
mode: "linear",
});
session.index = idx + 1;
if (session.index >= steps.length) {
session.done = true;
}
return {
kind: step.kind || "chat",
reply: step.reply,
result: step.result,
};
}
/**
* Graph/branching behavior:
* - sc.steps is a map: { [stateId]: { branches: [...], final? } }
* - session.stateId is the current state
* - ctx.text is the last user text (from the request)
*
* Each branch can look like:
* {
* when: "my last name is {{lastName}}",
* if: (vars, ctx) => boolean (optional),
* kind: "chat" | "tools",
* reply?: string | (args) => string,
* result?: any,
* next?: "nextStateId"
* }
*/
async nextGraphStep(sc, session, ctx) {
const steps = sc.steps || {};
const currentId = session.stateId;
if (!currentId) {
session.done = true;
return null;
}
const state = steps[currentId];
if (!state) {
session.done = true;
return null;
}
// If state is terminal and has no branches, we are done.
if (state.final && !state.branches) {
session.done = true;
return null;
}
const branches = state.branches || [];
const text = ctx.text || "";
for (let i = 0; i < branches.length; i++) {
const branch = branches[i];
const pattern = branch.when;
if (!pattern) continue;
const vars = extractVarsLoosely(text, pattern);
if (!vars) {
// pattern didn't match
continue;
}
// Optional guard
if (typeof branch.if === "function") {
const ok = branch.if(vars, ctx);
if (!ok) continue;
}
// We have a matching branch.
let reply = undefined;
let result = branch.result;
if (typeof branch.reply === "function") {
reply = await branch.reply({ text, vars, stateId: currentId, branch, ctx });
} else {
reply = branch.reply;
}
const kind = branch.kind || "chat";
log("scenario.step", {
scenarioId: sc.id,
stateId: currentId,
branchIndex: i,
kind,
mode: "graph",
});
// Optional per-branch delay
if (branch.delayMs && branch.delayMs > 0) {
await sleep(branch.delayMs);
}
// Move to next state
if (branch.next) {
session.stateId = branch.next;
const nextState = steps[branch.next];
if (nextState && nextState.final && !nextState.branches) {
session.done = true;
}
} else if (state.final) {
session.done = true;
}
return { kind, reply, result };
}
return null;
}
reset() {
this.session = null;
}
}