@nanobpm/urban 0.7.0 → 0.9.0

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,237 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { LlmBinding } from "../manifest.ts";
4
+ import {
5
+ buildMessages,
6
+ callLlm,
7
+ type ChatMessage,
8
+ type EnvLookup,
9
+ resolveProvider,
10
+ runLlmJob,
11
+ } from "./llm.ts";
12
+
13
+ const noEnv: EnvLookup = () => undefined;
14
+ const envOf =
15
+ (m: Record<string, string>): EnvLookup =>
16
+ (k) =>
17
+ m[k];
18
+
19
+ /** A minimal fetch double returning a fixed chat-completion. */
20
+ function fakeFetch(
21
+ content: string,
22
+ opts: { ok?: boolean; status?: number; capture?: (url: string, init: RequestInit) => void } = {},
23
+ ): typeof fetch {
24
+ return (async (url: string, init: RequestInit) => {
25
+ opts.capture?.(String(url), init);
26
+ return {
27
+ ok: opts.ok ?? true,
28
+ status: opts.status ?? 200,
29
+ json: async () => ({ choices: [{ message: { content } }] }),
30
+ text: async () => content,
31
+ };
32
+ }) as unknown as typeof fetch;
33
+ }
34
+
35
+ // --- resolveProvider -------------------------------------------------------
36
+
37
+ test("resolveProvider defaults to the local Ollama endpoint and env-templates the model", () => {
38
+ const binding: LlmBinding = { provider: "env", model: "${NANO_APP_LLM_MODEL}" };
39
+ const p = resolveProvider(binding, envOf({ NANO_APP_LLM_MODEL: "llama3" }));
40
+ assert.equal(p.baseUrl, "http://localhost:11434/v1");
41
+ assert.equal(p.model, "llama3");
42
+ assert.equal(p.apiKey, undefined);
43
+ });
44
+
45
+ test("resolveProvider honours NANO_APP_LLM_BASE_URL (trailing slash trimmed) + api key", () => {
46
+ const p = resolveProvider(
47
+ { provider: "env", model: "gpt-4o-mini" },
48
+ envOf({ NANO_APP_LLM_BASE_URL: "https://api.example.com/v1/", NANO_APP_LLM_API_KEY: "sk-x" }),
49
+ );
50
+ assert.equal(p.baseUrl, "https://api.example.com/v1");
51
+ assert.equal(p.model, "gpt-4o-mini");
52
+ assert.equal(p.apiKey, "sk-x");
53
+ });
54
+
55
+ test("resolveProvider falls back to NANO_APP_LLM_MODEL when the binding model is empty", () => {
56
+ const p = resolveProvider({ provider: "env", model: "" }, envOf({ NANO_APP_LLM_MODEL: "phi3" }));
57
+ assert.equal(p.model, "phi3");
58
+ });
59
+
60
+ test("resolveProvider throws when no model resolves", () => {
61
+ assert.throws(() => resolveProvider({ provider: "env", model: "" }, noEnv), /no model resolved/);
62
+ });
63
+
64
+ test("resolveProvider throws on an unsupported provider", () => {
65
+ assert.throws(
66
+ () => resolveProvider({ provider: "openai", model: "gpt" }, noEnv),
67
+ /unsupported provider "openai"/,
68
+ );
69
+ });
70
+
71
+ // --- buildMessages ---------------------------------------------------------
72
+
73
+ test("buildMessages wraps a prompt string as a single user message", () => {
74
+ assert.deepEqual(buildMessages({ prompt: "hi" }), [{ role: "user", content: "hi" }]);
75
+ });
76
+
77
+ test("buildMessages prepends an optional system message", () => {
78
+ assert.deepEqual(buildMessages({ prompt: "hi", system: "be terse" }), [
79
+ { role: "system", content: "be terse" },
80
+ { role: "user", content: "hi" },
81
+ ]);
82
+ });
83
+
84
+ test("buildMessages uses a messages array verbatim", () => {
85
+ const messages: ChatMessage[] = [
86
+ { role: "user", content: "a" },
87
+ { role: "assistant", content: "b" },
88
+ ];
89
+ assert.deepEqual(buildMessages({ messages }), messages);
90
+ });
91
+
92
+ test("buildMessages rejects a malformed messages entry", () => {
93
+ assert.throws(() => buildMessages({ messages: [{ role: "user" }] }), /messages\[0\]/);
94
+ assert.throws(() => buildMessages({ messages: [{ role: "bogus", content: "x" }] }), /messages\[0\]/);
95
+ });
96
+
97
+ test("buildMessages requires a prompt or messages", () => {
98
+ assert.throws(() => buildMessages({}), /needs a 'prompt' string or a 'messages' array/);
99
+ });
100
+
101
+ test("buildMessages rejects an empty messages array", () => {
102
+ assert.throws(() => buildMessages({ messages: [] }), /must not be an empty array/);
103
+ });
104
+
105
+ // --- callLlm ---------------------------------------------------------------
106
+
107
+ test("callLlm posts to /chat/completions and returns the message content", async () => {
108
+ let seen: { url: string; init: RequestInit } | undefined;
109
+ const f = fakeFetch("hello world", { capture: (url, init) => (seen = { url, init }) });
110
+ const out = await callLlm({ baseUrl: "http://h/v1", model: "m", apiKey: "k" }, [
111
+ { role: "user", content: "hi" },
112
+ ], { fetch: f });
113
+ assert.equal(out, "hello world");
114
+ assert.equal(seen?.url, "http://h/v1/chat/completions");
115
+ assert.equal(seen?.init.method, "POST");
116
+ const headers = seen?.init.headers as Record<string, string>;
117
+ assert.equal(headers.authorization, "Bearer k");
118
+ const body = JSON.parse(String(seen?.init.body));
119
+ assert.equal(body.model, "m");
120
+ assert.equal(body.response_format, undefined);
121
+ });
122
+
123
+ test("callLlm requests a JSON object response when json:true", async () => {
124
+ let body: Record<string, unknown> = {};
125
+ const f = fakeFetch("{}", { capture: (_u, init) => (body = JSON.parse(String(init.body))) });
126
+ await callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], {
127
+ json: true,
128
+ fetch: f,
129
+ });
130
+ assert.deepEqual(body.response_format, { type: "json_object" });
131
+ });
132
+
133
+ test("callLlm throws on a non-ok provider response", async () => {
134
+ const f = fakeFetch("boom", { ok: false, status: 500 });
135
+ await assert.rejects(
136
+ callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], { fetch: f }),
137
+ /llm provider returned 500/,
138
+ );
139
+ });
140
+
141
+ test("callLlm throws when the provider returns no content", async () => {
142
+ const f = (async () => ({ ok: true, status: 200, json: async () => ({ choices: [] }) })) as unknown as typeof fetch;
143
+ await assert.rejects(
144
+ callLlm({ baseUrl: "http://h/v1", model: "m" }, [{ role: "user", content: "hi" }], { fetch: f }),
145
+ /no message content/,
146
+ );
147
+ });
148
+
149
+ // --- runLlmJob -------------------------------------------------------------
150
+
151
+ const model = envOf({ NANO_APP_LLM_MODEL: "m" });
152
+
153
+ test("runLlmJob returns raw text when the binding has no output schema", async () => {
154
+ const out = await runLlmJob({ prompt: "hi" }, { provider: "env", model: "" }, {
155
+ env: model,
156
+ fetch: fakeFetch("plain answer"),
157
+ });
158
+ assert.deepEqual(out, { text: "plain answer" });
159
+ });
160
+
161
+ test("runLlmJob parses JSON output when the binding sets output", async () => {
162
+ const out = await runLlmJob(
163
+ { prompt: "classify" },
164
+ { provider: "env", model: "", output: {} },
165
+ { env: model, fetch: fakeFetch('{"decision":"risk","score":0.9}') },
166
+ );
167
+ assert.deepEqual(out, { decision: "risk", score: 0.9 });
168
+ });
169
+
170
+ test("runLlmJob throws when JSON output is expected but not returned", async () => {
171
+ await assert.rejects(
172
+ runLlmJob({ prompt: "x" }, { provider: "env", model: "", output: {} }, {
173
+ env: model,
174
+ fetch: fakeFetch("not json"),
175
+ }),
176
+ /expected JSON output/,
177
+ );
178
+ });
179
+
180
+ test("runLlmJob rejects a non-object JSON output (scalar/array/null)", async () => {
181
+ for (const bad of ["42", "[1,2]", "null", '"just a string"']) {
182
+ await assert.rejects(
183
+ runLlmJob({ prompt: "x" }, { provider: "env", model: "", output: {} }, {
184
+ env: model,
185
+ fetch: fakeFetch(bad),
186
+ }),
187
+ /expected a JSON object output/,
188
+ `should reject ${bad}`,
189
+ );
190
+ }
191
+ });
192
+
193
+ test("runLlmJob feeds parsed JSON through the decision rails and returns its output", async () => {
194
+ const calls: Array<{ id: string; vars: Record<string, unknown> }> = [];
195
+ const out = await runLlmJob(
196
+ { prompt: "x" },
197
+ { provider: "env", model: "", output: { decision: "risk" } },
198
+ {
199
+ env: model,
200
+ fetch: fakeFetch('{"amount":100}'),
201
+ evaluateDecision: async (id, vars) => {
202
+ calls.push({ id, vars });
203
+ return { approved: true };
204
+ },
205
+ },
206
+ );
207
+ assert.deepEqual(out, { approved: true });
208
+ assert.deepEqual(calls, [{ id: "risk", vars: { amount: 100 } }]);
209
+ });
210
+
211
+ test("runLlmJob wraps a scalar decision output as { result }", async () => {
212
+ const out = await runLlmJob(
213
+ { prompt: "x" },
214
+ { provider: "env", model: "", output: { decision: "risk" } },
215
+ { env: model, fetch: fakeFetch("{}"), evaluateDecision: async () => "APPROVE" },
216
+ );
217
+ assert.deepEqual(out, { result: "APPROVE" });
218
+ });
219
+
220
+ test("runLlmJob wraps an array decision output as { result } (not a var map)", async () => {
221
+ const out = await runLlmJob(
222
+ { prompt: "x" },
223
+ { provider: "env", model: "", output: { decision: "risk" } },
224
+ { env: model, fetch: fakeFetch("{}"), evaluateDecision: async () => [1, 2] },
225
+ );
226
+ assert.deepEqual(out, { result: [1, 2] });
227
+ });
228
+
229
+ test("runLlmJob throws when decision rails are declared but no evaluator is available", async () => {
230
+ await assert.rejects(
231
+ runLlmJob({ prompt: "x" }, { provider: "env", model: "", output: { decision: "risk" } }, {
232
+ env: model,
233
+ fetch: fakeFetch("{}"),
234
+ }),
235
+ /no decision evaluator is available/,
236
+ );
237
+ });
@@ -0,0 +1,209 @@
1
+ // LLM-as-worker runtime (ADR 0022 §E role 1) — the batteries-included implementation of
2
+ // the manifest's `llm` seam. For every `workers[]` entry that carries an `llm` binding
3
+ // (instead of a `handler` file), `workers.ts` registers a job worker on that task type
4
+ // whose handler is an LLM call — no external connector runtime required.
5
+ //
6
+ // A worker's contract:
7
+ // { "taskType": "classify", "llm": "classifier" } // workers[]
8
+ // "llm": { "classifier": { "provider": "env", // llm registry
9
+ // "model": "${NANO_APP_LLM_MODEL}",
10
+ // "output": { "decision": "risk" } } }
11
+ //
12
+ // The job's input variables drive the prompt (`prompt` string and/or `messages` array,
13
+ // optional `system`); the model's reply becomes the job's output variables. When the
14
+ // binding declares `output.decision`, the model's JSON is fed through that DMN decision
15
+ // (the rails) and the decision's output is returned instead — the LLM proposes, the
16
+ // decision disposes.
17
+ //
18
+ // Provider is configuration, not code: `provider: "env"` resolves an OpenAI-compatible
19
+ // endpoint from the environment, so a fully-local model (Ollama, LM Studio, llama.cpp,
20
+ // vLLM, …) or a hosted API both work unchanged. This module is host-agnostic: env and
21
+ // fetch are injected (env always via the host seam), never read from a concrete runtime.
22
+
23
+ import type { LlmBinding } from "../manifest.ts";
24
+ import { expandEnvString } from "../manifest.ts";
25
+
26
+ /** Job variables/headers default to untyped JSON. */
27
+ export type LlmVars = Record<string, unknown>;
28
+
29
+ /** Reads an environment variable (routed through the host seam). */
30
+ export type EnvLookup = (name: string) => string | undefined;
31
+
32
+ /** One chat message on the wire. */
33
+ export interface ChatMessage {
34
+ role: "system" | "user" | "assistant";
35
+ content: string;
36
+ }
37
+
38
+ /** A resolved provider endpoint (OpenAI-compatible chat completions). */
39
+ export interface ProviderConfig {
40
+ baseUrl: string;
41
+ apiKey?: string;
42
+ model: string;
43
+ }
44
+
45
+ /**
46
+ * Evaluate a DMN decision (the output "rails"), returning its already-parsed output.
47
+ * Injected by the caller so this module stays free of any engine/SDK coupling.
48
+ */
49
+ export type DecisionEvaluator = (
50
+ decisionId: string,
51
+ variables: Record<string, unknown>,
52
+ ) => Promise<unknown>;
53
+
54
+ /** Everything running one LLM job needs, injected for host-agnosticism + testability. */
55
+ export interface LlmRuntime {
56
+ env: EnvLookup;
57
+ /** Fetch implementation. Defaults to the global `fetch`. */
58
+ fetch?: typeof fetch;
59
+ /** Decision-rails evaluator; required only when a binding sets `output.decision`. */
60
+ evaluateDecision?: DecisionEvaluator;
61
+ }
62
+
63
+ /**
64
+ * Resolve an OpenAI-compatible endpoint for a binding. `provider: "env"` (the only
65
+ * built-in today) reads:
66
+ * - NANO_APP_LLM_BASE_URL (default http://localhost:11434/v1 — Ollama's
67
+ * OpenAI-compatible endpoint; override for any host)
68
+ * - NANO_APP_LLM_API_KEY (optional; sent as `Authorization: Bearer` if set)
69
+ * - NANO_APP_LLM_MODEL (fallback when the binding's `model` resolves empty)
70
+ * The binding's `model` is env-templated (`${VAR}` / `${VAR:-default}`).
71
+ */
72
+ export function resolveProvider(binding: LlmBinding, env: EnvLookup): ProviderConfig {
73
+ if (binding.provider !== "env") {
74
+ throw new Error(
75
+ `llm worker: unsupported provider "${binding.provider}" (only "env" is built in — ` +
76
+ `it resolves an OpenAI-compatible endpoint from NANO_APP_LLM_* env)`,
77
+ );
78
+ }
79
+ const baseUrl = (env("NANO_APP_LLM_BASE_URL") ?? "http://localhost:11434/v1").replace(/\/+$/, "");
80
+ const apiKey = env("NANO_APP_LLM_API_KEY") || undefined;
81
+ const model = expandEnvString(binding.model ?? "", env) || env("NANO_APP_LLM_MODEL") || "";
82
+ if (!model) {
83
+ throw new Error(
84
+ `llm worker: no model resolved for binding (model="${binding.model}"); ` +
85
+ `set NANO_APP_LLM_MODEL or a literal model`,
86
+ );
87
+ }
88
+ return { baseUrl, apiKey, model };
89
+ }
90
+
91
+ /**
92
+ * Build the chat messages for a job's variables. Input contract:
93
+ * - `messages`: a ready `{role,content}[]` (used verbatim), or
94
+ * - `prompt`: a user-message string.
95
+ * An optional `system` string is prepended.
96
+ */
97
+ export function buildMessages(vars: LlmVars): ChatMessage[] {
98
+ const system = typeof vars.system === "string" ? vars.system : undefined;
99
+
100
+ let msgs: ChatMessage[];
101
+ if (Array.isArray(vars.messages)) {
102
+ if (vars.messages.length === 0) {
103
+ throw new Error("llm worker: 'messages' must not be an empty array");
104
+ }
105
+ msgs = (vars.messages as unknown[]).map((m, i) => {
106
+ const o = m as { role?: unknown; content?: unknown };
107
+ if (
108
+ typeof o.content !== "string" ||
109
+ (o.role !== "user" && o.role !== "assistant" && o.role !== "system")
110
+ ) {
111
+ throw new Error(
112
+ `llm worker: messages[${i}] must be { role: user|assistant|system, content: string }`,
113
+ );
114
+ }
115
+ return { role: o.role, content: o.content };
116
+ });
117
+ } else if (typeof vars.prompt === "string") {
118
+ msgs = [{ role: "user", content: vars.prompt }];
119
+ } else {
120
+ throw new Error(
121
+ "llm worker: job needs a 'prompt' string or a 'messages' array in its variables",
122
+ );
123
+ }
124
+
125
+ return system ? [{ role: "system", content: system }, ...msgs] : msgs;
126
+ }
127
+
128
+ /**
129
+ * Call the model's chat-completions endpoint. When `json` is set, requests a JSON object
130
+ * response (`response_format`) so structured output can be parsed. Returns the assistant
131
+ * message text.
132
+ */
133
+ export async function callLlm(
134
+ provider: ProviderConfig,
135
+ messages: ChatMessage[],
136
+ opts: { json?: boolean; fetch?: typeof fetch } = {},
137
+ ): Promise<string> {
138
+ const doFetch = opts.fetch ?? fetch;
139
+ const headers: Record<string, string> = { "content-type": "application/json" };
140
+ if (provider.apiKey) headers.authorization = `Bearer ${provider.apiKey}`;
141
+ const body: Record<string, unknown> = { model: provider.model, messages };
142
+ if (opts.json) body.response_format = { type: "json_object" };
143
+
144
+ const res = await doFetch(`${provider.baseUrl}/chat/completions`, {
145
+ method: "POST",
146
+ headers,
147
+ body: JSON.stringify(body),
148
+ });
149
+ if (!res.ok) {
150
+ const detail = await res.text().catch(() => "");
151
+ throw new Error(`llm provider returned ${res.status}: ${detail.slice(0, 500)}`);
152
+ }
153
+ const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
154
+ const content = data.choices?.[0]?.message?.content;
155
+ if (typeof content !== "string") {
156
+ throw new Error("llm provider returned no message content");
157
+ }
158
+ return content;
159
+ }
160
+
161
+ /**
162
+ * Run one job through the LLM (and optional decision rails). Returns the output variables
163
+ * the job is completed with.
164
+ *
165
+ * - No `output` binding → returns `{ text }` (the raw completion).
166
+ * - `output` set → requests JSON, parses it; the parsed object is the output.
167
+ * - `output.decision` set → the parsed JSON is fed to that DMN decision and the decision's
168
+ * output is returned (object as-is, scalar wrapped as `{ result }`).
169
+ */
170
+ export async function runLlmJob(
171
+ vars: LlmVars,
172
+ binding: LlmBinding,
173
+ rt: LlmRuntime,
174
+ ): Promise<Record<string, unknown>> {
175
+ const wantJson = !!binding.output;
176
+ const provider = resolveProvider(binding, rt.env);
177
+ const messages = buildMessages(vars);
178
+ const text = await callLlm(provider, messages, { json: wantJson, fetch: rt.fetch });
179
+
180
+ if (!wantJson) return { text };
181
+
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(text);
185
+ } catch {
186
+ throw new Error(`llm worker: expected JSON output but got: ${text.slice(0, 300)}`);
187
+ }
188
+ // response_format asks for a JSON object, but a provider can still return a scalar, array,
189
+ // or null — none of which is a valid variable map (nor valid decision-rails input).
190
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
191
+ throw new Error(`llm worker: expected a JSON object output but got: ${text.slice(0, 300)}`);
192
+ }
193
+ const obj = parsed as Record<string, unknown>;
194
+
195
+ const decisionId = binding.output?.decision;
196
+ if (decisionId) {
197
+ if (!rt.evaluateDecision) {
198
+ throw new Error(
199
+ `llm worker: binding declares output.decision "${decisionId}" but no decision ` +
200
+ `evaluator is available (the engine SDK surface is required for decision rails)`,
201
+ );
202
+ }
203
+ const out = await rt.evaluateDecision(decisionId, obj);
204
+ return out !== null && typeof out === "object" && !Array.isArray(out)
205
+ ? (out as Record<string, unknown>)
206
+ : { result: out };
207
+ }
208
+ return obj;
209
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { AppApi, RuntimeContext } from "../context.ts";
7
7
  import { html, json, normalizeRoutePath, type Route } from "../router.ts";
