okengine 0.23.2 → 0.24.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.
Files changed (75) hide show
  1. package/AGENTS.md +5 -1
  2. package/manifest.v1.schema.json +53 -3
  3. package/package.json +2 -1
  4. package/site/content/docs/ai/mcp.mdx +1 -1
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/decide.mdx +95 -417
  7. package/site/content/docs/elements/ai/deciders.mdx +154 -0
  8. package/site/content/docs/elements/ai/index.mdx +2 -1
  9. package/site/content/docs/elements/ai/meta.json +1 -1
  10. package/site/content/docs/reference/cli.mdx +2 -2
  11. package/site/content/docs/reference/configuration.mdx +2 -1
  12. package/site/content/docs/reference/fx.mdx +11 -9
  13. package/src/cli/agents-md.test.ts +8 -2
  14. package/src/cli/decide-certify.test.ts +166 -0
  15. package/src/cli/decide.ts +170 -3
  16. package/src/cli/decision-lock-watch.test.ts +4 -4
  17. package/src/cli/eval.ts +38 -59
  18. package/src/cli/mcp-from-console.ts +1 -1
  19. package/src/cli/registry.ts +45 -9
  20. package/src/compiler/decisions.extract.test.ts +86 -31
  21. package/src/compiler/effects-infer.ts +1 -0
  22. package/src/compiler/extract.ts +239 -24
  23. package/src/config/index.ts +6 -0
  24. package/src/console/server/decisions.test.ts +6 -1
  25. package/src/console/server/decisions.ts +32 -1
  26. package/src/console/ui-next/dist/assets/{access-page-zBMhZnFm.js → access-page-DX2dKLmq.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{agent-disclosure-CBj9f7Ju.js → agent-disclosure-CiRvA3M9.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{cache-glyph-D3MY49h3.js → cache-glyph-CC1yFJ8A.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{call-pii-button-L9JxdBhp.js → call-pii-button-CLXQgIDD.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{collapsible-BvFaX-Zt.js → collapsible-C0AThBZg.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{decisions-page-DgB76m-X.js → decisions-page-Bf3AhTAa.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{duration-tone-l1DSJ2Vz.js → duration-tone-Bptur2gX.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{flows-page-Bb9t1lu6.js → flows-page-DRZVeTZU.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{highlighted-json-DMvsi3Ti.js → highlighted-json-BKf0PjBK.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{http-method-BwhJBTcQ.js → http-method-uhrnWrGC.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{index-RhV2jT_7.js → index-Duxus_sG.js} +3 -3
  37. package/src/console/ui-next/dist/assets/{observability-page-BRfUILBq.js → observability-page-B-QM8X0O.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{replica-lag-BsceAzIG.js → replica-lag-DuqHCANi.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{request-meta-C6lTyCXp.js → request-meta-BTBZ3Ulc.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{store-page-YmE806bJ.js → store-page-fpo7VxYb.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{trace-detail-sheet-Di4irS49.js → trace-detail-sheet-DKtr1-K1.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DYeL6Rye.js → tree-expand-toggle-QeTceKKI.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{units-page-07agasRA.js → units-page-ChCp8mOy.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{vault-page-CHBck4n_.js → vault-page-CEYLXhUX.js} +1 -1
  45. package/src/console/ui-next/dist/index.html +1 -1
  46. package/src/console/ui-next/src/client.ts +8 -0
  47. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +16 -0
  48. package/src/elements/ai/deciders/presets.ts +182 -0
  49. package/src/elements/ai/decisions/bind.ts +6 -5
  50. package/src/elements/ai/decisions/catalog.ts +93 -0
  51. package/src/elements/ai/decisions/certificate.test.ts +47 -19
  52. package/src/elements/ai/decisions/certificate.ts +107 -24
  53. package/src/elements/ai/decisions/certify.ts +104 -9
  54. package/src/elements/ai/decisions/codec.test.ts +142 -0
  55. package/src/elements/ai/decisions/codec.ts +302 -0
  56. package/src/elements/ai/decisions/decide.test.ts +108 -31
  57. package/src/elements/ai/decisions/decider-runtime.test.ts +415 -0
  58. package/src/elements/ai/decisions/decider.types.test.ts +62 -0
  59. package/src/elements/ai/decisions/e2e.test.ts +20 -16
  60. package/src/elements/ai/decisions/http.ts +15 -7
  61. package/src/elements/ai/decisions/labels.test.ts +6 -1
  62. package/src/elements/ai/decisions/labels.ts +4 -3
  63. package/src/elements/ai/decisions/openai.live.test.ts +42 -0
  64. package/src/elements/ai/decisions/openrouter.live.test.ts +5 -1
  65. package/src/elements/ai/decisions/provider.ts +22 -1
  66. package/src/elements/ai/declare.ts +208 -51
  67. package/src/elements/ai.ts +1 -0
  68. package/src/index.ts +1 -0
  69. package/src/kernel/app.ts +8 -0
  70. package/src/kernel/decision-budget-entry.ts +11 -0
  71. package/src/kernel/element-registries.ts +3 -0
  72. package/src/kernel/fx-decide.ts +276 -88
  73. package/src/manifest/types.ts +38 -2
  74. package/src/release/measure.ts +47 -8
  75. package/src/test/create-test-app.ts +21 -1
@@ -37,26 +37,43 @@ export interface DecisionCertSlice {
37
37
  readonly metrics?: Readonly<Record<string, number>>;
38
38
  }
39
39
 
40
- /** One decision inside `oke-decisions.lock.json`. */
41
- export interface DecisionLockEntry {
40
+ /** One decider's certificate inside a decision. */
41
+ export interface DecisionDeciderCert {
42
42
  readonly model: string;
43
+ /** False when the provider only echoes an alias. */
44
+ readonly pinned: boolean;
45
+ /** Epoch ms an alias certificate stops granting auto. */
46
+ readonly expiresAt?: number;
43
47
  /** Epoch ms the certificate was written. Drift counts labels after this. */
44
48
  readonly certifiedAt?: number;
45
49
  readonly questions: Readonly<Record<string, Readonly<Record<string, DecisionCertSlice>>>>;
46
50
  }
47
51
 
48
- /** App lockfile. Lives next to that app's OKE config. */
52
+ /** One decision inside `oke-decisions.lock.json`. */
53
+ export interface DecisionLockEntry {
54
+ readonly deciders: Readonly<Record<string, DecisionDeciderCert>>;
55
+ }
56
+
57
+ /** App lockfile. Version 1 is rejected. */
49
58
  export interface DecisionLockfile {
59
+ readonly version: 2;
50
60
  readonly decisions: Readonly<Record<string, DecisionLockEntry>>;
51
61
  }
52
62
 
63
+ /** A lockfile that is not version 2. */
64
+ export class DecisionLockVersionError extends Error {
65
+ constructor() {
66
+ super("oke-decisions.lock.json is not version 2. Run `oke decide certify` to recertify.");
67
+ this.name = "DecisionLockVersionError";
68
+ }
69
+ }
70
+
53
71
  /** Why a question is not auto. */
54
- export type DecisionUncertainty =
55
- | "low-confidence"
56
- | "missing-lock"
57
- | "stale-hash"
58
- | "version-mismatch"
59
- | "uncertified-locale"
72
+ export type DecisionWhy =
73
+ | "uncertain"
74
+ | "refused"
75
+ | "none_of_these"
76
+ | "uncertified"
60
77
  | "drift"
61
78
  | "outage";
62
79
 
@@ -84,10 +101,14 @@ export interface DecisionLabel {
84
101
  readonly reviewId?: string;
85
102
  }
86
103
 
87
- /** App-wide candidate. A full lock entry, not a count. */
104
+ /** App-wide candidate for one decider. */
88
105
  export interface DecisionCandidate {
106
+ readonly decider: string;
89
107
  readonly model: string;
90
- readonly questions: DecisionLockEntry["questions"];
108
+ readonly pinned: boolean;
109
+ readonly expiresAt?: number;
110
+ readonly certifiedAt?: number;
111
+ readonly questions: DecisionDeciderCert["questions"];
91
112
  }
92
113
 
93
114
  /** Lockfile name at the app project root. */
@@ -292,7 +313,9 @@ export function recordDecisionLabel(label: DecisionLabel): void {
292
313
  export function aggregateDecisionCandidate(
293
314
  decision: string,
294
315
  fit: (rows: readonly DecisionLabel[]) => DecisionCandidate = () => ({
316
+ decider: "",
295
317
  model: "",
318
+ pinned: true,
296
319
  questions: {},
297
320
  }),
298
321
  ): DecisionCandidate {
@@ -312,21 +335,39 @@ export function lockFromCandidate(
312
335
  candidate: unknown,
313
336
  current: DecisionLockfile | undefined,
314
337
  ): DecisionLockfile {
315
- const entry = parseDecisionLockEntry(candidate);
316
- if (!entry) throw new TypeError("promote: candidate is not a lock entry");
338
+ const parsed = parseDecisionCandidate(candidate);
339
+ if (!parsed) throw new TypeError("promote: candidate is not a lock entry");
317
340
  setDecisionDrift(name, false);
318
- return { decisions: { ...(current?.decisions ?? {}), [name]: entry } };
341
+ const prev = current?.decisions[name]?.deciders ?? {};
342
+ return {
343
+ version: 2,
344
+ decisions: {
345
+ ...(current?.decisions ?? {}),
346
+ [name]: { deciders: { ...prev, [parsed.decider]: certFromCandidate(parsed) } },
347
+ },
348
+ };
349
+ }
350
+
351
+ function certFromCandidate(candidate: DecisionCandidate): DecisionDeciderCert {
352
+ return {
353
+ model: candidate.model,
354
+ pinned: candidate.pinned,
355
+ ...(candidate.expiresAt !== undefined ? { expiresAt: candidate.expiresAt } : {}),
356
+ ...(candidate.certifiedAt !== undefined ? { certifiedAt: candidate.certifiedAt } : {}),
357
+ questions: candidate.questions,
358
+ };
319
359
  }
320
360
 
321
361
  /**
322
- * Accept a lock entry with a model and a questions map.
362
+ * Accept a candidate for one decider.
323
363
  *
324
- * @param body - Candidate or file slot
364
+ * @param body - Candidate body
325
365
  */
326
- export function parseDecisionLockEntry(body: unknown): DecisionLockEntry | undefined {
366
+ export function parseDecisionCandidate(body: unknown): DecisionCandidate | undefined {
327
367
  if (!body || typeof body !== "object") return undefined;
328
368
  const record = body as Record<string, unknown>;
329
- if (typeof record.model !== "string") return undefined;
369
+ if (typeof record.decider !== "string" || typeof record.model !== "string") return undefined;
370
+ if (typeof record.pinned !== "boolean") return undefined;
330
371
  if (
331
372
  !record.questions ||
332
373
  typeof record.questions !== "object" ||
@@ -335,28 +376,70 @@ export function parseDecisionLockEntry(body: unknown): DecisionLockEntry | undef
335
376
  return undefined;
336
377
  }
337
378
  return {
379
+ decider: record.decider,
338
380
  model: record.model,
381
+ pinned: record.pinned,
382
+ ...(typeof record.expiresAt === "number" ? { expiresAt: record.expiresAt } : {}),
339
383
  ...(typeof record.certifiedAt === "number" ? { certifiedAt: record.certifiedAt } : {}),
340
- questions: record.questions as DecisionLockEntry["questions"],
384
+ questions: record.questions as DecisionCandidate["questions"],
341
385
  };
342
386
  }
343
387
 
344
388
  /**
345
- * Accept a lockfile object.
389
+ * Accept one decision slot (`deciders` map).
390
+ *
391
+ * @param body - File slot
392
+ */
393
+ export function parseDecisionLockEntry(body: unknown): DecisionLockEntry | undefined {
394
+ if (!body || typeof body !== "object") return undefined;
395
+ const record = body as Record<string, unknown>;
396
+ if (typeof record.model === "string" && record.deciders === undefined) {
397
+ throw new DecisionLockVersionError();
398
+ }
399
+ if (!record.deciders || typeof record.deciders !== "object" || Array.isArray(record.deciders)) {
400
+ return undefined;
401
+ }
402
+ const deciders: Record<string, DecisionDeciderCert> = {};
403
+ for (const [name, entry] of Object.entries(record.deciders)) {
404
+ const parsed = parseDecisionCandidate({
405
+ ...(entry as object),
406
+ decider: name,
407
+ pinned: (entry as { pinned?: unknown }).pinned ?? true,
408
+ });
409
+ if (!parsed) return undefined;
410
+ deciders[name] = certFromCandidate(parsed);
411
+ }
412
+ return { deciders };
413
+ }
414
+
415
+ /**
416
+ * Accept a version 2 lockfile. Version 1 throws {@link DecisionLockVersionError}.
346
417
  *
347
418
  * @param body - Parsed JSON
348
419
  */
349
420
  export function parseDecisionLockfile(body: unknown): DecisionLockfile | undefined {
350
421
  if (!body || typeof body !== "object") return undefined;
351
- const decisions = (body as { decisions?: unknown }).decisions;
352
- if (!decisions || typeof decisions !== "object" || Array.isArray(decisions)) return undefined;
422
+ const record = body as { version?: unknown; decisions?: unknown };
423
+ if (record.version !== 2) {
424
+ if (record.decisions && typeof record.decisions === "object") {
425
+ throw new DecisionLockVersionError();
426
+ }
427
+ return undefined;
428
+ }
429
+ if (
430
+ !record.decisions ||
431
+ typeof record.decisions !== "object" ||
432
+ Array.isArray(record.decisions)
433
+ ) {
434
+ return undefined;
435
+ }
353
436
  const next: Record<string, DecisionLockEntry> = {};
354
- for (const [name, entry] of Object.entries(decisions)) {
437
+ for (const [name, entry] of Object.entries(record.decisions)) {
355
438
  const parsed = parseDecisionLockEntry(entry);
356
439
  if (!parsed) return undefined;
357
440
  next[name] = parsed;
358
441
  }
359
- return { decisions: next };
442
+ return { version: 2, decisions: next };
360
443
  }
361
444
 
362
445
  /**
@@ -10,8 +10,8 @@ import {
10
10
  questionHash,
11
11
  type DecisionCalibrator,
12
12
  type DecisionCertSlice,
13
+ type DecisionDeciderCert,
13
14
  type DecisionLabel,
14
- type DecisionLockEntry,
15
15
  } from "./certificate.ts";
16
16
  import type { DecisionResponse } from "./provider.ts";
17
17
 
@@ -26,6 +26,24 @@ export interface DecisionSeedCase {
26
26
  /** One provider call used while certifying. */
27
27
  export type DecisionEvaluate = (input: unknown) => Promise<DecisionResponse>;
28
28
 
29
+ /** Printed for one question after certify. */
30
+ export interface CertifyQuestionReport {
31
+ readonly accuracy: number;
32
+ readonly ece: number;
33
+ readonly coverage: number;
34
+ readonly p50Ms: number;
35
+ readonly p95Ms: number;
36
+ readonly cost: number;
37
+ }
38
+
39
+ /** Certificate plus the comparison row. */
40
+ export interface CertifySeedResult {
41
+ readonly cert: DecisionDeciderCert;
42
+ readonly report: Readonly<Record<string, CertifyQuestionReport>>;
43
+ /** True when the certificate is an expiring alias. */
44
+ readonly unpinned: boolean;
45
+ }
46
+
29
47
  /**
30
48
  * Parse decision seed JSONL. Blank lines are skipped.
31
49
  *
@@ -73,29 +91,51 @@ interface FitRow {
73
91
  */
74
92
  export async function certifySeed(options: {
75
93
  readonly model: string;
94
+ readonly pinned?: boolean;
95
+ readonly expiresAt?: number;
76
96
  readonly maxError: number;
77
97
  readonly delta?: number;
78
98
  readonly jsonl: string;
79
99
  readonly ask: Readonly<Record<string, AiDecisionQuestion>>;
80
100
  readonly evaluate: DecisionEvaluate;
81
- }): Promise<DecisionLockEntry> {
101
+ readonly now?: () => number;
102
+ }): Promise<CertifySeedResult> {
82
103
  const cases = parseDecisionSeed(options.jsonl);
83
104
  const buckets = new Map<string, FitRow[]>();
105
+ const samples = new Map<string, { correct: boolean; confidence: number }[]>();
106
+ const latencies: number[] = [];
107
+ let cost = 0;
84
108
  let resolved = options.model;
109
+ const now = options.now ?? Date.now;
85
110
  for (const row of cases) {
111
+ const started = now();
86
112
  const response = await options.evaluate(row.input);
113
+ latencies.push(Math.max(0, now() - started));
114
+ cost += response.usage.cost ?? 0;
87
115
  if (response.model) resolved = response.model;
88
116
  const locale = row.locale ?? "";
89
117
  for (const [id, expected] of Object.entries(row.expect)) {
90
118
  const question = options.ask[id];
91
119
  if (!question) continue;
92
- const answer = response.answers[id];
120
+ const answers = response.answers;
121
+ const answer = Array.isArray(answers)
122
+ ? undefined
123
+ : (answers as Readonly<Record<string, unknown>>)[id];
93
124
  const fit = fitRow(question, answer, expected);
94
125
  if (!fit) continue;
95
126
  const key = `${id}\0${locale}`;
96
127
  const list = buckets.get(key) ?? [];
97
128
  list.push(fit);
98
129
  buckets.set(key, list);
130
+ const confidence =
131
+ fit.noul !== undefined ? Math.max(fit.noul, 1 - fit.noul) : Math.max(...fit.probs, 0);
132
+ const correct =
133
+ fit.booleanLabel !== undefined
134
+ ? fit.noul !== undefined && fit.noul >= 0.5 === fit.booleanLabel
135
+ : fit.probs[fit.labelIndex] === Math.max(...fit.probs);
136
+ const bag = samples.get(id) ?? [];
137
+ bag.push({ correct, confidence });
138
+ samples.set(id, bag);
99
139
  }
100
140
  }
101
141
  const entry = entryFromBuckets(
@@ -105,7 +145,30 @@ export async function certifySeed(options: {
105
145
  options.maxError,
106
146
  options.delta ?? 0.1,
107
147
  );
108
- return { ...entry, certifiedAt: Date.now() };
148
+ const pinned = options.pinned !== false;
149
+ const cert: DecisionDeciderCert = {
150
+ ...entry,
151
+ pinned,
152
+ ...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}),
153
+ certifiedAt: Date.now(),
154
+ };
155
+ const report: Record<string, CertifyQuestionReport> = {};
156
+ for (const [id, rows] of samples) {
157
+ const threshold = cert.questions[id]?.[""]?.threshold;
158
+ const covered =
159
+ threshold === undefined
160
+ ? 0
161
+ : rows.filter((row) => row.confidence >= threshold).length / rows.length;
162
+ report[id] = {
163
+ accuracy: rows.filter((row) => row.correct).length / rows.length,
164
+ ece: expectedCalibrationError(rows),
165
+ coverage: covered,
166
+ p50Ms: percentile(latencies, 0.5),
167
+ p95Ms: percentile(latencies, 0.95),
168
+ cost,
169
+ };
170
+ }
171
+ return { cert, report, unpinned: !pinned };
109
172
  }
110
173
 
111
174
  /**
@@ -119,7 +182,7 @@ export function certifyLabels(options: {
119
182
  readonly delta?: number;
120
183
  readonly ask: Readonly<Record<string, AiDecisionQuestion>>;
121
184
  readonly labels: readonly DecisionLabel[];
122
- }): DecisionLockEntry {
185
+ }): DecisionDeciderCert {
123
186
  const groups = new Map<string, DecisionLabel[]>();
124
187
  for (const label of options.labels) {
125
188
  if (label.raw === undefined) continue;
@@ -148,7 +211,7 @@ export function certifyLabels(options: {
148
211
  bucket[locale] = slice;
149
212
  questions[id] = bucket;
150
213
  }
151
- return { model, certifiedAt: Date.now(), questions };
214
+ return { model, pinned: true, certifiedAt: Date.now(), questions };
152
215
  }
153
216
 
154
217
  function entryFromBuckets(
@@ -157,7 +220,7 @@ function entryFromBuckets(
157
220
  buckets: ReadonlyMap<string, readonly FitRow[]>,
158
221
  maxError: number,
159
222
  delta: number,
160
- ): DecisionLockEntry {
223
+ ): DecisionDeciderCert {
161
224
  const questions: Record<string, Record<string, DecisionCertSlice>> = {};
162
225
  for (const [key, rows] of buckets) {
163
226
  const split = key.indexOf("\0");
@@ -171,7 +234,7 @@ function entryFromBuckets(
171
234
  bucket[locale] = slice;
172
235
  questions[id] = bucket;
173
236
  }
174
- return { model, questions };
237
+ return { model, pinned: true, questions };
175
238
  }
176
239
 
177
240
  function sliceFromRows(
@@ -270,6 +333,32 @@ function scoreFit(
270
333
  *
271
334
  * @param record - Answer or stored raw
272
335
  */
336
+ function percentile(values: readonly number[], p: number): number {
337
+ if (values.length === 0) return 0;
338
+ const sorted = [...values].sort((a, b) => a - b);
339
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
340
+ return sorted[index] ?? 0;
341
+ }
342
+
343
+ function expectedCalibrationError(
344
+ rows: readonly { readonly confidence: number; readonly correct: boolean }[],
345
+ ): number {
346
+ if (rows.length === 0) return 0;
347
+ const bins = Array.from({ length: 10 }, () => ({ n: 0, confidence: 0, correct: 0 }));
348
+ for (const row of rows) {
349
+ const index = Math.min(9, Math.floor(row.confidence * 10));
350
+ const bin = bins[index];
351
+ if (!bin) continue;
352
+ bin.n += 1;
353
+ bin.confidence += row.confidence;
354
+ bin.correct += row.correct ? 1 : 0;
355
+ }
356
+ return bins.reduce((sum, bin) => {
357
+ if (bin.n === 0) return sum;
358
+ return sum + (bin.n / rows.length) * Math.abs(bin.correct / bin.n - bin.confidence / bin.n);
359
+ }, 0);
360
+ }
361
+
273
362
  function storedDistribution(record: Record<string, unknown>): unknown {
274
363
  if (record.probabilities !== undefined) return record.probabilities;
275
364
  return record;
@@ -281,12 +370,18 @@ function fitRow(
281
370
  expected: unknown,
282
371
  ): FitRow | undefined {
283
372
  const record = answer && typeof answer === "object" ? (answer as Record<string, unknown>) : {};
373
+ if (record.type === "refusal" || record.type === "malformed") return undefined;
284
374
  if (question.kind === "boolean") {
285
375
  if (typeof expected !== "boolean") return undefined;
286
376
  return {
287
377
  probs: [],
288
378
  labelIndex: expected ? 1 : 0,
289
- noul: typeof record.noul === "number" ? record.noul : 0,
379
+ noul:
380
+ typeof record.probability === "number"
381
+ ? record.probability
382
+ : typeof record.noul === "number"
383
+ ? record.noul
384
+ : 0,
290
385
  booleanLabel: expected,
291
386
  weight: 1,
292
387
  };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Codec round-trips for the verified System One body and the OpenAI decisions wire.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { ai } from "../../ai.ts";
7
+ import { encodeDecisionRequest, normalizeDecisionResponse, wireQuestions } from "./codec.ts";
8
+
9
+ const openRouterBody = {
10
+ model: "typesafe/jev-1.13-20260917",
11
+ answers: {
12
+ team: {
13
+ type: "choice",
14
+ choice: "technical",
15
+ probabilities: { billing: 0.49, none_of_these: 0.02, technical: 0.49 },
16
+ confidence: 0.23,
17
+ },
18
+ urgent: { type: "noul", noul: 0.54 },
19
+ },
20
+ usage: { input_tokens: 348, output_tokens: 58, cost: 0.000014616 },
21
+ id: "gen-dec-1791389894-FvrNcwqrVRdAEvlEDRh9",
22
+ provider: "TypeSafe",
23
+ };
24
+
25
+ describe("decision codecs", () => {
26
+ test("System One normalizes the verified OpenRouter body", () => {
27
+ const response = normalizeDecisionResponse("systemone", openRouterBody);
28
+ expect(response.model).toBe("typesafe/jev-1.13-20260917");
29
+ expect(response.provider).toBe("TypeSafe");
30
+ expect(response.usage).toEqual({ inputTokens: 348, outputTokens: 58, cost: 0.000014616 });
31
+ expect(response.answers).toMatchObject({
32
+ team: {
33
+ type: "choice",
34
+ choice: "technical",
35
+ probabilities: { billing: 0.49, none_of_these: 0.02, technical: 0.49 },
36
+ },
37
+ urgent: { type: "boolean", probability: 0.54 },
38
+ });
39
+ });
40
+
41
+ test("a choice without a choice string uses the highest probability", () => {
42
+ const response = normalizeDecisionResponse("systemone", {
43
+ model: "m",
44
+ answers: {
45
+ team: { type: "choice", probabilities: { billing: 0.2, technical: 0.8 } },
46
+ },
47
+ });
48
+ expect(response.answers).toMatchObject({
49
+ team: { type: "choice", choice: "technical" },
50
+ });
51
+ });
52
+
53
+ test("refusal drops the text and a partial body is malformed", () => {
54
+ const response = normalizeDecisionResponse("systemone", {
55
+ model: "m",
56
+ answers: {
57
+ team: { type: "refusal", text: "not a label" },
58
+ urgent: { type: "choice" },
59
+ empty: { nope: true },
60
+ },
61
+ });
62
+ expect(response.answers).toEqual({
63
+ team: { type: "refusal" },
64
+ urgent: { type: "malformed" },
65
+ empty: { type: "malformed" },
66
+ });
67
+ expect(JSON.stringify(response.answers)).not.toContain("not a label");
68
+ });
69
+
70
+ test("OpenAI decisions encode and normalize predicate, choice, score, and refusal", () => {
71
+ const questions = wireQuestions({
72
+ urgent: ai.boolean("Does this need a person today?"),
73
+ team: ai.choice("Which team owns this ticket?", {
74
+ billing: "Billing",
75
+ technical: "Technical",
76
+ }),
77
+ severity: ai.score("How severe is this?", ["low", "high"]),
78
+ });
79
+ const encoded = encodeDecisionRequest("openai-decisions", {
80
+ model: "gpt-6-luna",
81
+ state: { ticket: "Checkout is blank" },
82
+ questions,
83
+ }) as {
84
+ model: string;
85
+ input: string;
86
+ questions: {
87
+ name: string;
88
+ type: string;
89
+ choices?: { value: string }[];
90
+ levels?: { label: string }[];
91
+ }[];
92
+ };
93
+ expect(encoded.model).toBe("gpt-6-luna");
94
+ expect(encoded.input).toBe(JSON.stringify({ ticket: "Checkout is blank" }));
95
+ expect(encoded.questions.map((question) => question.type)).toEqual([
96
+ "predicate",
97
+ "choice",
98
+ "score",
99
+ ]);
100
+ expect(encoded.questions[1]?.choices?.map((choice) => choice.value)).toEqual([
101
+ "billing",
102
+ "technical",
103
+ "none_of_these",
104
+ ]);
105
+ expect(encoded.questions[2]?.levels).toEqual([{ label: "low" }, { label: "high" }]);
106
+
107
+ const response = normalizeDecisionResponse("openai-decisions", {
108
+ model: "gpt-6-luna",
109
+ answers: [
110
+ { name: "urgent", type: "predicate", probability: 0.12 },
111
+ {
112
+ name: "team",
113
+ type: "choice",
114
+ choice: "none_of_these",
115
+ probabilities: [
116
+ { value: "billing", probability: 0.1 },
117
+ { value: "technical", probability: 0.2 },
118
+ { value: "none_of_these", probability: 0.7 },
119
+ ],
120
+ },
121
+ {
122
+ name: "severity",
123
+ type: "score",
124
+ score: "low",
125
+ probabilities: [
126
+ { label: "low", probability: 0.8 },
127
+ { label: "high", probability: 0.2 },
128
+ ],
129
+ },
130
+ { name: "held", type: "refusal" },
131
+ ],
132
+ usage: { input_tokens: 20, output_tokens: 8 },
133
+ });
134
+ expect(response.model).toBe("gpt-6-luna");
135
+ expect(response.answers).toMatchObject({
136
+ urgent: { type: "boolean", probability: 0.12 },
137
+ team: { type: "choice", choice: "none_of_these" },
138
+ severity: { type: "score", score: "low", probabilities: { low: 0.8, high: 0.2 } },
139
+ held: { type: "refusal" },
140
+ });
141
+ });
142
+ });