8
+ import { mountActions } from "./actions.ts";
8
9
  import { mountPages } from "./pages.ts";
9
10
 
10
11
  /** Escape HTML-significant characters before embedding a value in markup. */
@@ -99,6 +100,15 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
99
100
  });
100
101
  }
101
102
 
103
+ // App-authored action overrides mount BEFORE the generic pages routes so an exact
104
+ // override (e.g. /app/actions/cancel) shadows the generic action; the router is
105
+ // first-match-wins.
106
+ const actions = mountActions(ctx, app);
107
+ if (actions.routes.length > 0) {
108
+ routes.push(...actions.routes);
109
+ enabled.push(`actions(${actions.routes.length})`);
110
+ }
111
+
102
112
  // The pages surface (the schema-driven page runtime) mounts its own routes.
103
113
  const pages = mountPages(ctx, app);
104
114
  if (pages.routes.length > 0) {
@@ -0,0 +1,158 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { AppApi, RuntimeContext } from "../context.ts";
4
+ import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
5
+ import type { AppManifest } from "../manifest.ts";
6
+ import { mountWorkers, sdkDecisionEvaluator } from "./workers.ts";
7
+
8
+ /** A tiny engine that records registrations and can deliver a job to a handler. */
9
+ class MiniEngine {
10
+ workers = new Map<string, JobHandler>();
11
+ async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
12
+ this.workers.set(jobType, handler);
13
+ return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
14
+ }
15
+ deliver(jobType: string, job: EngineJob) {
16
+ const h = this.workers.get(jobType);
17
+ if (!h) throw new Error(`no worker for ${jobType}`);
18
+ return h(job);
19
+ }
20
+ }
21
+
22
+ function makeCtx(
23
+ manifest: Partial<AppManifest>,
24
+ engine: MiniEngine,
25
+ ): { ctx: RuntimeContext; logs: Array<{ level: string; msg: string }> } {
26
+ const logs: Array<{ level: string; msg: string }> = [];
27
+ const ctx = {
28
+ root: "/app",
29
+ manifest: { schemaVersion: 1, id: "t", name: "T", ...manifest } as AppManifest,
30
+ engine: engine as unknown as RuntimeContext["engine"],
31
+ host: {
32
+ log: (level: string, msg: string) => logs.push({ level, msg }),
33
+ importModule: () => Promise.reject(new Error("no modules in this test")),
34
+ },
35
+ } as unknown as RuntimeContext;
36
+ return { ctx, logs };
37
+ }
38
+
39
+ function makeApp(over: Partial<AppApi> = {}): AppApi {
40
+ return {
41
+ env: (n) => ({ NANO_APP_LLM_MODEL: "m" } as Record<string, string>)[n],
42
+ log: () => {},
43
+ ...over,
44
+ } as AppApi;
45
+ }
46
+
47
+ function fakeFetch(content: string): typeof fetch {
48
+ return (async () => ({
49
+ ok: true,
50
+ status: 200,
51
+ json: async () => ({ choices: [{ message: { content } }] }),
52
+ text: async () => content,
53
+ })) as unknown as typeof fetch;
54
+ }
55
+
56
+ const orig = globalThis.fetch;
57
+ function withFetch(content: string, fn: () => Promise<void>): Promise<void> {
58
+ globalThis.fetch = fakeFetch(content);
59
+ return fn().finally(() => {
60
+ globalThis.fetch = orig;
61
+ });
62
+ }
63
+
64
+ test("mountWorkers registers an llm-bound worker and runs the job through the model", async () => {
65
+ const engine = new MiniEngine();
66
+ const { ctx, logs } = makeCtx(
67
+ {
68
+ workers: [{ taskType: "classify", llm: "classifier" }],
69
+ llm: { classifier: { provider: "env", model: "" } },
70
+ },
71
+ engine,
72
+ );
73
+ const handle = await mountWorkers(ctx, makeApp());
74
+ assert.deepEqual(handle.jobTypes, ["classify"]);
75
+ assert.ok(logs.some((l) => l.msg === "llm worker registered"));
76
+
77
+ await withFetch("hi there", async () => {
78
+ const out = await engine.deliver("classify", {
79
+ jobKey: "j1",
80
+ jobType: "classify",
81
+ variables: { prompt: "who are you" },
82
+ });
83
+ assert.deepEqual(out, { text: "hi there" });
84
+ });
85
+ await handle.stop();
86
+ });
87
+
88
+ test("mountWorkers throws when an llm worker references an unknown binding", async () => {
89
+ const engine = new MiniEngine();
90
+ const { ctx } = makeCtx(
91
+ { workers: [{ taskType: "classify", llm: "missing" }], llm: {} },
92
+ engine,
93
+ );
94
+ await assert.rejects(mountWorkers(ctx, makeApp()), /unknown llm binding "missing"/);
95
+ });
96
+
97
+ test("mountWorkers skips (with a warning) a worker with neither handler nor llm", async () => {
98
+ const engine = new MiniEngine();
99
+ const { ctx, logs } = makeCtx({ workers: [{ taskType: "orphan" }] }, engine);
100
+ const handle = await mountWorkers(ctx, makeApp());
101
+ assert.deepEqual(handle.jobTypes, []);
102
+ assert.ok(logs.some((l) => l.level === "warn" && /neither a handler nor an llm/.test(l.msg)));
103
+ });
104
+
105
+ test("an llm worker with decision rails uses app.sdk to evaluate the decision", async () => {
106
+ const engine = new MiniEngine();
107
+ const evaluated: Array<{ id: string; vars: Record<string, unknown> }> = [];
108
+ const sdk = {
109
+ evaluateDecision: async (input: { decisionDefinitionId: string; variables: Record<string, unknown> }) => {
110
+ evaluated.push({ id: input.decisionDefinitionId, vars: input.variables });
111
+ return { output: JSON.stringify({ approved: true }) };
112
+ },
113
+ };
114
+ const { ctx } = makeCtx(
115
+ {
116
+ workers: [{ taskType: "risk", llm: "risker" }],
117
+ llm: { risker: { provider: "env", model: "", output: { decision: "risk-table" } } },
118
+ },
119
+ engine,
120
+ );
121
+ await mountWorkers(ctx, makeApp({ sdk: sdk as unknown as AppApi["sdk"] }));
122
+
123
+ await withFetch('{"amount":100}', async () => {
124
+ const out = await engine.deliver("risk", {
125
+ jobKey: "j1",
126
+ jobType: "risk",
127
+ variables: { prompt: "score it" },
128
+ });
129
+ assert.deepEqual(out, { approved: true });
130
+ });
131
+ assert.deepEqual(evaluated, [{ id: "risk-table", vars: { amount: 100 } }]);
132
+ });
133
+
134
+ test("sdkDecisionEvaluator parses the decision's JSON-string output", async () => {
135
+ const evaluate = sdkDecisionEvaluator({
136
+ evaluateDecision: async () => ({ output: JSON.stringify({ ok: 1 }) }),
137
+ });
138
+ assert.deepEqual(await evaluate("d", {}), { ok: 1 });
139
+ });
140
+
141
+ test("sdkDecisionEvaluator passes through a non-string output", async () => {
142
+ const evaluate = sdkDecisionEvaluator({
143
+ evaluateDecision: async () => ({ output: { ok: 2 } }),
144
+ });
145
+ assert.deepEqual(await evaluate("d", {}), { ok: 2 });
146
+ });
147
+
148
+ test("sdkDecisionEvaluator throws with decision context on unparseable output", async () => {
149
+ const evaluate = sdkDecisionEvaluator({
150
+ evaluateDecision: async () => ({ output: "not json" }),
151
+ });
152
+ await assert.rejects(evaluate("risk", {}), /decision "risk" returned unparseable JSON/);
153
+ });
154
+
155
+ test("sdkDecisionEvaluator throws a clear error when the SDK lacks evaluateDecision", async () => {
156
+ const evaluate = sdkDecisionEvaluator({});
157
+ await assert.rejects(evaluate("risk", {}), /does not support evaluateDecision.*"risk"/);
158
+ });