@desplega.ai/agent-swarm 1.158.0 → 1.159.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.
- package/dist/{acp-adapter-xaq4n6pc.js → acp-adapter-qhch0n2v.js} +1 -1
- package/dist/{actions-p9hz5yat.js → actions-dtsh1mfb.js} +33 -29
- package/dist/{anthropic-messages-51pz26qe.js → anthropic-messages-f2h06k64.js} +8 -3
- package/dist/{app-g26wdcz6.js → app-y1b9srva.js} +10 -9
- package/dist/{assistant-wydfqkb3.js → assistant-31hwy5xj.js} +34 -30
- package/dist/{azure-openai-responses-8gksqtmz.js → azure-openai-responses-0k1mfax5.js} +12 -13
- package/dist/{boot-reembed-se8zmdcp.js → boot-reembed-48d2qpkt.js} +11 -11
- package/dist/{boot-reembed-g5ckg9yb.js → boot-reembed-w0bryhkr.js} +12 -12
- package/dist/{boot-scrub-logs-wfxqan61.js → boot-scrub-logs-t65wbrn7.js} +9 -8
- package/dist/{boot-seeds-s3q01xj3.js → boot-seeds-h7c32hvm.js} +12 -11
- package/dist/{claude-adapter-tv2x4f36.js → claude-adapter-1t6611ne.js} +26 -21
- package/dist/{claude-managed-adapter-41b2s7fd.js → claude-managed-adapter-werp659j.js} +4 -2
- package/dist/{claude-managed-setup-60g8aazx.js → claude-managed-setup-y6rdgbc1.js} +11 -2
- package/dist/claude-sdk-session-zhkyk8yw.js +29319 -0
- package/dist/{cli-vj6h9kc2.js → cli-0m7wct0d.js} +59 -1
- package/dist/{cli-wscdvcqz.js → cli-0n2e4x49.js} +1 -1
- package/dist/cli-0xqde53b.js +176 -0
- package/dist/{cli-xberxa1r.js → cli-10ym38qc.js} +2 -2
- package/dist/{cli-5b6b62tz.js → cli-13hw4z91.js} +1 -1
- package/dist/{cli-rqz8qbw7.js → cli-20035trh.js} +50 -18
- package/dist/{cli-k0g1sjzm.js → cli-31nkw3g3.js} +3 -3
- package/dist/cli-3gvrr8vj.js +1422 -0
- package/dist/{cli-c07r9a2m.js → cli-48527fpw.js} +9 -8
- package/dist/{cli-24k806sc.js → cli-4aj25szq.js} +2 -2
- package/dist/{cli-7gtvvpbh.js → cli-4tvgz99m.js} +7 -7
- package/dist/cli-503ya656.js +455 -0
- package/dist/{cli-cw9zck8j.js → cli-5f2hq128.js} +8 -4
- package/dist/{cli-s8eet254.js → cli-68bmme5p.js} +11078 -27833
- package/dist/{cli-ftfvggzx.js → cli-79aq9cgb.js} +794 -563
- package/dist/cli-7cxw8b60.js +14910 -0
- package/dist/cli-7kyk9jr3.js +22 -0
- package/dist/cli-7zttzetj.js +324 -0
- package/dist/{cli-y95wp3tg.js → cli-96qd5psa.js} +1 -1
- package/dist/{cli-g8djp6qe.js → cli-9yg65c1j.js} +22 -0
- package/dist/{cli-3gmcwfyx.js → cli-ardpzhjm.js} +5 -5
- package/dist/{cli-7s3syk22.js → cli-b6thtzac.js} +5 -5
- package/dist/{cli-00w2c66p.js → cli-bgv70kym.js} +13 -2
- package/dist/{cli-j55n64b6.js → cli-bv0a38v9.js} +163 -44
- package/dist/cli-ca2w7zv7.js +115 -0
- package/dist/cli-cpvsaqsw.js +333 -0
- package/dist/cli-cygyqj3b.js +754 -0
- package/dist/{cli-x24pshtq.js → cli-dvjpkkjd.js} +6 -6
- package/dist/{cli-5d10vd2m.js → cli-e0ndxhtk.js} +4 -4
- package/dist/{cli-4198qc9v.js → cli-e4xnc0qp.js} +15 -3
- package/dist/{cli-rjehvrts.js → cli-eg7cdg68.js} +8 -8
- package/dist/cli-ejpygnqz.js +3815 -0
- package/dist/{cli-fgqxwrpv.js → cli-fjtp3463.js} +1631 -127
- package/dist/{cli-zt1227k1.js → cli-fkqhbg8g.js} +5 -3
- package/dist/{cli-47m6dkd3.js → cli-g9df2m37.js} +409 -168
- package/dist/cli-gzyh0j0g.js +8455 -0
- package/dist/cli-hgqfttfc.js +183 -0
- package/dist/cli-hj65wsts.js +17755 -0
- package/dist/cli-j9z8mfx3.js +476 -0
- package/dist/{cli-aehm1rbq.js → cli-kc3dp84d.js} +1135 -61
- package/dist/{cli-h38x0qhg.js → cli-kdnehe0t.js} +4 -3
- package/dist/{cli-20cj37vt.js → cli-m4excttz.js} +136 -2216
- package/dist/{cli-8qmme6vd.js → cli-mg24hkt4.js} +2 -2
- package/dist/{cli-xhaeg2nc.js → cli-mzkqpxee.js} +1 -1
- package/dist/{cli-q67y4wzm.js → cli-phsaj4xq.js} +24 -2
- package/dist/{cli-pw9rj5t9.js → cli-pqetb83f.js} +1 -176
- package/dist/{cli-rj19hc34.js → cli-qjxy52w7.js} +1 -1
- package/dist/{cli-53sz8gkz.js → cli-qv4jv8bx.js} +1 -1
- package/dist/cli-qvnnx4ct.js +1051 -0
- package/dist/cli-rb9m6rpe.js +4980 -0
- package/dist/{cli-tt9k8r35.js → cli-rfezr056.js} +476 -909
- package/dist/{cli-ggm92wcn.js → cli-rgjprct3.js} +1 -0
- package/dist/{cli-9httdpyq.js → cli-rrqmqr1s.js} +1 -1
- package/dist/cli-rykfmgwg.js +102 -0
- package/dist/{cli-eetrvv91.js → cli-t3p77qk1.js} +1 -1
- package/dist/{cli-68w09sjw.js → cli-vn0qv9cj.js} +25 -88
- package/dist/{cli-987hg0aa.js → cli-vwbbaecp.js} +2 -2
- package/dist/cli-wd9hz40d.js +69 -0
- package/dist/{cli-j57qkrpq.js → cli-wgjx0q33.js} +13 -13
- package/dist/cli-xgpf00sk.js +2149 -0
- package/dist/{cli-mcnhgnn1.js → cli-ykk5jaax.js} +184 -26
- package/dist/{cli-aky8tctm.js → cli-z9x0sh1t.js} +3 -3
- package/dist/{cli-1hkrch3k.js → cli-zw65mab6.js} +5 -5
- package/dist/cli.js +38 -29
- package/dist/cloudflare-workers-ai-system-one-1sg586qv.js +46 -0
- package/dist/{codex-adapter-cnm8wk11.js → codex-adapter-jqfqqa21.js} +21 -14
- package/dist/{codex-hook-9hptmdhj.js → codex-hook-5eys3dya.js} +2 -2
- package/dist/{codex-session-runner-gfg547zj.js → codex-session-runner-zd341dsm.js} +21 -14
- package/dist/{commands-e3bn0p75.js → commands-vfsysz4s.js} +9 -8
- package/dist/{db-03v9kzr2.js → db-s3znhvbn.js} +15 -10
- package/dist/{dispatcher-nrmg8yht.js → dispatcher-5xn0c7rf.js} +32 -28
- package/dist/{dsh-adapter-k03nfqa6.js → dsh-adapter-894mgfvs.js} +3 -3
- package/dist/{e2b-93x1k0j5.js → e2b-ez07znam.js} +4 -3
- package/dist/execute-71ysgv35.js +965 -0
- package/dist/{google-generative-ai-bxmv50nc.js → google-generative-ai-1nfcdphp.js} +5 -4
- package/dist/{google-vertex-809af514.js → google-vertex-1tqcd7b1.js} +5 -4
- package/dist/{handlers-aqcedbyb.js → handlers-5w4jwycr.js} +35 -31
- package/dist/{hook-7vkskkre.js → hook-853bxrr6.js} +16 -13
- package/dist/{hook-ybvmrmhq.js → hook-z0hbxj85.js} +16 -13
- package/dist/{http-2eqa8t24.js → http-akc3wy3m.js} +600 -123
- package/dist/{index-f6cryxwe.js → index-4a5njnm1.js} +2 -59
- package/dist/{index-a3vn96n8.js → index-5sz16ksh.js} +187 -47
- package/dist/{index-c6zsv1bd.js → index-bjmjh9h4.js} +11 -11
- package/dist/{index-chgdkq36.js → index-cd151qkq.js} +32 -28
- package/dist/{index-x71bvq0p.js → index-k661dzzr.js} +33 -29
- package/dist/{index-1yks3qcr.js → index-ttpbhnjg.js} +32 -28
- package/dist/{keepalive-vtkf5n99.js → keepalive-8p9rpvz5.js} +11 -10
- package/dist/{lead-h3sp357x.js → lead-jp69z5h3.js} +49 -45
- package/dist/llama-cpp-classify-t2tccams.js +335 -0
- package/dist/{maintenance-jzeqtk3q.js → maintenance-fwx4ev0k.js} +12 -12
- package/dist/{mistral-conversations-zx0kn554.js → mistral-conversations-bhvwqcnk.js} +14 -17
- package/dist/{oauth-refresh-sweep-qp3dzmdp.js → oauth-refresh-sweep-cwcq1ctx.js} +10 -9
- package/dist/{onboard-tmsp0qbz.js → onboard-2b4q0t84.js} +6 -5
- package/dist/{openai-codex-responses-4vjqzvjp.js → openai-codex-responses-nekthgks.js} +21 -8
- package/dist/{openai-completions-9hjrqbw8.js → openai-completions-hxtzwj0s.js} +8 -11
- package/dist/{openai-responses-7b1pn0xt.js → openai-responses-w4r118w7.js} +22 -17
- package/dist/{opencode-adapter-x6x2da1z.js → opencode-adapter-zz7xvjj6.js} +6 -4
- package/dist/{openrouter-images-9f9ek4rw.js → openrouter-images-b6sqqjgn.js} +4 -6
- package/dist/{otel-impl-9mz73q65.js → otel-impl-g8rs4vwr.js} +1 -1
- package/dist/{pi-messages-txpjj39q.js → pi-messages-vxdt8v7j.js} +2 -1
- package/dist/{pi-mono-adapter-m6dvv0dz.js → pi-mono-adapter-n0me28wt.js} +29 -17
- package/dist/pricing-refresh-0v77385v.js +39 -0
- package/dist/{rbac-roles-c9wqz83x.js → rbac-roles-krjyazbj.js} +10 -9
- package/dist/{render-v2-gryrh9h8.js → render-v2-3wjsp9aw.js} +10 -9
- package/dist/runtime-ktjysacx.js +24 -0
- package/dist/{setup-q9720bt6.js → setup-yeqryke6.js} +6 -5
- package/dist/typesafe-system-one-96960jxp.js +27 -0
- package/dist/{virtual-modules-3htpgn8x.js → virtual-modules-dzdeq1sf.js} +21 -13
- package/dist/{work-objects-b76x7deb.js → work-objects-3pakpkw0.js} +35 -31
- package/dist/{worker-6vrxbv41.js → worker-85testgj.js} +49 -45
- package/openapi.json +863 -14
- package/package.json +9 -8
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/tasks/human-free.ts +442 -0
- package/src/be/db/tasks/read.ts +56 -1
- package/src/be/db/tasks/write.ts +107 -11
- package/src/be/db.ts +89 -165
- package/src/be/harness-model-support.ts +182 -0
- package/src/be/human-free-drain.ts +67 -0
- package/src/be/migrations/182_task_human_free_flag.sql +63 -0
- package/src/be/migrations/183_human_free_reclassify_queue.sql +25 -0
- package/src/be/migrations/184_model_catalog.sql +120 -0
- package/src/be/model-catalog-store.ts +644 -0
- package/src/be/model-tier-keys.ts +31 -0
- package/src/be/model-tier-resolution.ts +489 -0
- package/src/be/model-validation.ts +112 -0
- package/src/be/models-catalog.ts +61 -7
- package/src/be/modelsdev-cache.json +82 -0
- package/src/be/modelsdev-cache.ts +2 -0
- package/src/be/pricing-refresh.ts +154 -12
- package/src/be/scripts/typecheck.ts +15 -0
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +61 -9
- package/src/be/swarm-config-guard.ts +6 -0
- package/src/cli.tsx +10 -0
- package/src/commands/claude-managed-setup.ts +3 -1
- package/src/commands/runner.ts +76 -2
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +64 -15
- package/src/heartbeat/templates.ts +2 -2
- package/src/http/agents.ts +49 -1
- package/src/http/index.ts +8 -0
- package/src/http/models-catalog.ts +357 -8
- package/src/http/poll.ts +45 -2
- package/src/http/schedules.ts +36 -6
- package/src/http/session-data.ts +2 -5
- package/src/http/stats.ts +6 -8
- package/src/http/tasks.ts +26 -1
- package/src/http/usage-cache.ts +56 -15
- package/src/http/workflows.ts +35 -8
- package/src/prompts/session-templates.ts +1 -0
- package/src/providers/claude-managed-models.ts +47 -161
- package/src/providers/codex-adapter.ts +4 -4
- package/src/providers/codex-models.ts +65 -198
- package/src/providers/reasoning-effort.ts +44 -140
- package/src/rbac/legacy-policy.ts +25 -0
- package/src/rbac/permissions.ts +10 -0
- package/src/scripts-runtime/sdk-allowlist.ts +4 -0
- package/src/scripts-runtime/types/stdlib.d.ts +15 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +15 -0
- package/src/server.ts +6 -0
- package/src/slack/message-text.ts +56 -2
- package/src/slack/thread-buffer.ts +10 -2
- package/src/tests/agents-list-model-display.test.ts +4 -6
- package/src/tests/base-prompt.test.ts +3 -0
- package/src/tests/claude-adapter.test.ts +10 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -18
- package/src/tests/codex-adapter.test.ts +2 -1
- package/src/tests/codex-gpt6-release.test.ts +66 -5
- package/src/tests/context-window.test.ts +7 -5
- package/src/tests/db-extraction-contract.test.ts +1 -0
- package/src/tests/db-task-read-contract.test.ts +5 -1
- package/src/tests/db-task-write-contract.test.ts +4 -1
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/explicit-model-validation.test.ts +332 -0
- package/src/tests/harness-model-support.test.ts +374 -0
- package/src/tests/heartbeat-checklist-config.test.ts +71 -0
- package/src/tests/heartbeat-checklist.test.ts +4 -2
- package/src/tests/heartbeat-orphan-resume.test.ts +48 -0
- package/src/tests/human-free-flag.test.ts +279 -0
- package/src/tests/human-free-oracle.ts +57 -0
- package/src/tests/human-free-reclassify.test.ts +669 -0
- package/src/tests/migration-046-budgets.test.ts +3 -3
- package/src/tests/model-catalog-migration.test.ts +134 -0
- package/src/tests/model-catalog-table.test.ts +249 -0
- package/src/tests/model-catalog-write-access.test.ts +387 -0
- package/src/tests/model-tier-resolution.test.ts +519 -0
- package/src/tests/models-catalog.test.ts +1 -1
- package/src/tests/multi-runtime-enabled-stats.test.ts +65 -1
- package/src/tests/package-publish.test.ts +137 -36
- package/src/tests/pricing-routes.test.ts +2 -2
- package/src/tests/rbac-engine.test.ts +42 -2
- package/src/tests/reasoning-effort.test.ts +109 -0
- package/src/tests/refresh-modelsdev-carryforward.test.ts +5 -5
- package/src/tests/runtime-model-catalog.test.ts +86 -0
- package/src/tests/script-connections.test.ts +5 -0
- package/src/tests/seed-scripts.test.ts +89 -0
- package/src/tests/session-costs.test.ts +14 -11
- package/src/tests/slack-message-text.test.ts +101 -0
- package/src/tests/slack-thread-buffer-context.test.ts +93 -0
- package/src/tests/task-cascade-fail.test.ts +148 -2
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/usage-cache.test.ts +166 -0
- package/src/tests/workflow-executors.test.ts +5 -2
- package/src/tests/workflow-system-one-decision-review.test.ts +1021 -0
- package/src/tests/workflow-system-one-decision-save-warnings.test.ts +365 -0
- package/src/tests/workflow-system-one-decision.test.ts +2063 -0
- package/src/tools/model-catalog/index.ts +137 -0
- package/src/tools/schedules/create-schedule.ts +16 -1
- package/src/tools/schedules/patch-schedule.ts +17 -0
- package/src/tools/schedules/update-schedule.ts +17 -0
- package/src/tools/send-task.ts +14 -0
- package/src/tools/task-action.ts +12 -0
- package/src/tools/tool-config.ts +3 -1
- package/src/tools/workflows/create-workflow.ts +9 -2
- package/src/tools/workflows/patch-workflow-node.ts +17 -7
- package/src/tools/workflows/patch-workflow.ts +9 -2
- package/src/tools/workflows/update-workflow.ts +9 -2
- package/src/types.ts +30 -1
- package/src/utils/context-window.ts +47 -53
- package/src/utils/harness-cli-version.ts +115 -0
- package/src/utils/harness-model-error.ts +33 -0
- package/src/utils/runtime-model-catalog.ts +137 -0
- package/src/workflows/approval-resolution.ts +39 -0
- package/src/workflows/definition.ts +7 -0
- package/src/workflows/engine.ts +69 -1
- package/src/workflows/executors/agent-task.ts +9 -0
- package/src/workflows/executors/base.ts +53 -0
- package/src/workflows/executors/human-in-the-loop.ts +8 -8
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/system-one-decision.ts +1232 -0
- package/src/workflows/executors/system-one-providers.ts +301 -0
- package/src/workflows/executors/workflow-llm.ts +13 -1
- package/src/workflows/readiness.ts +86 -0
- package/src/workflows/recovery.ts +12 -12
- package/src/workflows/resume.ts +23 -9
- package/templates/schedules/daily-blocker-digest/content.md +4 -5
- package/templates/skills/agent-browser/SKILL.md +6 -5
- package/templates/skills/agent-browser/content.md +6 -5
- package/templates/skills/heartbeat-runbook/config.json +1 -1
- package/templates/skills/heartbeat-runbook/content.md +2 -2
- package/templates/skills/swarm-scripts/SKILL.md +1 -1
- package/templates/skills/swarm-scripts/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +81 -1
- package/templates/workflows/claude-code-changelog-watch/content.md +2 -0
- package/templates/workflows/pr-review-status-sweep/content.md +2 -0
- package/dist/claude-sdk-session-t442qz6f.js +0 -39108
- package/dist/cli-0xjkj1j7.js +0 -61
- package/dist/cli-65hjmev5.js +0 -1141
- package/dist/cli-aw1jwp1r.js +0 -14075
- package/dist/cli-h967nb6r.js +0 -6603
- package/dist/cli-vapyk8sw.js +0 -20
- package/dist/cli-ws6rqycm.js +0 -247
- package/dist/pricing-refresh-v1cbr9te.js +0 -27
- package/src/providers/modelsdev-reasoning.json +0 -6453
- package/dist/{codex-login-x3jk4etn.js → codex-login-he9vrn90.js} +3 -3
- package/dist/{index-f24115ge.js → index-2wsdsnmm.js} +3 -3
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createAgent,
|
|
7
|
+
createWorkflow,
|
|
8
|
+
getDbClient,
|
|
9
|
+
getWorkflowRun,
|
|
10
|
+
getWorkflowRunStepsByRunId,
|
|
11
|
+
initDb,
|
|
12
|
+
upsertSwarmConfig,
|
|
13
|
+
} from "../be/db";
|
|
14
|
+
import type { Workflow, WorkflowDefinition, WorkflowNode } from "../types";
|
|
15
|
+
import { validateDefinition } from "../workflows/definition";
|
|
16
|
+
import { interpolateNodeConfig, startWorkflowExecution } from "../workflows/engine";
|
|
17
|
+
import { InProcessEventBus } from "../workflows/event-bus";
|
|
18
|
+
import {
|
|
19
|
+
BaseExecutor,
|
|
20
|
+
type ExecutorDependencies,
|
|
21
|
+
type ExecutorResult,
|
|
22
|
+
} from "../workflows/executors/base";
|
|
23
|
+
import { PropertyMatchExecutor } from "../workflows/executors/property-match";
|
|
24
|
+
import { createExecutorRegistry, ExecutorRegistry } from "../workflows/executors/registry";
|
|
25
|
+
import {
|
|
26
|
+
SYSTEM_ONE_ENDPOINT,
|
|
27
|
+
SystemOneDecisionConfigSchema,
|
|
28
|
+
SystemOneDecisionExecutor,
|
|
29
|
+
type SystemOneDecisionExecutorOptions,
|
|
30
|
+
SystemOneDecisionOutputSchema,
|
|
31
|
+
} from "../workflows/executors/system-one-decision";
|
|
32
|
+
import { findWorkflowReadinessProblems, workflowSaveWarnings } from "../workflows/readiness";
|
|
33
|
+
import { retryFailedRun } from "../workflows/resume";
|
|
34
|
+
import { interpolate } from "../workflows/template";
|
|
35
|
+
|
|
36
|
+
const TEST_DB_PATH = "./test-workflow-system-one-decision.sqlite";
|
|
37
|
+
// Shaped like a token but obviously fake; must never appear in any output or error.
|
|
38
|
+
const API_KEY = "tsk_example-jev-test-key.0123456789";
|
|
39
|
+
|
|
40
|
+
// ─── Harness ────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
let deps: ExecutorDependencies;
|
|
43
|
+
let agentId: string;
|
|
44
|
+
|
|
45
|
+
async function removeDbFiles(): Promise<void> {
|
|
46
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
47
|
+
try {
|
|
48
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type TransportStep = Response | Error | ((init: RequestInit) => Response | Promise<Response>);
|
|
56
|
+
|
|
57
|
+
interface RecordedCall {
|
|
58
|
+
url: string;
|
|
59
|
+
init: RequestInit;
|
|
60
|
+
body: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fake TypeSafe transport: serves `steps` in order, repeating the last one. */
|
|
64
|
+
function makeTransport(steps: TransportStep[]) {
|
|
65
|
+
const calls: RecordedCall[] = [];
|
|
66
|
+
const transport = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
67
|
+
const step = steps[Math.min(calls.length, steps.length - 1)];
|
|
68
|
+
const requestInit = init ?? {};
|
|
69
|
+
calls.push({
|
|
70
|
+
url: String(input),
|
|
71
|
+
init: requestInit,
|
|
72
|
+
body: JSON.parse(String(requestInit.body ?? "{}")) as Record<string, unknown>,
|
|
73
|
+
});
|
|
74
|
+
if (step instanceof Error) throw step;
|
|
75
|
+
if (typeof step === "function") return step(requestInit);
|
|
76
|
+
// A Response body can only be read once; hand out a fresh copy per call.
|
|
77
|
+
return (step as Response).clone();
|
|
78
|
+
}) as typeof fetch;
|
|
79
|
+
return { calls, transport };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
83
|
+
return new Response(JSON.stringify(body), {
|
|
84
|
+
status,
|
|
85
|
+
headers: { "content-type": "application/json", ...headers },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeExecutor(steps: TransportStep[], options: SystemOneDecisionExecutorOptions = {}) {
|
|
90
|
+
const { calls, transport } = makeTransport(steps);
|
|
91
|
+
const sleeps: number[] = [];
|
|
92
|
+
const executor = new SystemOneDecisionExecutor(deps, {
|
|
93
|
+
fetch: transport,
|
|
94
|
+
getApiKey: async () => API_KEY,
|
|
95
|
+
sleep: async (ms) => {
|
|
96
|
+
sleeps.push(ms);
|
|
97
|
+
},
|
|
98
|
+
random: () => 1,
|
|
99
|
+
...options,
|
|
100
|
+
});
|
|
101
|
+
return { executor, calls, sleeps };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function meta(nodeId = "qualify") {
|
|
105
|
+
return {
|
|
106
|
+
runId: crypto.randomUUID(),
|
|
107
|
+
stepId: crypto.randomUUID(),
|
|
108
|
+
nodeId,
|
|
109
|
+
workflowId: crypto.randomUUID(),
|
|
110
|
+
dryRun: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runSystemOne(executor: SystemOneDecisionExecutor, config: Record<string, unknown>) {
|
|
115
|
+
return executor.run({ config, context: {}, meta: meta() });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Fixtures ───────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const noulAnswer = { type: "noul", noul: 0.82 };
|
|
121
|
+
const choiceAnswer = {
|
|
122
|
+
type: "choice",
|
|
123
|
+
choice: "buyer",
|
|
124
|
+
confidence: 0.75,
|
|
125
|
+
probabilities: { buyer: 0.9, champion: 0.07, unknown: 0.03 },
|
|
126
|
+
};
|
|
127
|
+
const scoreAnswer = {
|
|
128
|
+
type: "score",
|
|
129
|
+
score: 1.7,
|
|
130
|
+
confidence: 0.65,
|
|
131
|
+
legend: { "0": "No timeline", "1": "This quarter", "2": "Blocked now" },
|
|
132
|
+
probabilities: { "0": 0.05, "1": 0.2, "2": 0.75 },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function mixedConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
136
|
+
return {
|
|
137
|
+
model: "jev-1.13.0",
|
|
138
|
+
state: { lead: { name: "Ada", message: "We are blocked on agent workflow tooling now" } },
|
|
139
|
+
questions: {
|
|
140
|
+
fit: {
|
|
141
|
+
type: "noul",
|
|
142
|
+
instructions: "Is this a real engineering team seeking agent workflow automation?",
|
|
143
|
+
criteria: {
|
|
144
|
+
true: "A matching team with a concrete use case",
|
|
145
|
+
false: "Spam, recruitment, unrelated need, or no matching team",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
authority: {
|
|
149
|
+
type: "choice",
|
|
150
|
+
instructions: "What purchasing authority is supported by this lead's message?",
|
|
151
|
+
criteria: {
|
|
152
|
+
buyer: "Can approve the purchase",
|
|
153
|
+
champion: "Influences the decision",
|
|
154
|
+
unknown: "The message does not establish authority",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
urgency: {
|
|
158
|
+
type: "score",
|
|
159
|
+
instructions: "How urgent is the stated need?",
|
|
160
|
+
criteria: ["No timeline", "This quarter", "Blocked now"],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
returns: {
|
|
164
|
+
fit: { type: "noul" },
|
|
165
|
+
authority: { type: "choice" },
|
|
166
|
+
urgency: { type: "score" },
|
|
167
|
+
},
|
|
168
|
+
...overrides,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mixedBody(overrides: Record<string, unknown> = {}) {
|
|
173
|
+
return {
|
|
174
|
+
model: "jev-1.13.0",
|
|
175
|
+
answers: { fit: noulAnswer, authority: choiceAnswer, urgency: scoreAnswer },
|
|
176
|
+
usage: { input_tokens: 400, output_tokens: 80 },
|
|
177
|
+
...overrides,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A single-question config + body, for validation table tests. */
|
|
182
|
+
const authorityOnly = mixedConfig({
|
|
183
|
+
questions: { authority: (mixedConfig().questions as Record<string, unknown>).authority },
|
|
184
|
+
returns: { authority: { type: "choice" } },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function withAnswers(answers: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
|
|
188
|
+
return mixedBody({ answers, ...overrides });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Lifecycle ──────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
beforeAll(async () => {
|
|
194
|
+
await removeDbFiles();
|
|
195
|
+
initDb(TEST_DB_PATH);
|
|
196
|
+
const agent = await createAgent({ name: "jev-test-agent", isLead: true, status: "idle" });
|
|
197
|
+
agentId = agent.id;
|
|
198
|
+
const db = await import("../be/db");
|
|
199
|
+
deps = {
|
|
200
|
+
db,
|
|
201
|
+
eventBus: new InProcessEventBus(),
|
|
202
|
+
interpolate: (template, ctx) => interpolate(template, ctx).result,
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterAll(async () => {
|
|
207
|
+
closeDb();
|
|
208
|
+
await removeDbFiles();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
beforeEach(async () => {
|
|
212
|
+
const client = getDbClient();
|
|
213
|
+
await client.run("DELETE FROM workflow_run_steps");
|
|
214
|
+
await client.run("DELETE FROM workflow_runs");
|
|
215
|
+
await client.run("DELETE FROM workflows");
|
|
216
|
+
await client.run(
|
|
217
|
+
"DELETE FROM swarm_config WHERE key IN ('TYPESAFE_API_KEY', 'LAYA_URL', 'LAYA_API_KEY')",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── Config schema ──────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe("system-one-decision config schema", () => {
|
|
224
|
+
test("accepts a mixed-primitive call and applies defaults", () => {
|
|
225
|
+
const parsed = SystemOneDecisionConfigSchema.parse({
|
|
226
|
+
state: "text",
|
|
227
|
+
questions: { fit: { type: "noul", instructions: "Is it real?" } },
|
|
228
|
+
returns: { fit: { type: "noul" } },
|
|
229
|
+
});
|
|
230
|
+
expect(parsed.provider).toBe("typesafe");
|
|
231
|
+
// The model default belongs to the provider, so the schema leaves it unset.
|
|
232
|
+
expect(parsed.model).toBeUndefined();
|
|
233
|
+
expect(parsed.timeoutMs).toBe(30_000);
|
|
234
|
+
expect(parsed.maxRetries).toBe(2);
|
|
235
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig()).success).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("accepts structured state: string, string array, and JSON object", () => {
|
|
239
|
+
for (const state of ["text", ["a", "b"], { nested: { list: [1, 2, 3] } }]) {
|
|
240
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig({ state })).success).toBe(true);
|
|
241
|
+
}
|
|
242
|
+
for (const state of ["", [], 42, null]) {
|
|
243
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig({ state })).success).toBe(false);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("rejects endpoint, credential, header, and unknown fields", () => {
|
|
248
|
+
for (const extra of [
|
|
249
|
+
{ url: "https://evil.example/v1/systemone" },
|
|
250
|
+
{ endpoint: "https://evil.example" },
|
|
251
|
+
{ apiKey: "tsk_x" },
|
|
252
|
+
{ api_key: "tsk_x" },
|
|
253
|
+
{ headers: { authorization: "Bearer x" } },
|
|
254
|
+
{ outputSchema: { type: "object" } },
|
|
255
|
+
{ fallbackPort: "x" },
|
|
256
|
+
]) {
|
|
257
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig(extra)).success).toBe(false);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("rejects misspelled question fields", () => {
|
|
262
|
+
const config = mixedConfig({
|
|
263
|
+
questions: { fit: { type: "noul", instruction: "typo" } },
|
|
264
|
+
returns: { fit: { type: "noul" } },
|
|
265
|
+
});
|
|
266
|
+
expect(SystemOneDecisionConfigSchema.safeParse(config).success).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("rejects out-of-range timeout and retry counts", () => {
|
|
270
|
+
for (const bad of [
|
|
271
|
+
{ timeoutMs: 999 },
|
|
272
|
+
{ timeoutMs: 300_001 },
|
|
273
|
+
{ maxRetries: 4 },
|
|
274
|
+
{ maxRetries: -1 },
|
|
275
|
+
]) {
|
|
276
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig(bad)).success).toBe(false);
|
|
277
|
+
}
|
|
278
|
+
expect(
|
|
279
|
+
SystemOneDecisionConfigSchema.safeParse(mixedConfig({ timeoutMs: 1_000, maxRetries: 0 }))
|
|
280
|
+
.success,
|
|
281
|
+
).toBe(true);
|
|
282
|
+
expect(
|
|
283
|
+
SystemOneDecisionConfigSchema.safeParse(mixedConfig({ timeoutMs: 300_000, maxRetries: 3 }))
|
|
284
|
+
.success,
|
|
285
|
+
).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("rejects invalid criteria counts", () => {
|
|
289
|
+
const withQuestion = (question: Record<string, unknown>) =>
|
|
290
|
+
mixedConfig({ questions: { q: question }, returns: { q: { type: question.type } } });
|
|
291
|
+
const options = (n: number) =>
|
|
292
|
+
Object.fromEntries(Array.from({ length: n }, (_, i) => [`opt${i}`, `Option ${i}`]));
|
|
293
|
+
const levels = (n: number) => Array.from({ length: n }, (_, i) => `Level ${i}`);
|
|
294
|
+
|
|
295
|
+
const cases: Array<[Record<string, unknown>, boolean]> = [
|
|
296
|
+
[{ type: "choice", instructions: "pick", criteria: options(1) }, false],
|
|
297
|
+
[{ type: "choice", instructions: "pick", criteria: options(2) }, true],
|
|
298
|
+
[{ type: "choice", instructions: "pick", criteria: options(255) }, true],
|
|
299
|
+
[{ type: "choice", instructions: "pick", criteria: options(256) }, false],
|
|
300
|
+
[{ type: "choice", instructions: "pick" }, false],
|
|
301
|
+
[{ type: "score", instructions: "rate", criteria: levels(1) }, false],
|
|
302
|
+
[{ type: "score", instructions: "rate", criteria: levels(2) }, true],
|
|
303
|
+
[{ type: "score", instructions: "rate", criteria: levels(10) }, true],
|
|
304
|
+
[{ type: "score", instructions: "rate", criteria: levels(11) }, false],
|
|
305
|
+
[{ type: "score", instructions: "rate" }, false],
|
|
306
|
+
[{ type: "noul", instructions: "true?", criteria: {} }, false],
|
|
307
|
+
[{ type: "noul", instructions: "true?", criteria: { true: "yes" } }, true],
|
|
308
|
+
[{ type: "noul", instructions: "true?", criteria: ["a", "b"] }, false],
|
|
309
|
+
[{ type: "noul", instructions: " " }, false],
|
|
310
|
+
[{ type: "noul", instructions: null }, false],
|
|
311
|
+
[{ type: "noul", instructions: { rubric: ["a", "b"] } }, true],
|
|
312
|
+
];
|
|
313
|
+
for (const [question, ok] of cases) {
|
|
314
|
+
expect(SystemOneDecisionConfigSchema.safeParse(withQuestion(question)).success).toBe(ok);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("rejects a returns map that disagrees with the questions", () => {
|
|
319
|
+
const base = mixedConfig();
|
|
320
|
+
const missing = mixedConfig({
|
|
321
|
+
returns: { fit: { type: "noul" }, authority: { type: "choice" } },
|
|
322
|
+
});
|
|
323
|
+
const extra = mixedConfig({
|
|
324
|
+
returns: { ...(base.returns as object), ghost: { type: "noul" } },
|
|
325
|
+
});
|
|
326
|
+
const wrongType = mixedConfig({
|
|
327
|
+
returns: {
|
|
328
|
+
fit: { type: "score" },
|
|
329
|
+
authority: { type: "choice" },
|
|
330
|
+
urgency: { type: "score" },
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const noReturns = { ...base };
|
|
334
|
+
delete noReturns.returns;
|
|
335
|
+
|
|
336
|
+
for (const config of [missing, extra, wrongType, noReturns]) {
|
|
337
|
+
expect(SystemOneDecisionConfigSchema.safeParse(config).success).toBe(false);
|
|
338
|
+
}
|
|
339
|
+
const issue = SystemOneDecisionConfigSchema.safeParse(wrongType);
|
|
340
|
+
expect(JSON.stringify(issue.error?.issues)).toContain("returns.fit.type");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("rejects an empty question map and unsafe question ids", () => {
|
|
344
|
+
const question = { type: "noul", instructions: "true?" };
|
|
345
|
+
expect(
|
|
346
|
+
SystemOneDecisionConfigSchema.safeParse(mixedConfig({ questions: {}, returns: {} })).success,
|
|
347
|
+
).toBe(false);
|
|
348
|
+
for (const id of ["1bad", "has space", "a.b", "", "constructor", "prototype", "toString"]) {
|
|
349
|
+
const config = mixedConfig({
|
|
350
|
+
questions: { [id]: question },
|
|
351
|
+
returns: { [id]: { type: "noul" } },
|
|
352
|
+
});
|
|
353
|
+
expect(SystemOneDecisionConfigSchema.safeParse(config).success).toBe(false);
|
|
354
|
+
}
|
|
355
|
+
const proto = JSON.parse(
|
|
356
|
+
`{"state":"x","questions":{"__proto__":{"type":"noul","instructions":"t"}},"returns":{"__proto__":{"type":"noul"}}}`,
|
|
357
|
+
);
|
|
358
|
+
expect(SystemOneDecisionConfigSchema.safeParse(proto).success).toBe(false);
|
|
359
|
+
for (const id of ["fit", "_private", "a-b_c9", "Q1"]) {
|
|
360
|
+
const config = mixedConfig({
|
|
361
|
+
questions: { [id]: question },
|
|
362
|
+
returns: { [id]: { type: "noul" } },
|
|
363
|
+
});
|
|
364
|
+
expect(SystemOneDecisionConfigSchema.safeParse(config).success).toBe(true);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ─── Request + success path ─────────────────────────────────
|
|
370
|
+
|
|
371
|
+
describe("SystemOneDecisionExecutor request and output", () => {
|
|
372
|
+
test("sends one state and the whole question map to the fixed TypeSafe endpoint", async () => {
|
|
373
|
+
const { executor, calls } = makeExecutor([jsonResponse(mixedBody())]);
|
|
374
|
+
const config = mixedConfig();
|
|
375
|
+
const result = await runSystemOne(executor, config);
|
|
376
|
+
|
|
377
|
+
expect(result.status).toBe("success");
|
|
378
|
+
expect(calls).toHaveLength(1);
|
|
379
|
+
const call = calls[0]!;
|
|
380
|
+
expect(call.url).toBe("https://api.typesafe.ai/v1/systemone");
|
|
381
|
+
expect(call.url).toBe(SYSTEM_ONE_ENDPOINT);
|
|
382
|
+
expect(call.init.method).toBe("POST");
|
|
383
|
+
expect(call.init.redirect).toBe("manual");
|
|
384
|
+
expect(call.init.signal).toBeInstanceOf(AbortSignal);
|
|
385
|
+
const headers = call.init.headers as Record<string, string>;
|
|
386
|
+
expect(headers.authorization).toBe(`Bearer ${API_KEY}`);
|
|
387
|
+
expect(headers["content-type"]).toBe("application/json");
|
|
388
|
+
// `returns` is our own contract and must not be sent to the provider.
|
|
389
|
+
expect(Object.keys(call.body).sort()).toEqual(["model", "questions", "state"]);
|
|
390
|
+
expect(call.body.model).toBe("jev-1.13.0");
|
|
391
|
+
expect(call.body.state).toEqual(config.state as object);
|
|
392
|
+
expect(call.body.questions).toEqual(config.questions as object);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("defaults the model alias and preserves structured state types", async () => {
|
|
396
|
+
const { executor, calls } = makeExecutor([jsonResponse(mixedBody({ model: "jev-1.13.0" }))]);
|
|
397
|
+
const state = { deal: { size: 12000, tags: ["a", "b"], open: true, owner: null } };
|
|
398
|
+
const config = mixedConfig({ state });
|
|
399
|
+
delete config.model;
|
|
400
|
+
const result = await runSystemOne(executor, config);
|
|
401
|
+
|
|
402
|
+
expect(result.status).toBe("success");
|
|
403
|
+
expect(calls[0]?.body.model).toBe("jev-latest");
|
|
404
|
+
expect(calls[0]?.body.state).toEqual(state);
|
|
405
|
+
// The persisted model is the one the API reports, not the requested alias.
|
|
406
|
+
expect(result.output?.model).toBe("jev-1.13.0");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("returns all three primitives with distributions, confidence, and usage", async () => {
|
|
410
|
+
const { executor } = makeExecutor([
|
|
411
|
+
jsonResponse(mixedBody(), 200, { "x-request-id": "req_abc-123" }),
|
|
412
|
+
]);
|
|
413
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
414
|
+
|
|
415
|
+
expect(result.status).toBe("success");
|
|
416
|
+
expect(result.output).toEqual({
|
|
417
|
+
model: "jev-1.13.0",
|
|
418
|
+
answers: { fit: noulAnswer, authority: choiceAnswer, urgency: scoreAnswer },
|
|
419
|
+
usage: { input_tokens: 400, output_tokens: 80 },
|
|
420
|
+
requestId: "req_abc-123",
|
|
421
|
+
});
|
|
422
|
+
expect(SystemOneDecisionOutputSchema.safeParse(result.output).success).toBe(true);
|
|
423
|
+
expect(result.nextPort).toBeUndefined();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("each primitive works on its own", async () => {
|
|
427
|
+
const cases: Array<[string, unknown]> = [
|
|
428
|
+
["fit", noulAnswer],
|
|
429
|
+
["authority", choiceAnswer],
|
|
430
|
+
["urgency", scoreAnswer],
|
|
431
|
+
];
|
|
432
|
+
for (const [id, answer] of cases) {
|
|
433
|
+
const full = mixedConfig();
|
|
434
|
+
const config = mixedConfig({
|
|
435
|
+
questions: { [id]: (full.questions as Record<string, unknown>)[id] },
|
|
436
|
+
returns: { [id]: (full.returns as Record<string, unknown>)[id] },
|
|
437
|
+
});
|
|
438
|
+
const { executor, calls } = makeExecutor([jsonResponse(withAnswers({ [id]: answer }))]);
|
|
439
|
+
const result = await runSystemOne(executor, config);
|
|
440
|
+
expect(result.status).toBe("success");
|
|
441
|
+
expect(Object.keys(result.output?.answers ?? {})).toEqual([id]);
|
|
442
|
+
expect(Object.keys(calls[0]?.body.questions as object)).toEqual([id]);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("a Noul answer stays {type, noul}: no invented confidence or provider extras", async () => {
|
|
447
|
+
const noisy = { type: "noul", noul: 0.5, confidence: 0.99, rationale: "because", extra: 1 };
|
|
448
|
+
const { executor } = makeExecutor([jsonResponse(withAnswers({ fit: noisy }))]);
|
|
449
|
+
const fitOnly = mixedConfig({
|
|
450
|
+
questions: { fit: (mixedConfig().questions as Record<string, unknown>).fit },
|
|
451
|
+
returns: { fit: { type: "noul" } },
|
|
452
|
+
});
|
|
453
|
+
const result = await runSystemOne(executor, fitOnly);
|
|
454
|
+
|
|
455
|
+
expect(result.status).toBe("success");
|
|
456
|
+
expect(result.output?.answers.fit).toEqual({ type: "noul", noul: 0.5 });
|
|
457
|
+
expect(JSON.stringify(result.output)).not.toContain("confidence");
|
|
458
|
+
expect(JSON.stringify(result.output)).not.toContain("rationale");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a low-confidence answer is a successful evaluation", async () => {
|
|
462
|
+
const lowConfidence = { ...choiceAnswer, confidence: 0.05 };
|
|
463
|
+
const { executor } = makeExecutor([jsonResponse(withAnswers({ authority: lowConfidence }))]);
|
|
464
|
+
const result = await runSystemOne(executor, authorityOnly);
|
|
465
|
+
expect(result.status).toBe("success");
|
|
466
|
+
expect((result.output?.answers.authority as { confidence: number }).confidence).toBe(0.05);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// ─── Answer validation ──────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
describe("SystemOneDecisionExecutor answer validation", () => {
|
|
473
|
+
async function failsWith(body: unknown, expected: string, config = mixedConfig()) {
|
|
474
|
+
const { executor } = makeExecutor([jsonResponse(body)]);
|
|
475
|
+
const result = await runSystemOne(executor, config);
|
|
476
|
+
expect(result.status).toBe("failed");
|
|
477
|
+
expect(result.output).toBeUndefined();
|
|
478
|
+
expect(result.error).toContain(expected);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
test("rejects a missing or extra answer", async () => {
|
|
482
|
+
await failsWith(
|
|
483
|
+
withAnswers({ fit: noulAnswer, authority: choiceAnswer }),
|
|
484
|
+
'question "urgency"',
|
|
485
|
+
);
|
|
486
|
+
await failsWith(
|
|
487
|
+
withAnswers({
|
|
488
|
+
fit: noulAnswer,
|
|
489
|
+
authority: choiceAnswer,
|
|
490
|
+
urgency: scoreAnswer,
|
|
491
|
+
ghost: noulAnswer,
|
|
492
|
+
}),
|
|
493
|
+
"undeclared",
|
|
494
|
+
);
|
|
495
|
+
await failsWith(withAnswers({}), "question", mixedConfig());
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("rejects an answer of the wrong primitive type", async () => {
|
|
499
|
+
await failsWith(
|
|
500
|
+
withAnswers({ fit: choiceAnswer, authority: choiceAnswer, urgency: scoreAnswer }),
|
|
501
|
+
'declares "noul"',
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("rejects a Noul outside [0,1] or non-numeric", async () => {
|
|
506
|
+
for (const noul of [1.2, -0.1, "0.5", null, Number.NaN]) {
|
|
507
|
+
await failsWith(
|
|
508
|
+
withAnswers({ fit: { type: "noul", noul }, authority: choiceAnswer, urgency: scoreAnswer }),
|
|
509
|
+
"answer",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("rejects an unknown choice, bad probability keys, and a distribution that does not sum to 1", async () => {
|
|
515
|
+
await failsWith(
|
|
516
|
+
withAnswers({ authority: { ...choiceAnswer, choice: "vip" } }),
|
|
517
|
+
"not one of the declared options",
|
|
518
|
+
authorityOnly,
|
|
519
|
+
);
|
|
520
|
+
await failsWith(
|
|
521
|
+
withAnswers({
|
|
522
|
+
authority: { ...choiceAnswer, probabilities: { buyer: 0.9, champion: 0.1 } },
|
|
523
|
+
}),
|
|
524
|
+
"probabilities keys",
|
|
525
|
+
authorityOnly,
|
|
526
|
+
);
|
|
527
|
+
await failsWith(
|
|
528
|
+
withAnswers({
|
|
529
|
+
authority: {
|
|
530
|
+
...choiceAnswer,
|
|
531
|
+
probabilities: { buyer: 0.9, champion: 0.07, unknown: 0.03, vip: 0 },
|
|
532
|
+
},
|
|
533
|
+
}),
|
|
534
|
+
"probabilities keys",
|
|
535
|
+
authorityOnly,
|
|
536
|
+
);
|
|
537
|
+
await failsWith(
|
|
538
|
+
withAnswers({
|
|
539
|
+
authority: { ...choiceAnswer, probabilities: { buyer: 0.5, champion: 0.1, unknown: 0.1 } },
|
|
540
|
+
}),
|
|
541
|
+
"must sum to 1",
|
|
542
|
+
authorityOnly,
|
|
543
|
+
);
|
|
544
|
+
await failsWith(
|
|
545
|
+
withAnswers({ authority: { ...choiceAnswer, confidence: 1.5 } }),
|
|
546
|
+
"confidence",
|
|
547
|
+
authorityOnly,
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("accepts a distribution within the rounding tolerance and never normalizes it", async () => {
|
|
552
|
+
const probabilities = { buyer: 0.905, champion: 0.075, unknown: 0.03 };
|
|
553
|
+
const { executor } = makeExecutor([
|
|
554
|
+
jsonResponse(withAnswers({ authority: { ...choiceAnswer, probabilities } })),
|
|
555
|
+
]);
|
|
556
|
+
const result = await runSystemOne(executor, authorityOnly);
|
|
557
|
+
expect(result.status).toBe("success");
|
|
558
|
+
expect((result.output?.answers.authority as { probabilities: object }).probabilities).toEqual(
|
|
559
|
+
probabilities,
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("rejects an out-of-range score, a missing legend level, and bad probabilities", async () => {
|
|
564
|
+
const scoreOnly = mixedConfig({
|
|
565
|
+
questions: { urgency: (mixedConfig().questions as Record<string, unknown>).urgency },
|
|
566
|
+
returns: { urgency: { type: "score" } },
|
|
567
|
+
});
|
|
568
|
+
for (const score of [-0.1, 2.01, "1", Number.NaN]) {
|
|
569
|
+
await failsWith(withAnswers({ urgency: { ...scoreAnswer, score } }), "score", scoreOnly);
|
|
570
|
+
}
|
|
571
|
+
await failsWith(
|
|
572
|
+
withAnswers({
|
|
573
|
+
urgency: { ...scoreAnswer, legend: { "0": "No timeline", "1": "This quarter" } },
|
|
574
|
+
}),
|
|
575
|
+
"legend is missing level 2",
|
|
576
|
+
scoreOnly,
|
|
577
|
+
);
|
|
578
|
+
await failsWith(
|
|
579
|
+
withAnswers({ urgency: { ...scoreAnswer, probabilities: { "0": 0.5, "1": 0.5 } } }),
|
|
580
|
+
"probabilities keys",
|
|
581
|
+
scoreOnly,
|
|
582
|
+
);
|
|
583
|
+
const boundary = { ...scoreAnswer, score: 2 };
|
|
584
|
+
const { executor } = makeExecutor([jsonResponse(withAnswers({ urgency: boundary }))]);
|
|
585
|
+
expect((await runSystemOne(executor, scoreOnly)).status).toBe("success");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("rejects malformed success bodies and missing usage or model", async () => {
|
|
589
|
+
await failsWith(mixedBody({ usage: undefined }), "usage");
|
|
590
|
+
await failsWith(mixedBody({ usage: { input_tokens: 1 } }), "usage");
|
|
591
|
+
await failsWith(mixedBody({ usage: { input_tokens: -1, output_tokens: 0 } }), "usage");
|
|
592
|
+
await failsWith(mixedBody({ usage: { input_tokens: 1.5, output_tokens: 0 } }), "usage");
|
|
593
|
+
await failsWith(mixedBody({ model: undefined }), "model");
|
|
594
|
+
await failsWith(mixedBody({ model: "" }), "model");
|
|
595
|
+
await failsWith(mixedBody({ answers: undefined }), "answers");
|
|
596
|
+
await failsWith(mixedBody({ answers: [] }), "answers");
|
|
597
|
+
await failsWith([mixedBody()], "JSON object");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("rejects a success body that is not JSON", async () => {
|
|
601
|
+
const { executor, calls } = makeExecutor([
|
|
602
|
+
new Response("<html>gateway</html>", { status: 200 }),
|
|
603
|
+
]);
|
|
604
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
605
|
+
expect(result.status).toBe("failed");
|
|
606
|
+
expect(result.error).toContain("not valid JSON");
|
|
607
|
+
// A bad success body is never retried: it would repeat a paid call.
|
|
608
|
+
expect(calls).toHaveLength(1);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("a validation failure is not retried", async () => {
|
|
612
|
+
const { executor, calls, sleeps } = makeExecutor([
|
|
613
|
+
jsonResponse(mixedBody({ usage: undefined })),
|
|
614
|
+
]);
|
|
615
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
616
|
+
expect(result.status).toBe("failed");
|
|
617
|
+
expect(calls).toHaveLength(1);
|
|
618
|
+
expect(sleeps).toEqual([]);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ─── Transport ──────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
describe("SystemOneDecisionExecutor transport", () => {
|
|
625
|
+
test("401 and 422 make exactly one attempt", async () => {
|
|
626
|
+
for (const status of [401, 422]) {
|
|
627
|
+
const { executor, calls, sleeps } = makeExecutor([
|
|
628
|
+
jsonResponse({ error: { code: "invalid_request", message: "state: bad" } }, status),
|
|
629
|
+
]);
|
|
630
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
631
|
+
expect(result.status).toBe("failed");
|
|
632
|
+
expect(result.error).toContain(`HTTP ${status}`);
|
|
633
|
+
expect(calls).toHaveLength(1);
|
|
634
|
+
expect(sleeps).toEqual([]);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("other 4xx statuses are not retried", async () => {
|
|
639
|
+
for (const status of [400, 403, 404, 413]) {
|
|
640
|
+
const { executor, calls } = makeExecutor([jsonResponse({}, status)]);
|
|
641
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
642
|
+
expect(result.status).toBe("failed");
|
|
643
|
+
expect(calls).toHaveLength(1);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("error diagnostics carry status, code, and request id, but not the provider message or state", async () => {
|
|
648
|
+
const { executor } = makeExecutor([
|
|
649
|
+
jsonResponse(
|
|
650
|
+
{ error: { code: "invalid_request", message: "state contained: We are blocked on agent" } },
|
|
651
|
+
422,
|
|
652
|
+
{ "x-request-id": "req_422" },
|
|
653
|
+
),
|
|
654
|
+
]);
|
|
655
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
656
|
+
expect(result.error).toBe(
|
|
657
|
+
"SystemOne API returned HTTP 422 (invalid_request) [request req_422]",
|
|
658
|
+
);
|
|
659
|
+
expect(result.error).not.toContain("blocked");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("429 then success retries once after a backoff", async () => {
|
|
663
|
+
const { executor, calls, sleeps } = makeExecutor([
|
|
664
|
+
jsonResponse({}, 429),
|
|
665
|
+
jsonResponse(mixedBody()),
|
|
666
|
+
]);
|
|
667
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
668
|
+
expect(result.status).toBe("success");
|
|
669
|
+
expect(calls).toHaveLength(2);
|
|
670
|
+
expect(sleeps).toEqual([500]);
|
|
671
|
+
// A retry repeats the same request.
|
|
672
|
+
expect(calls[1]?.body).toEqual(calls[0]?.body);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("529 exhausts maxRetries with exponential, jittered waits", async () => {
|
|
676
|
+
const { executor, calls, sleeps } = makeExecutor([jsonResponse({}, 529)], { random: () => 1 });
|
|
677
|
+
const result = await runSystemOne(executor, mixedConfig({ maxRetries: 2 }));
|
|
678
|
+
expect(result.status).toBe("failed");
|
|
679
|
+
expect(result.error).toContain("HTTP 529");
|
|
680
|
+
expect(result.error).toContain("after 3 attempts");
|
|
681
|
+
expect(calls).toHaveLength(3);
|
|
682
|
+
expect(sleeps).toEqual([500, 1000]);
|
|
683
|
+
|
|
684
|
+
const lower = makeExecutor([jsonResponse({}, 529)], { random: () => 0 });
|
|
685
|
+
await runSystemOne(lower.executor, mixedConfig({ maxRetries: 2 }));
|
|
686
|
+
expect(lower.sleeps).toEqual([250, 500]);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("maxRetries 0 makes a single attempt", async () => {
|
|
690
|
+
const { executor, calls, sleeps } = makeExecutor([jsonResponse({}, 429)]);
|
|
691
|
+
const result = await runSystemOne(executor, mixedConfig({ maxRetries: 0 }));
|
|
692
|
+
expect(result.status).toBe("failed");
|
|
693
|
+
expect(calls).toHaveLength(1);
|
|
694
|
+
expect(sleeps).toEqual([]);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("retries 408, 5xx, and connection errors", async () => {
|
|
698
|
+
for (const first of [
|
|
699
|
+
jsonResponse({}, 408),
|
|
700
|
+
jsonResponse({}, 503),
|
|
701
|
+
new TypeError("fetch failed"),
|
|
702
|
+
]) {
|
|
703
|
+
const { executor, calls } = makeExecutor([first, jsonResponse(mixedBody())]);
|
|
704
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
705
|
+
expect(result.status).toBe("success");
|
|
706
|
+
expect(calls).toHaveLength(2);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("a connection error reports only a sanitized code", async () => {
|
|
711
|
+
const error = Object.assign(new Error(`connect failed for Bearer ${API_KEY}`), {
|
|
712
|
+
code: "ECONNRESET",
|
|
713
|
+
});
|
|
714
|
+
const { executor } = makeExecutor([error], { random: () => 1 });
|
|
715
|
+
const result = await runSystemOne(executor, mixedConfig({ maxRetries: 0 }));
|
|
716
|
+
expect(result.error).toBe("SystemOne request failed: network error (ECONNRESET)");
|
|
717
|
+
expect(result.error).not.toContain(API_KEY);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("Retry-After is honored: the wait is never shorter than the header", async () => {
|
|
721
|
+
const { executor, sleeps } = makeExecutor([
|
|
722
|
+
jsonResponse({}, 429, { "retry-after": "2" }),
|
|
723
|
+
jsonResponse(mixedBody()),
|
|
724
|
+
]);
|
|
725
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
726
|
+
expect(result.status).toBe("success");
|
|
727
|
+
expect(sleeps).toEqual([2000]);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("Retry-After accepts an HTTP date", async () => {
|
|
731
|
+
const now = Date.UTC(2026, 8, 29, 12, 0, 0);
|
|
732
|
+
const { executor, sleeps } = makeExecutor(
|
|
733
|
+
[
|
|
734
|
+
jsonResponse({}, 429, { "retry-after": new Date(now + 3_000).toUTCString() }),
|
|
735
|
+
jsonResponse(mixedBody()),
|
|
736
|
+
],
|
|
737
|
+
{ now: () => now },
|
|
738
|
+
);
|
|
739
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
740
|
+
expect(result.status).toBe("success");
|
|
741
|
+
expect(sleeps).toEqual([3000]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("a Retry-After beyond the time budget fails instead of retrying early", async () => {
|
|
745
|
+
const { executor, calls, sleeps } = makeExecutor([
|
|
746
|
+
jsonResponse({}, 429, { "retry-after": "120" }),
|
|
747
|
+
]);
|
|
748
|
+
const result = await runSystemOne(executor, mixedConfig({ timeoutMs: 30_000 }));
|
|
749
|
+
expect(result.status).toBe("failed");
|
|
750
|
+
expect(result.error).toContain("HTTP 429");
|
|
751
|
+
expect(result.error).toContain("does not fit");
|
|
752
|
+
expect(calls).toHaveLength(1);
|
|
753
|
+
expect(sleeps).toEqual([]);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("redirects are refused, not followed", async () => {
|
|
757
|
+
const { executor, calls } = makeExecutor([
|
|
758
|
+
new Response(null, { status: 302, headers: { location: "https://evil.example/steal" } }),
|
|
759
|
+
]);
|
|
760
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
761
|
+
expect(result.status).toBe("failed");
|
|
762
|
+
expect(result.error).toContain("redirect");
|
|
763
|
+
expect(calls).toHaveLength(1);
|
|
764
|
+
expect(calls.every((call) => call.url === SYSTEM_ONE_ENDPOINT)).toBe(true);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("timeout aborts the in-flight request", async () => {
|
|
768
|
+
let aborted = false;
|
|
769
|
+
const { executor, calls } = makeExecutor([
|
|
770
|
+
(init) =>
|
|
771
|
+
new Promise<Response>((_, reject) => {
|
|
772
|
+
init.signal?.addEventListener("abort", () => {
|
|
773
|
+
aborted = true;
|
|
774
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
775
|
+
});
|
|
776
|
+
}),
|
|
777
|
+
]);
|
|
778
|
+
const started = Date.now();
|
|
779
|
+
const result = await runSystemOne(executor, mixedConfig({ timeoutMs: 1_000 }));
|
|
780
|
+
expect(result.status).toBe("failed");
|
|
781
|
+
expect(result.error).toContain("1000ms time budget");
|
|
782
|
+
expect(aborted).toBe(true);
|
|
783
|
+
// The executor reports before the engine watchdog (timeoutMs) would fire.
|
|
784
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
785
|
+
expect(calls).toHaveLength(1);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("timeout aborts a backoff wait", async () => {
|
|
789
|
+
const { executor, calls } = makeExecutor([jsonResponse({}, 429, { "retry-after": "0.5" })], {
|
|
790
|
+
sleep: (_ms, signal) =>
|
|
791
|
+
new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve())),
|
|
792
|
+
});
|
|
793
|
+
const started = Date.now();
|
|
794
|
+
const result = await runSystemOne(executor, mixedConfig({ timeoutMs: 1_000 }));
|
|
795
|
+
expect(result.status).toBe("failed");
|
|
796
|
+
expect(result.error).toContain("time budget");
|
|
797
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
798
|
+
expect(calls).toHaveLength(1);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// ─── Credentials ────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
describe("SystemOneDecisionExecutor credentials", () => {
|
|
805
|
+
test("reads only the global TYPESAFE_API_KEY swarm config row", async () => {
|
|
806
|
+
await upsertSwarmConfig({
|
|
807
|
+
scope: "global",
|
|
808
|
+
key: "TYPESAFE_API_KEY",
|
|
809
|
+
value: API_KEY,
|
|
810
|
+
isSecret: true,
|
|
811
|
+
});
|
|
812
|
+
await upsertSwarmConfig({
|
|
813
|
+
scope: "agent",
|
|
814
|
+
scopeId: agentId,
|
|
815
|
+
key: "TYPESAFE_API_KEY",
|
|
816
|
+
value: "agent-scoped-key-must-not-be-used",
|
|
817
|
+
});
|
|
818
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
819
|
+
const executor = new SystemOneDecisionExecutor(deps, { fetch: transport });
|
|
820
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
821
|
+
|
|
822
|
+
expect(result.status).toBe("success");
|
|
823
|
+
expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe(
|
|
824
|
+
`Bearer ${API_KEY}`,
|
|
825
|
+
);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("fails without a request when the key is absent, blank, or malformed", async () => {
|
|
829
|
+
for (const lookup of [
|
|
830
|
+
async () => null,
|
|
831
|
+
async () => undefined,
|
|
832
|
+
async () => " ",
|
|
833
|
+
async () => "bad\nkey",
|
|
834
|
+
]) {
|
|
835
|
+
const { executor, calls } = makeExecutor([jsonResponse(mixedBody())], { getApiKey: lookup });
|
|
836
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
837
|
+
expect(result.status).toBe("failed");
|
|
838
|
+
expect(result.error).toContain("TYPESAFE_API_KEY");
|
|
839
|
+
expect(calls).toHaveLength(0);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
843
|
+
const noRow = await runSystemOne(
|
|
844
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport }),
|
|
845
|
+
mixedConfig(),
|
|
846
|
+
);
|
|
847
|
+
expect(noRow.status).toBe("failed");
|
|
848
|
+
expect(noRow.error).toContain("not configured");
|
|
849
|
+
expect(calls).toHaveLength(0);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("a lookup failure does not leak its cause", async () => {
|
|
853
|
+
const { executor } = makeExecutor([jsonResponse(mixedBody())], {
|
|
854
|
+
getApiKey: async () => {
|
|
855
|
+
throw new Error("Failed to decrypt config 'TYPESAFE_API_KEY' (id=row-1)");
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
const result = await runSystemOne(executor, mixedConfig());
|
|
859
|
+
expect(result.status).toBe("failed");
|
|
860
|
+
expect(result.error).toBe("Could not read TYPESAFE_API_KEY from swarm config");
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test("the key never appears in outputs or errors, even if the provider echoes it", async () => {
|
|
864
|
+
const echoed = makeExecutor(
|
|
865
|
+
[jsonResponse({ error: { code: API_KEY, message: API_KEY } }, 500)],
|
|
866
|
+
{
|
|
867
|
+
random: () => 1,
|
|
868
|
+
},
|
|
869
|
+
);
|
|
870
|
+
const failed = await runSystemOne(echoed.executor, mixedConfig({ maxRetries: 1 }));
|
|
871
|
+
expect(failed.status).toBe("failed");
|
|
872
|
+
expect(JSON.stringify(failed)).not.toContain(API_KEY);
|
|
873
|
+
|
|
874
|
+
const ok = makeExecutor([jsonResponse(mixedBody())]);
|
|
875
|
+
const succeeded = await runSystemOne(ok.executor, mixedConfig());
|
|
876
|
+
expect(JSON.stringify(succeeded)).not.toContain(API_KEY);
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// ─── Registry, discovery, authoring ─────────────────────────
|
|
881
|
+
|
|
882
|
+
describe("system-one-decision registration and authoring", () => {
|
|
883
|
+
test("registers as an async executor (it can wait for a reviewer) and derives discovery JSON Schemas", () => {
|
|
884
|
+
const registry = createExecutorRegistry(deps);
|
|
885
|
+
expect(registry.has("system-one-decision")).toBe(true);
|
|
886
|
+
const info = registry.describe("system-one-decision");
|
|
887
|
+
expect(info.type).toBe("system-one-decision");
|
|
888
|
+
expect(info.mode).toBe("async");
|
|
889
|
+
// The refinements and the discriminated union must survive JSON Schema conversion.
|
|
890
|
+
const config = info.configSchema as { required?: string[] };
|
|
891
|
+
expect(config.required).toEqual(expect.arrayContaining(["state", "questions", "returns"]));
|
|
892
|
+
const output = info.outputSchema as { required?: string[] };
|
|
893
|
+
expect(output.required).toEqual(expect.arrayContaining(["model", "answers", "usage"]));
|
|
894
|
+
expect(() => registry.describeAll()).not.toThrow();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const qualifyNode = (overrides: Partial<WorkflowNode> = {}): WorkflowNode => ({
|
|
898
|
+
id: "qualify",
|
|
899
|
+
type: "system-one-decision",
|
|
900
|
+
inputs: { lead: "trigger.lead" },
|
|
901
|
+
config: mixedConfig({ state: "{{lead}}" }),
|
|
902
|
+
...overrides,
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const validate = (node: WorkflowNode) =>
|
|
906
|
+
validateDefinition({ nodes: [node] }, createExecutorRegistry(deps));
|
|
907
|
+
|
|
908
|
+
test("accepts a node whose state and descriptions are interpolated", () => {
|
|
909
|
+
const node = qualifyNode({
|
|
910
|
+
config: mixedConfig({
|
|
911
|
+
state: "{{lead}}",
|
|
912
|
+
questions: {
|
|
913
|
+
fit: {
|
|
914
|
+
type: "noul",
|
|
915
|
+
instructions: "Is {{lead.company}} a fit?",
|
|
916
|
+
criteria: "{{criteria}}",
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
returns: { fit: { type: "noul" } },
|
|
920
|
+
}),
|
|
921
|
+
});
|
|
922
|
+
expect(validate(node)).toEqual({ valid: true, errors: [] });
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("reports a static returns/questions disagreement at authoring time", () => {
|
|
926
|
+
const node = qualifyNode({
|
|
927
|
+
config: mixedConfig({
|
|
928
|
+
returns: {
|
|
929
|
+
fit: { type: "score" },
|
|
930
|
+
authority: { type: "choice" },
|
|
931
|
+
urgency: { type: "score" },
|
|
932
|
+
},
|
|
933
|
+
}),
|
|
934
|
+
});
|
|
935
|
+
const result = validate(node);
|
|
936
|
+
expect(result.valid).toBe(false);
|
|
937
|
+
expect(result.errors.join("\n")).toContain("returns.fit.type");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test("requires questions and returns to be static objects", () => {
|
|
941
|
+
const result = validate(qualifyNode({ config: mixedConfig({ questions: "{{qs}}" }) }));
|
|
942
|
+
expect(result.valid).toBe(false);
|
|
943
|
+
expect(result.errors.join("\n")).toContain("config.questions must be an object");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("rejects an engine retry policy or a validation retry on a system-one-decision node", () => {
|
|
947
|
+
const withRetry = validate(
|
|
948
|
+
qualifyNode({ retry: { maxRetries: 2, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 } }),
|
|
949
|
+
);
|
|
950
|
+
expect(withRetry.valid).toBe(false);
|
|
951
|
+
expect(withRetry.errors.join("\n")).toContain("retry.maxRetries > 0");
|
|
952
|
+
|
|
953
|
+
const withValidationRetry = validate(
|
|
954
|
+
qualifyNode({
|
|
955
|
+
validation: {
|
|
956
|
+
executor: "validate",
|
|
957
|
+
config: {},
|
|
958
|
+
mustPass: true,
|
|
959
|
+
retry: { maxRetries: 1, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 },
|
|
960
|
+
},
|
|
961
|
+
}),
|
|
962
|
+
);
|
|
963
|
+
expect(withValidationRetry.valid).toBe(false);
|
|
964
|
+
expect(withValidationRetry.errors.join("\n")).toContain("validation.retry.maxRetries > 0");
|
|
965
|
+
|
|
966
|
+
const zeroRetries = validate(
|
|
967
|
+
qualifyNode({ retry: { maxRetries: 0, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 } }),
|
|
968
|
+
);
|
|
969
|
+
expect(zeroRetries).toEqual({ valid: true, errors: [] });
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("the retry rule applies to system-one-decision nodes only", () => {
|
|
973
|
+
const node: WorkflowNode = {
|
|
974
|
+
id: "x",
|
|
975
|
+
type: "property-match",
|
|
976
|
+
config: { conditions: [{ field: "a", op: "exists" }] },
|
|
977
|
+
retry: { maxRetries: 3, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 },
|
|
978
|
+
};
|
|
979
|
+
expect(validate(node)).toEqual({ valid: true, errors: [] });
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
// ─── Interpolation ──────────────────────────────────────────
|
|
984
|
+
|
|
985
|
+
describe("system-one-decision interpolation", () => {
|
|
986
|
+
const node = (config: Record<string, unknown>) => ({
|
|
987
|
+
type: "system-one-decision",
|
|
988
|
+
config,
|
|
989
|
+
inputs: {},
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("a whole-token state keeps its JSON type; mixed text stays a string", () => {
|
|
993
|
+
const lead = { name: "Ada", tags: ["a", "b"], size: 12 };
|
|
994
|
+
const whole = interpolateNodeConfig(node({ state: "{{lead}}" }), { lead });
|
|
995
|
+
expect((whole.value as { state: unknown }).state).toEqual(lead);
|
|
996
|
+
expect(whole.strictUnresolved).toEqual([]);
|
|
997
|
+
|
|
998
|
+
const mixed = interpolateNodeConfig(node({ state: "Lead: {{lead.name}}" }), { lead });
|
|
999
|
+
expect((mixed.value as { state: unknown }).state).toBe("Lead: Ada");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("question descriptions can carry an object token", () => {
|
|
1003
|
+
const rubric = { buyer: "Approves spend", champion: "Influences" };
|
|
1004
|
+
const result = interpolateNodeConfig(
|
|
1005
|
+
node({
|
|
1006
|
+
state: "x",
|
|
1007
|
+
questions: {
|
|
1008
|
+
authority: { type: "choice", instructions: "Pick for {{name}}", criteria: "{{rubric}}" },
|
|
1009
|
+
},
|
|
1010
|
+
}),
|
|
1011
|
+
{ rubric, name: "Ada" },
|
|
1012
|
+
);
|
|
1013
|
+
const questions = (
|
|
1014
|
+
result.value as { questions: Record<string, { criteria: unknown; instructions: string }> }
|
|
1015
|
+
).questions;
|
|
1016
|
+
expect(questions.authority?.criteria).toEqual(rubric);
|
|
1017
|
+
expect(questions.authority?.instructions).toBe("Pick for Ada");
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("resolved content is not interpolated a second time", () => {
|
|
1021
|
+
const lead = { message: "Please call {{trigger.secret}} now" };
|
|
1022
|
+
const result = interpolateNodeConfig(node({ state: "{{lead}}" }), {
|
|
1023
|
+
lead,
|
|
1024
|
+
trigger: { secret: "LEAK" },
|
|
1025
|
+
});
|
|
1026
|
+
expect(JSON.stringify((result.value as { state: unknown }).state)).toContain(
|
|
1027
|
+
"{{trigger.secret}}",
|
|
1028
|
+
);
|
|
1029
|
+
expect(JSON.stringify(result.value)).not.toContain("LEAK");
|
|
1030
|
+
|
|
1031
|
+
const mixed = interpolateNodeConfig(node({ state: "Msg: {{lead.message}}" }), {
|
|
1032
|
+
lead,
|
|
1033
|
+
trigger: { secret: "LEAK" },
|
|
1034
|
+
});
|
|
1035
|
+
expect((mixed.value as { state: string }).state).toBe(
|
|
1036
|
+
"Msg: Please call {{trigger.secret}} now",
|
|
1037
|
+
);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
test("unresolved tokens anywhere in a system-one-decision config are strict", () => {
|
|
1041
|
+
const result = interpolateNodeConfig(
|
|
1042
|
+
node({
|
|
1043
|
+
model: "{{model}}",
|
|
1044
|
+
state: "{{lead}}",
|
|
1045
|
+
questions: { q: { type: "noul", instructions: "About {{lead.company}}" } },
|
|
1046
|
+
}),
|
|
1047
|
+
{ lead: { name: "Ada" } },
|
|
1048
|
+
);
|
|
1049
|
+
expect(result.strictUnresolved?.sort()).toEqual(["lead.company", "model"]);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test("other node types do not get strict tokens", () => {
|
|
1053
|
+
const result = interpolateNodeConfig(
|
|
1054
|
+
{ type: "raw-llm", config: { prompt: "{{missing}}" } },
|
|
1055
|
+
{},
|
|
1056
|
+
);
|
|
1057
|
+
expect(result.strictUnresolved).toBeUndefined();
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// ─── Engine integration ─────────────────────────────────────
|
|
1062
|
+
|
|
1063
|
+
class MarkerExecutor extends BaseExecutor<
|
|
1064
|
+
typeof MarkerExecutor.schema,
|
|
1065
|
+
typeof MarkerExecutor.outSchema
|
|
1066
|
+
> {
|
|
1067
|
+
static readonly schema = z.object({ label: z.string() });
|
|
1068
|
+
static readonly outSchema = z.object({ label: z.string() });
|
|
1069
|
+
readonly type = "marker";
|
|
1070
|
+
readonly mode = "instant" as const;
|
|
1071
|
+
readonly configSchema = MarkerExecutor.schema;
|
|
1072
|
+
readonly outputSchema = MarkerExecutor.outSchema;
|
|
1073
|
+
protected async execute(
|
|
1074
|
+
config: z.infer<typeof MarkerExecutor.schema>,
|
|
1075
|
+
): Promise<ExecutorResult<z.infer<typeof MarkerExecutor.outSchema>>> {
|
|
1076
|
+
return { status: "success", output: { label: config.label } };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function engineRegistry(decision: SystemOneDecisionExecutor): ExecutorRegistry {
|
|
1081
|
+
const registry = new ExecutorRegistry();
|
|
1082
|
+
registry.register(decision);
|
|
1083
|
+
registry.register(new PropertyMatchExecutor(deps));
|
|
1084
|
+
registry.register(new MarkerExecutor(deps));
|
|
1085
|
+
return registry;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function makeWorkflow(definition: WorkflowDefinition): Promise<Workflow> {
|
|
1089
|
+
return createWorkflow({
|
|
1090
|
+
name: `jev-test-${crypto.randomUUID()}`,
|
|
1091
|
+
definition,
|
|
1092
|
+
createdByAgentId: agentId,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** qualify -> gate on authority confidence -> approved | human-review. */
|
|
1097
|
+
const gatedDefinition = (qualify: Partial<WorkflowNode> = {}): WorkflowDefinition => ({
|
|
1098
|
+
nodes: [
|
|
1099
|
+
{
|
|
1100
|
+
id: "qualify",
|
|
1101
|
+
type: "system-one-decision",
|
|
1102
|
+
inputs: { lead: "trigger.lead" },
|
|
1103
|
+
config: mixedConfig({ state: "{{lead}}" }),
|
|
1104
|
+
next: "gate",
|
|
1105
|
+
...qualify,
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
id: "gate",
|
|
1109
|
+
type: "property-match",
|
|
1110
|
+
inputs: { qualification: "qualify" },
|
|
1111
|
+
config: {
|
|
1112
|
+
conditions: [
|
|
1113
|
+
{ field: "qualification.answers.authority.confidence", op: "gt", value: 0.55 },
|
|
1114
|
+
],
|
|
1115
|
+
},
|
|
1116
|
+
next: { true: "approved", false: "human-review" },
|
|
1117
|
+
},
|
|
1118
|
+
{ id: "approved", type: "marker", config: { label: "approved" } },
|
|
1119
|
+
{ id: "human-review", type: "marker", config: { label: "human" } },
|
|
1120
|
+
],
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
describe("system-one-decision in the workflow engine", () => {
|
|
1124
|
+
test("downstream aliases route on the answer, including to human review", async () => {
|
|
1125
|
+
const trigger = { lead: { name: "Ada", message: "Need workflow tooling now" } };
|
|
1126
|
+
|
|
1127
|
+
const confident = makeTransport([jsonResponse(mixedBody())]);
|
|
1128
|
+
const registryA = engineRegistry(
|
|
1129
|
+
new SystemOneDecisionExecutor(deps, {
|
|
1130
|
+
fetch: confident.transport,
|
|
1131
|
+
getApiKey: async () => API_KEY,
|
|
1132
|
+
}),
|
|
1133
|
+
);
|
|
1134
|
+
const runA = await startWorkflowExecution(
|
|
1135
|
+
await makeWorkflow(gatedDefinition()),
|
|
1136
|
+
trigger,
|
|
1137
|
+
registryA,
|
|
1138
|
+
);
|
|
1139
|
+
const stepsA = await getWorkflowRunStepsByRunId(runA);
|
|
1140
|
+
expect((await getWorkflowRun(runA))?.status).toBe("completed");
|
|
1141
|
+
expect(stepsA.map((s) => s.nodeId).sort()).toEqual(["approved", "gate", "qualify"]);
|
|
1142
|
+
// The whole-token lead reached the API as an object, not a JSON string.
|
|
1143
|
+
expect(confident.calls[0]?.body.state).toEqual(trigger.lead);
|
|
1144
|
+
const qualifyStep = stepsA.find((s) => s.nodeId === "qualify");
|
|
1145
|
+
expect(qualifyStep?.status).toBe("completed");
|
|
1146
|
+
expect((qualifyStep?.output as { usage: unknown }).usage).toEqual({
|
|
1147
|
+
input_tokens: 400,
|
|
1148
|
+
output_tokens: 80,
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
const unsure = makeTransport([
|
|
1152
|
+
jsonResponse(
|
|
1153
|
+
withAnswers({
|
|
1154
|
+
fit: noulAnswer,
|
|
1155
|
+
authority: { ...choiceAnswer, confidence: 0.4 },
|
|
1156
|
+
urgency: scoreAnswer,
|
|
1157
|
+
}),
|
|
1158
|
+
),
|
|
1159
|
+
]);
|
|
1160
|
+
const registryB = engineRegistry(
|
|
1161
|
+
new SystemOneDecisionExecutor(deps, {
|
|
1162
|
+
fetch: unsure.transport,
|
|
1163
|
+
getApiKey: async () => API_KEY,
|
|
1164
|
+
}),
|
|
1165
|
+
);
|
|
1166
|
+
const runB = await startWorkflowExecution(
|
|
1167
|
+
await makeWorkflow(gatedDefinition()),
|
|
1168
|
+
trigger,
|
|
1169
|
+
registryB,
|
|
1170
|
+
);
|
|
1171
|
+
const stepsB = await getWorkflowRunStepsByRunId(runB);
|
|
1172
|
+
expect((await getWorkflowRun(runB))?.status).toBe("completed");
|
|
1173
|
+
expect(stepsB.map((s) => s.nodeId).sort()).toEqual(["gate", "human-review", "qualify"]);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test("an unresolved reference stops the step before any request is sent", async () => {
|
|
1177
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
1178
|
+
const registry = engineRegistry(
|
|
1179
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, getApiKey: async () => API_KEY }),
|
|
1180
|
+
);
|
|
1181
|
+
// `lead` is declared, but the trigger has no `lead`, so {{lead}} cannot resolve.
|
|
1182
|
+
const runId = await startWorkflowExecution(
|
|
1183
|
+
await makeWorkflow(gatedDefinition()),
|
|
1184
|
+
{ other: true },
|
|
1185
|
+
registry,
|
|
1186
|
+
).catch(async () => undefined);
|
|
1187
|
+
const runs = await getDbClient().query<{ id: string }>("SELECT id FROM workflow_runs");
|
|
1188
|
+
const id = runId ?? runs[0]?.id;
|
|
1189
|
+
const steps = await getWorkflowRunStepsByRunId(id as string);
|
|
1190
|
+
const qualify = steps.find((s) => s.nodeId === "qualify");
|
|
1191
|
+
|
|
1192
|
+
expect(calls).toHaveLength(0);
|
|
1193
|
+
expect(qualify?.status).toBe("failed");
|
|
1194
|
+
expect(qualify?.error).toContain("{{lead}}");
|
|
1195
|
+
expect(qualify?.error).toContain("No request was sent");
|
|
1196
|
+
expect((await getWorkflowRun(id as string))?.status).toBe("failed");
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
test("a missing alias in a question description stops the step before any request", async () => {
|
|
1200
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
1201
|
+
const registry = engineRegistry(
|
|
1202
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, getApiKey: async () => API_KEY }),
|
|
1203
|
+
);
|
|
1204
|
+
const definition = gatedDefinition({
|
|
1205
|
+
inputs: { lead: "trigger.lead" },
|
|
1206
|
+
config: mixedConfig({
|
|
1207
|
+
state: "{{lead}}",
|
|
1208
|
+
questions: { fit: { type: "noul", instructions: "Is {{company}} a fit?" } },
|
|
1209
|
+
returns: { fit: { type: "noul" } },
|
|
1210
|
+
}),
|
|
1211
|
+
});
|
|
1212
|
+
const runId = await startWorkflowExecution(
|
|
1213
|
+
await makeWorkflow(definition),
|
|
1214
|
+
{ lead: { name: "Ada" } },
|
|
1215
|
+
registry,
|
|
1216
|
+
).catch(() => undefined);
|
|
1217
|
+
const runs = await getDbClient().query<{ id: string }>("SELECT id FROM workflow_runs");
|
|
1218
|
+
const steps = await getWorkflowRunStepsByRunId((runId ?? runs[0]?.id) as string);
|
|
1219
|
+
|
|
1220
|
+
expect(calls).toHaveLength(0);
|
|
1221
|
+
expect(steps.find((s) => s.nodeId === "qualify")?.error).toContain("{{company}}");
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
test("a stored definition with an engine retry policy fails before dispatch", async () => {
|
|
1225
|
+
const { calls, transport } = makeTransport([jsonResponse({}, 500)]);
|
|
1226
|
+
const registry = engineRegistry(
|
|
1227
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, getApiKey: async () => API_KEY }),
|
|
1228
|
+
);
|
|
1229
|
+
const definition = gatedDefinition({
|
|
1230
|
+
retry: { maxRetries: 3, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 },
|
|
1231
|
+
});
|
|
1232
|
+
const runId = await startWorkflowExecution(
|
|
1233
|
+
await makeWorkflow(definition),
|
|
1234
|
+
{ lead: { name: "Ada" } },
|
|
1235
|
+
registry,
|
|
1236
|
+
).catch(() => undefined);
|
|
1237
|
+
const runs = await getDbClient().query<{ id: string }>("SELECT id FROM workflow_runs");
|
|
1238
|
+
const id = (runId ?? runs[0]?.id) as string;
|
|
1239
|
+
const qualify = (await getWorkflowRunStepsByRunId(id)).find((s) => s.nodeId === "qualify");
|
|
1240
|
+
|
|
1241
|
+
expect(calls).toHaveLength(0);
|
|
1242
|
+
expect(qualify?.status).toBe("failed");
|
|
1243
|
+
expect(qualify?.error).toContain("retry.maxRetries > 0");
|
|
1244
|
+
expect(qualify?.retryCount ?? 0).toBe(0);
|
|
1245
|
+
expect(qualify?.nextRetryAt ?? null).toBeNull();
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("a 401 fails the run after one attempt with nothing scheduled for retry", async () => {
|
|
1249
|
+
const { calls, transport } = makeTransport([
|
|
1250
|
+
jsonResponse({ error: { code: "unauthorized" } }, 401),
|
|
1251
|
+
]);
|
|
1252
|
+
const registry = engineRegistry(
|
|
1253
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, getApiKey: async () => API_KEY }),
|
|
1254
|
+
);
|
|
1255
|
+
const runId = await startWorkflowExecution(
|
|
1256
|
+
await makeWorkflow(gatedDefinition()),
|
|
1257
|
+
{ lead: { name: "Ada" } },
|
|
1258
|
+
registry,
|
|
1259
|
+
).catch(() => undefined);
|
|
1260
|
+
const runs = await getDbClient().query<{ id: string }>("SELECT id FROM workflow_runs");
|
|
1261
|
+
const id = (runId ?? runs[0]?.id) as string;
|
|
1262
|
+
const steps = await getWorkflowRunStepsByRunId(id);
|
|
1263
|
+
const qualify = steps.find((s) => s.nodeId === "qualify");
|
|
1264
|
+
|
|
1265
|
+
expect(calls).toHaveLength(1);
|
|
1266
|
+
expect(qualify?.status).toBe("failed");
|
|
1267
|
+
expect(qualify?.error).toContain("HTTP 401");
|
|
1268
|
+
expect(qualify?.nextRetryAt ?? null).toBeNull();
|
|
1269
|
+
expect(steps.map((s) => s.nodeId)).toEqual(["qualify"]);
|
|
1270
|
+
expect((await getWorkflowRun(id))?.status).toBe("failed");
|
|
1271
|
+
expect(JSON.stringify(steps)).not.toContain(API_KEY);
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// ─── Providers ──────────────────────────────────────────────
|
|
1276
|
+
|
|
1277
|
+
const OPENROUTER_KEY = "sk-or-example-jev-test-key-0123456789";
|
|
1278
|
+
const OPENROUTER_ENDPOINT = "https://openrouter.ai/api/alpha/decisions";
|
|
1279
|
+
|
|
1280
|
+
/** OpenRouter's decisions body: the same contract plus id, provider, and usage.cost. */
|
|
1281
|
+
const openRouterBody = () =>
|
|
1282
|
+
mixedBody({
|
|
1283
|
+
model: "typesafe/jev-1.13-20260917",
|
|
1284
|
+
id: "gen-dec-1790686228-example",
|
|
1285
|
+
provider: "TypeSafe",
|
|
1286
|
+
usage: { input_tokens: 418, output_tokens: 61, cost: 0.000017556 },
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
function makeOpenRouterExecutor(steps: TransportStep[], env: NodeJS.ProcessEnv = {}) {
|
|
1290
|
+
const { calls, transport } = makeTransport(steps);
|
|
1291
|
+
const executor = new SystemOneDecisionExecutor(deps, {
|
|
1292
|
+
fetch: transport,
|
|
1293
|
+
env: { OPENROUTER_API_KEY: OPENROUTER_KEY, ...env },
|
|
1294
|
+
sleep: async () => {},
|
|
1295
|
+
random: () => 1,
|
|
1296
|
+
});
|
|
1297
|
+
return { executor, calls };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
describe("system-one-decision providers", () => {
|
|
1301
|
+
test("a definition can name only a listed provider and carries no endpoint, header, or key", () => {
|
|
1302
|
+
for (const evil of ["https://evil.example", "typesafe.ai", "", "constructor"]) {
|
|
1303
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig({ provider: evil })).success).toBe(
|
|
1304
|
+
false,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
// A definition cannot carry an endpoint, header, or key.
|
|
1308
|
+
for (const field of ["endpoint", "url", "headers", "apiKey"]) {
|
|
1309
|
+
expect(SystemOneDecisionConfigSchema.safeParse(mixedConfig({ [field]: "x" })).success).toBe(
|
|
1310
|
+
false,
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
test("provider must be a literal, not a token that could change the checked key", () => {
|
|
1316
|
+
const registry = createExecutorRegistry(deps);
|
|
1317
|
+
const node = (provider: string): WorkflowNode => ({
|
|
1318
|
+
id: "qualify",
|
|
1319
|
+
type: "system-one-decision",
|
|
1320
|
+
inputs: { p: "trigger.p" },
|
|
1321
|
+
config: mixedConfig({ provider }),
|
|
1322
|
+
});
|
|
1323
|
+
const bad = validateDefinition({ nodes: [node("{{p}}")] }, registry);
|
|
1324
|
+
expect(bad.valid).toBe(false);
|
|
1325
|
+
expect(bad.errors.join(";")).toContain("config.provider must be a literal");
|
|
1326
|
+
expect(validateDefinition({ nodes: [node("openrouter")] }, registry).valid).toBe(true);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("typesafe sends to the TypeSafe host with the TypeSafe default model and no provider field", async () => {
|
|
1330
|
+
const { executor, calls } = makeExecutor([jsonResponse(mixedBody())]);
|
|
1331
|
+
const result = await runSystemOne(executor, mixedConfig({ model: undefined }));
|
|
1332
|
+
|
|
1333
|
+
expect(result.status).toBe("success");
|
|
1334
|
+
expect(calls[0]?.url).toBe(SYSTEM_ONE_ENDPOINT);
|
|
1335
|
+
expect(calls[0]?.body.model).toBe("jev-latest");
|
|
1336
|
+
expect(Object.keys(calls[0]?.body ?? {}).sort()).toEqual(["model", "questions", "state"]);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test("openrouter sends the same request to the decisions endpoint with the OpenRouter key", async () => {
|
|
1340
|
+
const { executor, calls } = makeOpenRouterExecutor([jsonResponse(openRouterBody())]);
|
|
1341
|
+
const config = mixedConfig({ provider: "openrouter", model: undefined });
|
|
1342
|
+
const result = await runSystemOne(executor, config);
|
|
1343
|
+
|
|
1344
|
+
expect(result.status).toBe("success");
|
|
1345
|
+
expect(calls).toHaveLength(1);
|
|
1346
|
+
const call = calls[0]!;
|
|
1347
|
+
expect(call.url).toBe(OPENROUTER_ENDPOINT);
|
|
1348
|
+
expect(call.init.redirect).toBe("manual");
|
|
1349
|
+
expect((call.init.headers as Record<string, string>).authorization).toBe(
|
|
1350
|
+
`Bearer ${OPENROUTER_KEY}`,
|
|
1351
|
+
);
|
|
1352
|
+
// Same wire body as TypeSafe: no `provider`, no `returns`.
|
|
1353
|
+
expect(Object.keys(call.body).sort()).toEqual(["model", "questions", "state"]);
|
|
1354
|
+
expect(call.body.model).toBe("~typesafe/jev-latest");
|
|
1355
|
+
expect(call.body.questions).toEqual(config.questions as object);
|
|
1356
|
+
// The typed contract holds on this path: one validated answer per question.
|
|
1357
|
+
expect(Object.keys(result.output?.answers ?? {}).sort()).toEqual([
|
|
1358
|
+
"authority",
|
|
1359
|
+
"fit",
|
|
1360
|
+
"urgency",
|
|
1361
|
+
]);
|
|
1362
|
+
expect(result.output?.model).toBe("typesafe/jev-1.13-20260917");
|
|
1363
|
+
expect(JSON.stringify(result)).not.toContain(OPENROUTER_KEY);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test("openrouter passes an explicit model slug through", async () => {
|
|
1367
|
+
const { executor, calls } = makeOpenRouterExecutor([jsonResponse(openRouterBody())]);
|
|
1368
|
+
await runSystemOne(
|
|
1369
|
+
executor,
|
|
1370
|
+
mixedConfig({ provider: "openrouter", model: "typesafe/jev-1.13" }),
|
|
1371
|
+
);
|
|
1372
|
+
expect(calls[0]?.body.model).toBe("typesafe/jev-1.13");
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
test("openrouter fails the same answer contract as typesafe", async () => {
|
|
1376
|
+
const { executor } = makeOpenRouterExecutor([
|
|
1377
|
+
jsonResponse(withAnswers({ fit: noulAnswer, authority: choiceAnswer })),
|
|
1378
|
+
]);
|
|
1379
|
+
const result = await runSystemOne(executor, mixedConfig({ provider: "openrouter" }));
|
|
1380
|
+
expect(result.status).toBe("failed");
|
|
1381
|
+
expect(result.error).toContain('missing the answer for question "urgency"');
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
test("openrouter never falls through to OPENAI_API_KEY", async () => {
|
|
1385
|
+
const { calls, transport } = makeTransport([jsonResponse(openRouterBody())]);
|
|
1386
|
+
const executor = new SystemOneDecisionExecutor(deps, {
|
|
1387
|
+
fetch: transport,
|
|
1388
|
+
env: { OPENAI_API_KEY: "sk-openai-example-0123456789" },
|
|
1389
|
+
});
|
|
1390
|
+
const result = await runSystemOne(executor, mixedConfig({ provider: "openrouter" }));
|
|
1391
|
+
|
|
1392
|
+
expect(result.status).toBe("failed");
|
|
1393
|
+
expect(result.error).toContain("OPENROUTER_API_KEY is not configured");
|
|
1394
|
+
expect(result.error).not.toContain("OPENAI_API_KEY");
|
|
1395
|
+
expect(calls).toHaveLength(0);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test("openrouter refuses a gateway base URL instead of sending the key there", async () => {
|
|
1399
|
+
const { executor, calls } = makeOpenRouterExecutor([jsonResponse(openRouterBody())], {
|
|
1400
|
+
OPENROUTER_BASE_URL: "https://gateway.example.test/proxy/v1",
|
|
1401
|
+
});
|
|
1402
|
+
const result = await runSystemOne(executor, mixedConfig({ provider: "openrouter" }));
|
|
1403
|
+
|
|
1404
|
+
expect(result.status).toBe("failed");
|
|
1405
|
+
expect(result.error).toContain("OPENROUTER_BASE_URL");
|
|
1406
|
+
expect(calls).toHaveLength(0);
|
|
1407
|
+
expect(
|
|
1408
|
+
await executor.checkReadiness([{ id: "n", config: { provider: "openrouter" } }]),
|
|
1409
|
+
).toEqual([{ nodeIds: ["n"], message: expect.stringContaining("OPENROUTER_BASE_URL") }]);
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
test("each provider reads only its own key", async () => {
|
|
1413
|
+
// TypeSafe key present, OpenRouter key absent.
|
|
1414
|
+
await upsertSwarmConfig({
|
|
1415
|
+
scope: "global",
|
|
1416
|
+
key: "TYPESAFE_API_KEY",
|
|
1417
|
+
value: API_KEY,
|
|
1418
|
+
isSecret: true,
|
|
1419
|
+
});
|
|
1420
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
1421
|
+
const executor = new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} });
|
|
1422
|
+
|
|
1423
|
+
const viaOpenRouter = await runSystemOne(executor, mixedConfig({ provider: "openrouter" }));
|
|
1424
|
+
expect(viaOpenRouter.error).toContain("OPENROUTER_API_KEY");
|
|
1425
|
+
expect(calls).toHaveLength(0);
|
|
1426
|
+
|
|
1427
|
+
const viaTypeSafe = await runSystemOne(executor, mixedConfig({ provider: "typesafe" }));
|
|
1428
|
+
expect(viaTypeSafe.status).toBe("success");
|
|
1429
|
+
expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe(
|
|
1430
|
+
`Bearer ${API_KEY}`,
|
|
1431
|
+
);
|
|
1432
|
+
|
|
1433
|
+
// OpenRouter key present, TypeSafe row absent.
|
|
1434
|
+
await getDbClient().run("DELETE FROM swarm_config WHERE key = 'TYPESAFE_API_KEY'");
|
|
1435
|
+
const { calls: orCalls, transport: orTransport } = makeTransport([
|
|
1436
|
+
jsonResponse(openRouterBody()),
|
|
1437
|
+
]);
|
|
1438
|
+
const orExecutor = new SystemOneDecisionExecutor(deps, {
|
|
1439
|
+
fetch: orTransport,
|
|
1440
|
+
env: { OPENROUTER_API_KEY: OPENROUTER_KEY },
|
|
1441
|
+
});
|
|
1442
|
+
const viaTypeSafeMissing = await runSystemOne(
|
|
1443
|
+
orExecutor,
|
|
1444
|
+
mixedConfig({ provider: "typesafe" }),
|
|
1445
|
+
);
|
|
1446
|
+
expect(viaTypeSafeMissing.error).toContain("TYPESAFE_API_KEY is not configured");
|
|
1447
|
+
expect(orCalls).toHaveLength(0);
|
|
1448
|
+
});
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
// ─── laya provider ──────────────────────────────────────────
|
|
1452
|
+
|
|
1453
|
+
const LAYA_KEY = "laya-example-test-key-0123456789";
|
|
1454
|
+
const LAYA_URL = "https://laya.example.test";
|
|
1455
|
+
|
|
1456
|
+
const setLayaConfig = async ({
|
|
1457
|
+
url = LAYA_URL,
|
|
1458
|
+
key = LAYA_KEY,
|
|
1459
|
+
}: {
|
|
1460
|
+
url?: string;
|
|
1461
|
+
key?: string;
|
|
1462
|
+
}) => {
|
|
1463
|
+
if (url) await upsertSwarmConfig({ scope: "global", key: "LAYA_URL", value: url });
|
|
1464
|
+
if (key) {
|
|
1465
|
+
await upsertSwarmConfig({ scope: "global", key: "LAYA_API_KEY", value: key, isSecret: true });
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
/** laya-server's body: the shared contract plus `routing`, `answer_confidence`, and `action`. */
|
|
1470
|
+
const layaBody = () =>
|
|
1471
|
+
mixedBody({
|
|
1472
|
+
model: "laya-rl-agent",
|
|
1473
|
+
answers: {
|
|
1474
|
+
fit: { ...noulAnswer, confidence: 0.82, answer_confidence: 0.82 },
|
|
1475
|
+
authority: { ...choiceAnswer, confidence: 0.4, answer_confidence: 0.9, action: {} },
|
|
1476
|
+
urgency: { ...scoreAnswer, confidence: 0.3, answer_confidence: 0.75 },
|
|
1477
|
+
},
|
|
1478
|
+
usage: { input_tokens: 190, output_tokens: 0 },
|
|
1479
|
+
routing: { model: "english", reason: "English Latin text" },
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
/** A real executor over the swarm config rows, so the default lookups run. */
|
|
1483
|
+
function makeLayaExecutor(steps: TransportStep[]) {
|
|
1484
|
+
const { calls, transport } = makeTransport(steps);
|
|
1485
|
+
const executor = new SystemOneDecisionExecutor(deps, {
|
|
1486
|
+
fetch: transport,
|
|
1487
|
+
env: {},
|
|
1488
|
+
sleep: async () => {},
|
|
1489
|
+
random: () => 1,
|
|
1490
|
+
});
|
|
1491
|
+
return { executor, calls };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const layaConfig = (overrides: Record<string, unknown> = {}) =>
|
|
1495
|
+
mixedConfig({ provider: "laya", model: undefined, ...overrides });
|
|
1496
|
+
|
|
1497
|
+
describe("system-one-decision laya provider", () => {
|
|
1498
|
+
test("the config accepts provider laya and nothing else about the host", () => {
|
|
1499
|
+
expect(SystemOneDecisionConfigSchema.safeParse(layaConfig()).success).toBe(true);
|
|
1500
|
+
for (const field of ["endpoint", "url", "baseUrl", "apiKey"]) {
|
|
1501
|
+
expect(
|
|
1502
|
+
SystemOneDecisionConfigSchema.safeParse(layaConfig({ [field]: "https://x.example" }))
|
|
1503
|
+
.success,
|
|
1504
|
+
).toBe(false);
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
test("the request goes to LAYA_URL with LAYA_API_KEY, from the swarm config, with no model", async () => {
|
|
1509
|
+
await setLayaConfig({});
|
|
1510
|
+
// A TypeSafe key present must not be used for laya.
|
|
1511
|
+
await upsertSwarmConfig({
|
|
1512
|
+
scope: "global",
|
|
1513
|
+
key: "TYPESAFE_API_KEY",
|
|
1514
|
+
value: API_KEY,
|
|
1515
|
+
isSecret: true,
|
|
1516
|
+
});
|
|
1517
|
+
const { executor, calls } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1518
|
+
const config = layaConfig();
|
|
1519
|
+
const result = await runSystemOne(executor, config);
|
|
1520
|
+
|
|
1521
|
+
expect(result.status).toBe("success");
|
|
1522
|
+
expect(calls).toHaveLength(1);
|
|
1523
|
+
const call = calls[0]!;
|
|
1524
|
+
expect(call.url).toBe(`${LAYA_URL}/v1/systemone`);
|
|
1525
|
+
expect(call.init.method).toBe("POST");
|
|
1526
|
+
expect(call.init.redirect).toBe("manual");
|
|
1527
|
+
expect((call.init.headers as Record<string, string>).authorization).toBe(`Bearer ${LAYA_KEY}`);
|
|
1528
|
+
// No `model`: laya answers an id it does not know with HTTP 200 and routes on the text.
|
|
1529
|
+
expect(Object.keys(call.body).sort()).toEqual(["questions", "state"]);
|
|
1530
|
+
expect(call.body.questions).toEqual(config.questions as object);
|
|
1531
|
+
expect(JSON.stringify(result)).not.toContain(LAYA_KEY);
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
test("a model the node sets is passed through to laya unchanged", async () => {
|
|
1535
|
+
await setLayaConfig({});
|
|
1536
|
+
const { executor, calls } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1537
|
+
await runSystemOne(executor, layaConfig({ model: "multilingual" }));
|
|
1538
|
+
expect(calls[0]?.body.model).toBe("multilingual");
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
test("the output keeps routing.model and reads the confidence from answer_confidence", async () => {
|
|
1542
|
+
await setLayaConfig({});
|
|
1543
|
+
const { executor } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1544
|
+
const result = await runSystemOne(executor, layaConfig());
|
|
1545
|
+
|
|
1546
|
+
expect(result.status).toBe("success");
|
|
1547
|
+
const output = result.output;
|
|
1548
|
+
expect(output?.model).toBe("laya-rl-agent");
|
|
1549
|
+
expect(output?.routing).toEqual({ model: "english" });
|
|
1550
|
+
// laya's entropy-based confidence (0.4, 0.3) is not the reported one.
|
|
1551
|
+
expect(output?.answers.authority).toMatchObject({ confidence: 0.9 });
|
|
1552
|
+
expect(output?.answers.urgency).toMatchObject({ confidence: 0.75 });
|
|
1553
|
+
expect(JSON.stringify(output)).not.toContain("reason");
|
|
1554
|
+
expect(SystemOneDecisionOutputSchema.safeParse(output).success).toBe(true);
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
test("a laya answer without answer_confidence fails validation", async () => {
|
|
1558
|
+
await setLayaConfig({});
|
|
1559
|
+
const { executor } = makeLayaExecutor([jsonResponse(mixedBody())]);
|
|
1560
|
+
const result = await runSystemOne(executor, layaConfig());
|
|
1561
|
+
expect(result.status).toBe("failed");
|
|
1562
|
+
expect(result.error).toContain("answer_confidence must be a finite number between 0 and 1");
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test("a base URL with a path and trailing slashes keeps its path", async () => {
|
|
1566
|
+
await setLayaConfig({ url: "https://host.example.test/laya//" });
|
|
1567
|
+
const { executor, calls } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1568
|
+
await runSystemOne(executor, layaConfig());
|
|
1569
|
+
expect(calls[0]?.url).toBe("https://host.example.test/laya/v1/systemone");
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test("plain http is accepted for localhost only", async () => {
|
|
1573
|
+
await setLayaConfig({ url: "http://localhost:8080" });
|
|
1574
|
+
const local = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1575
|
+
expect((await runSystemOne(local.executor, layaConfig())).status).toBe("success");
|
|
1576
|
+
expect(local.calls[0]?.url).toBe("http://localhost:8080/v1/systemone");
|
|
1577
|
+
|
|
1578
|
+
await setLayaConfig({ url: "http://laya.example.test" });
|
|
1579
|
+
const remote = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1580
|
+
const result = await runSystemOne(remote.executor, layaConfig());
|
|
1581
|
+
expect(result.status).toBe("failed");
|
|
1582
|
+
expect(result.error).toContain("LAYA_URL is not a usable laya server URL");
|
|
1583
|
+
expect(remote.calls).toHaveLength(0);
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
test("an unusable LAYA_URL fails before any request and never echoes the value", async () => {
|
|
1587
|
+
for (const url of [
|
|
1588
|
+
"not a url",
|
|
1589
|
+
"ftp://laya.example.test",
|
|
1590
|
+
"https://admin:hunter2@laya.example.test",
|
|
1591
|
+
"https://laya.example.test?token=hunter2",
|
|
1592
|
+
"https://laya.example.test/#hunter2",
|
|
1593
|
+
]) {
|
|
1594
|
+
await setLayaConfig({ url });
|
|
1595
|
+
const { executor, calls } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1596
|
+
const result = await runSystemOne(executor, layaConfig());
|
|
1597
|
+
|
|
1598
|
+
expect(result.status).toBe("failed");
|
|
1599
|
+
expect(result.error).toContain("LAYA_URL is not a usable laya server URL");
|
|
1600
|
+
expect(result.error).not.toContain("hunter2");
|
|
1601
|
+
expect(calls).toHaveLength(0);
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
test("a missing LAYA_URL and a missing LAYA_API_KEY are each named, with no TypeSafe text", async () => {
|
|
1606
|
+
const onlyKey = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1607
|
+
await setLayaConfig({ url: "" });
|
|
1608
|
+
const noUrl = await runSystemOne(onlyKey.executor, layaConfig());
|
|
1609
|
+
expect(noUrl.status).toBe("failed");
|
|
1610
|
+
expect(noUrl.error).toContain("LAYA_URL is not configured");
|
|
1611
|
+
expect(noUrl.error).toContain("URL of a laya server");
|
|
1612
|
+
expect(noUrl.error).not.toContain("LAYA_API_KEY");
|
|
1613
|
+
|
|
1614
|
+
await getDbClient().run("DELETE FROM swarm_config WHERE key = 'LAYA_API_KEY'");
|
|
1615
|
+
await setLayaConfig({ key: "" });
|
|
1616
|
+
const noKey = await runSystemOne(onlyKey.executor, layaConfig());
|
|
1617
|
+
expect(noKey.status).toBe("failed");
|
|
1618
|
+
expect(noKey.error).toContain("LAYA_API_KEY is not configured");
|
|
1619
|
+
expect(noKey.error).toContain("working laya API key");
|
|
1620
|
+
expect(noKey.error).toContain("Secrets page");
|
|
1621
|
+
expect(noKey.error).not.toContain("LAYA_URL");
|
|
1622
|
+
|
|
1623
|
+
// Neither set: one message that names both.
|
|
1624
|
+
await getDbClient().run("DELETE FROM swarm_config WHERE key IN ('LAYA_URL', 'LAYA_API_KEY')");
|
|
1625
|
+
const neither = await runSystemOne(onlyKey.executor, layaConfig());
|
|
1626
|
+
expect(neither.error).toContain("LAYA_URL is not configured");
|
|
1627
|
+
expect(neither.error).toContain("LAYA_API_KEY is not configured");
|
|
1628
|
+
for (const result of [noUrl, noKey, neither]) {
|
|
1629
|
+
expect(result.error).not.toMatch(/TypeSafe|TYPESAFE/);
|
|
1630
|
+
}
|
|
1631
|
+
expect(onlyKey.calls).toHaveLength(0);
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
test("readiness names LAYA_URL and LAYA_API_KEY for a laya node and does not call laya", async () => {
|
|
1635
|
+
const { executor, calls } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1636
|
+
const problems = await executor.checkReadiness([
|
|
1637
|
+
{ id: "a", config: layaConfig() },
|
|
1638
|
+
{ id: "b", config: layaConfig() },
|
|
1639
|
+
{ id: "c", config: mixedConfig() },
|
|
1640
|
+
]);
|
|
1641
|
+
|
|
1642
|
+
const laya = problems.find((p) => p.message.startsWith("LAYA_URL"));
|
|
1643
|
+
expect(laya?.nodeIds).toEqual(["a", "b"]);
|
|
1644
|
+
expect(laya?.message).toContain("LAYA_API_KEY is not configured");
|
|
1645
|
+
expect(problems.find((p) => p.message.startsWith("TYPESAFE_API_KEY"))?.nodeIds).toEqual(["c"]);
|
|
1646
|
+
expect(problems).toHaveLength(2);
|
|
1647
|
+
expect(calls).toHaveLength(0);
|
|
1648
|
+
|
|
1649
|
+
await setLayaConfig({});
|
|
1650
|
+
expect(await executor.checkReadiness([{ id: "a", config: layaConfig() }])).toEqual([]);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
test("a config row that cannot be read is reported without its cause", async () => {
|
|
1654
|
+
const { calls, transport } = makeTransport([jsonResponse(layaBody())]);
|
|
1655
|
+
const executor = new SystemOneDecisionExecutor(deps, {
|
|
1656
|
+
fetch: transport,
|
|
1657
|
+
env: {},
|
|
1658
|
+
getServerUrl: async () => {
|
|
1659
|
+
throw new Error("Failed to decrypt config 'LAYA_URL' (id=row-9)");
|
|
1660
|
+
},
|
|
1661
|
+
getApiKey: async () => LAYA_KEY,
|
|
1662
|
+
});
|
|
1663
|
+
const problems = await executor.checkReadiness([{ id: "a", config: layaConfig() }]);
|
|
1664
|
+
expect(problems).toEqual([
|
|
1665
|
+
{ nodeIds: ["a"], message: "Could not read LAYA_URL from swarm config" },
|
|
1666
|
+
]);
|
|
1667
|
+
expect(calls).toHaveLength(0);
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
test("a save warning and a run start both name the laya settings", async () => {
|
|
1671
|
+
const { calls, transport } = makeTransport([jsonResponse(layaBody())]);
|
|
1672
|
+
const registry = engineRegistry(
|
|
1673
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} }),
|
|
1674
|
+
);
|
|
1675
|
+
const definition = markerThenSystemOne({ config: layaConfig() });
|
|
1676
|
+
|
|
1677
|
+
const warnings = await workflowSaveWarnings(definition, registry);
|
|
1678
|
+
expect(warnings).toHaveLength(1);
|
|
1679
|
+
expect(warnings[0]).toContain("LAYA_URL is not configured");
|
|
1680
|
+
expect(warnings[0]).toContain("LAYA_API_KEY is not configured");
|
|
1681
|
+
expect(warnings[0]).toContain('system-one-decision node "qualify"');
|
|
1682
|
+
|
|
1683
|
+
const runId = await startWorkflowExecution(await makeWorkflow(definition), {}, registry);
|
|
1684
|
+
const run = await getWorkflowRun(runId);
|
|
1685
|
+
expect(run?.status).toBe("failed");
|
|
1686
|
+
expect(run?.error).toContain("Run not started, no node executed");
|
|
1687
|
+
expect(run?.error).toContain("LAYA_URL is not configured");
|
|
1688
|
+
expect(run?.error).toContain("LAYA_API_KEY is not configured");
|
|
1689
|
+
expect(await getWorkflowRunStepsByRunId(runId)).toHaveLength(0);
|
|
1690
|
+
expect(calls).toHaveLength(0);
|
|
1691
|
+
|
|
1692
|
+
await setLayaConfig({});
|
|
1693
|
+
expect(await workflowSaveWarnings(definition, registry)).toEqual([]);
|
|
1694
|
+
const ok = await startWorkflowExecution(await makeWorkflow(definition), {}, registry);
|
|
1695
|
+
expect((await getWorkflowRun(ok))?.status).toBe("completed");
|
|
1696
|
+
expect(calls).toHaveLength(1);
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
test("a 401 names LAYA_API_KEY and laya, not TypeSafe, and is not retried", async () => {
|
|
1700
|
+
await setLayaConfig({});
|
|
1701
|
+
const { executor, calls } = makeLayaExecutor([
|
|
1702
|
+
// laya's own body, with no error code.
|
|
1703
|
+
jsonResponse({ detail: "invalid or missing bearer token" }, 401),
|
|
1704
|
+
]);
|
|
1705
|
+
const result = await runSystemOne(executor, layaConfig({ maxRetries: 3 }));
|
|
1706
|
+
|
|
1707
|
+
expect(result.status).toBe("failed");
|
|
1708
|
+
expect(result.error).toContain("LAYA_API_KEY was rejected by laya (HTTP 401)");
|
|
1709
|
+
expect(result.error).toContain("No decision was made");
|
|
1710
|
+
expect(result.error).not.toMatch(/TypeSafe|TYPESAFE/);
|
|
1711
|
+
expect(calls).toHaveLength(1);
|
|
1712
|
+
expect(JSON.stringify(result)).not.toContain(LAYA_KEY);
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
test("laya reads only its own settings", async () => {
|
|
1716
|
+
// A key on the wrong provider does not satisfy laya, and the reverse.
|
|
1717
|
+
await upsertSwarmConfig({ scope: "global", key: "LAYA_URL", value: LAYA_URL });
|
|
1718
|
+
const { executor, calls } = makeLayaExecutor([jsonResponse(layaBody())]);
|
|
1719
|
+
const viaLaya = await runSystemOne(executor, layaConfig());
|
|
1720
|
+
expect(viaLaya.error).toContain("LAYA_API_KEY is not configured");
|
|
1721
|
+
|
|
1722
|
+
await upsertSwarmConfig({
|
|
1723
|
+
scope: "global",
|
|
1724
|
+
key: "TYPESAFE_API_KEY",
|
|
1725
|
+
value: API_KEY,
|
|
1726
|
+
isSecret: true,
|
|
1727
|
+
});
|
|
1728
|
+
// The TypeSafe key serves typesafe, at the TypeSafe host, and laya still has none.
|
|
1729
|
+
const viaTypeSafe = await runSystemOne(executor, mixedConfig({ provider: "typesafe" }));
|
|
1730
|
+
expect(viaTypeSafe.status).toBe("success");
|
|
1731
|
+
expect(calls).toHaveLength(1);
|
|
1732
|
+
expect(calls[0]?.url).toBe(SYSTEM_ONE_ENDPOINT);
|
|
1733
|
+
expect((await runSystemOne(executor, layaConfig())).error).toContain(
|
|
1734
|
+
"LAYA_API_KEY is not configured",
|
|
1735
|
+
);
|
|
1736
|
+
});
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
// ─── Rejected key ───────────────────────────────────────────
|
|
1740
|
+
|
|
1741
|
+
describe("a key the host rejects", () => {
|
|
1742
|
+
test("a 401 or 403 says the named key was rejected, once, never retried", async () => {
|
|
1743
|
+
for (const status of [401, 403]) {
|
|
1744
|
+
const { executor, calls, sleeps } = makeExecutor([
|
|
1745
|
+
jsonResponse({ detail: { error_type: "authentication_error", message: API_KEY } }, status),
|
|
1746
|
+
]);
|
|
1747
|
+
const result = await runSystemOne(executor, mixedConfig({ maxRetries: 3 }));
|
|
1748
|
+
|
|
1749
|
+
expect(result.status).toBe("failed");
|
|
1750
|
+
expect(result.error).toContain(`TYPESAFE_API_KEY was rejected by TypeSafe (HTTP ${status}`);
|
|
1751
|
+
expect(result.error).toContain("(authentication_error)");
|
|
1752
|
+
expect(result.error).toContain("Secrets page");
|
|
1753
|
+
expect(calls).toHaveLength(1);
|
|
1754
|
+
expect(sleeps).toHaveLength(0);
|
|
1755
|
+
// The host echoed the key in its body; nothing of it reaches the error.
|
|
1756
|
+
expect(JSON.stringify(result)).not.toContain(API_KEY);
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
test("the rejection names the provider that was used", async () => {
|
|
1761
|
+
const { executor, calls } = makeOpenRouterExecutor([
|
|
1762
|
+
jsonResponse({ error: { message: OPENROUTER_KEY, code: 401 } }, 401),
|
|
1763
|
+
]);
|
|
1764
|
+
const result = await runSystemOne(
|
|
1765
|
+
executor,
|
|
1766
|
+
mixedConfig({ provider: "openrouter", maxRetries: 3 }),
|
|
1767
|
+
);
|
|
1768
|
+
|
|
1769
|
+
expect(result.status).toBe("failed");
|
|
1770
|
+
expect(result.error).toContain("OPENROUTER_API_KEY was rejected by OpenRouter (HTTP 401");
|
|
1771
|
+
expect(result.error).not.toContain("TYPESAFE_API_KEY");
|
|
1772
|
+
expect(calls).toHaveLength(1);
|
|
1773
|
+
expect(JSON.stringify(result)).not.toContain(OPENROUTER_KEY);
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
test("other failures keep the generic message, not the key-rejected one", async () => {
|
|
1777
|
+
for (const status of [400, 402, 422, 500]) {
|
|
1778
|
+
const { executor } = makeExecutor([jsonResponse({ error: { code: "boom" } }, status)]);
|
|
1779
|
+
const result = await runSystemOne(executor, mixedConfig({ maxRetries: 0 }));
|
|
1780
|
+
expect(result.error).toContain(`SystemOne API returned HTTP ${status}`);
|
|
1781
|
+
expect(result.error).not.toContain("was rejected");
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
// ─── Readiness (before first use) ───────────────────────────
|
|
1787
|
+
|
|
1788
|
+
describe("system-one-decision readiness before first use", () => {
|
|
1789
|
+
test("no problem when the key is present", async () => {
|
|
1790
|
+
const { executor } = makeExecutor([jsonResponse(mixedBody())]);
|
|
1791
|
+
expect(await executor.checkReadiness([{ id: "a", config: mixedConfig() }])).toEqual([]);
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
test("names the exact key for the provider each node uses", async () => {
|
|
1795
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
1796
|
+
const executor = new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} });
|
|
1797
|
+
const problems = await executor.checkReadiness([
|
|
1798
|
+
{ id: "a", config: mixedConfig() },
|
|
1799
|
+
{ id: "b", config: mixedConfig({ provider: "openrouter" }) },
|
|
1800
|
+
{ id: "c", config: mixedConfig({ provider: "typesafe" }) },
|
|
1801
|
+
{ id: "d", config: mixedConfig({ provider: "no-such-provider" }) },
|
|
1802
|
+
]);
|
|
1803
|
+
|
|
1804
|
+
expect(problems).toHaveLength(2);
|
|
1805
|
+
const typesafe = problems.find((p) => p.message.startsWith("TYPESAFE_API_KEY"));
|
|
1806
|
+
const openrouter = problems.find((p) => p.message.startsWith("OPENROUTER_API_KEY"));
|
|
1807
|
+
expect(typesafe?.nodeIds).toEqual(["a", "c"]);
|
|
1808
|
+
expect(openrouter?.nodeIds).toEqual(["b"]);
|
|
1809
|
+
for (const problem of problems) {
|
|
1810
|
+
expect(problem.message).toContain("Secrets page");
|
|
1811
|
+
expect(problem.message).toContain("not configured");
|
|
1812
|
+
}
|
|
1813
|
+
// Readiness never calls the provider.
|
|
1814
|
+
expect(calls).toHaveLength(0);
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
test("blank and malformed keys are not ready", async () => {
|
|
1818
|
+
for (const key of [" ", "bad\nkey"]) {
|
|
1819
|
+
const { executor } = makeExecutor([jsonResponse(mixedBody())], {
|
|
1820
|
+
getApiKey: async () => key,
|
|
1821
|
+
});
|
|
1822
|
+
const problems = await executor.checkReadiness([{ id: "a", config: mixedConfig() }]);
|
|
1823
|
+
expect(problems).toHaveLength(1);
|
|
1824
|
+
expect(problems[0]?.message).toContain("TYPESAFE_API_KEY");
|
|
1825
|
+
expect(JSON.stringify(problems)).not.toContain("bad");
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
test("a config row that cannot be read is reported without its cause", async () => {
|
|
1830
|
+
const { executor } = makeExecutor([jsonResponse(mixedBody())], {
|
|
1831
|
+
getApiKey: async () => {
|
|
1832
|
+
throw new Error("Failed to decrypt config 'TYPESAFE_API_KEY' (id=row-1)");
|
|
1833
|
+
},
|
|
1834
|
+
});
|
|
1835
|
+
const problems = await executor.checkReadiness([{ id: "a", config: mixedConfig() }]);
|
|
1836
|
+
expect(problems[0]?.message).toBe("Could not read TYPESAFE_API_KEY from swarm config");
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
test("a workflow with no system-one-decision node is ready without asking anyone", async () => {
|
|
1840
|
+
const registry = engineRegistry(
|
|
1841
|
+
new SystemOneDecisionExecutor(deps, { getApiKey: async () => null }),
|
|
1842
|
+
);
|
|
1843
|
+
expect(
|
|
1844
|
+
await findWorkflowReadinessProblems(
|
|
1845
|
+
{ nodes: [{ id: "m", type: "marker", config: { label: "x" } }] },
|
|
1846
|
+
registry,
|
|
1847
|
+
),
|
|
1848
|
+
).toEqual([]);
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
test("a save warning names the key, where to set it, the node, and the consequence", async () => {
|
|
1852
|
+
const registry = engineRegistry(
|
|
1853
|
+
new SystemOneDecisionExecutor(deps, { getApiKey: async () => null }),
|
|
1854
|
+
);
|
|
1855
|
+
const warnings = await workflowSaveWarnings(gatedDefinition(), registry);
|
|
1856
|
+
|
|
1857
|
+
expect(warnings).toHaveLength(1);
|
|
1858
|
+
expect(warnings[0]).toContain("TYPESAFE_API_KEY is not configured");
|
|
1859
|
+
expect(warnings[0]).toContain("Secrets page");
|
|
1860
|
+
expect(warnings[0]).toContain('system-one-decision node "qualify"');
|
|
1861
|
+
expect(warnings[0]).toContain("fail before any node executes");
|
|
1862
|
+
|
|
1863
|
+
const ready = engineRegistry(
|
|
1864
|
+
new SystemOneDecisionExecutor(deps, { getApiKey: async () => API_KEY }),
|
|
1865
|
+
);
|
|
1866
|
+
expect(await workflowSaveWarnings(gatedDefinition(), ready)).toEqual([]);
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
test("a readiness check that throws becomes a problem, never an exception", async () => {
|
|
1870
|
+
class ThrowingExecutor extends MarkerExecutor {
|
|
1871
|
+
override async checkReadiness(): Promise<never> {
|
|
1872
|
+
throw new Error("boom with secret-detail");
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
const registry = new ExecutorRegistry();
|
|
1876
|
+
registry.register(new ThrowingExecutor(deps));
|
|
1877
|
+
const problems = await findWorkflowReadinessProblems(
|
|
1878
|
+
{ nodes: [{ id: "m", type: "marker", config: { label: "x" } }] },
|
|
1879
|
+
registry,
|
|
1880
|
+
);
|
|
1881
|
+
expect(problems).toHaveLength(1);
|
|
1882
|
+
expect(problems[0]?.message).not.toContain("secret-detail");
|
|
1883
|
+
});
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
// ─── Run start ──────────────────────────────────────────────
|
|
1887
|
+
|
|
1888
|
+
/** A marker runs first, so a run that started would leave a step behind. */
|
|
1889
|
+
const markerThenSystemOne = (overrides: Partial<WorkflowNode> = {}): WorkflowDefinition => ({
|
|
1890
|
+
nodes: [
|
|
1891
|
+
{ id: "before", type: "marker", config: { label: "side effect" }, next: "qualify" },
|
|
1892
|
+
{
|
|
1893
|
+
id: "qualify",
|
|
1894
|
+
type: "system-one-decision",
|
|
1895
|
+
config: mixedConfig(),
|
|
1896
|
+
next: "after",
|
|
1897
|
+
...overrides,
|
|
1898
|
+
},
|
|
1899
|
+
{ id: "after", type: "marker", config: { label: "done" } },
|
|
1900
|
+
],
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
describe("run start with a system-one-decision node", () => {
|
|
1904
|
+
test("a missing key fails the run before any node executes", async () => {
|
|
1905
|
+
// Default key lookup (the swarm config row) with no row present.
|
|
1906
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
1907
|
+
const registry = engineRegistry(
|
|
1908
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} }),
|
|
1909
|
+
);
|
|
1910
|
+
const runId = await startWorkflowExecution(
|
|
1911
|
+
await makeWorkflow(markerThenSystemOne()),
|
|
1912
|
+
{},
|
|
1913
|
+
registry,
|
|
1914
|
+
);
|
|
1915
|
+
|
|
1916
|
+
const run = await getWorkflowRun(runId);
|
|
1917
|
+
expect(run?.status).toBe("failed");
|
|
1918
|
+
expect(run?.finishedAt).toBeTruthy();
|
|
1919
|
+
expect(run?.error).toContain("Run not started, no node executed");
|
|
1920
|
+
expect(run?.error).toContain("TYPESAFE_API_KEY is not configured");
|
|
1921
|
+
expect(run?.error).toContain("Secrets page");
|
|
1922
|
+
expect(run?.error).toContain('system-one-decision node "qualify"');
|
|
1923
|
+
// Not even the marker ahead of the system-one-decision node ran, and nothing was sent.
|
|
1924
|
+
expect(await getWorkflowRunStepsByRunId(runId)).toHaveLength(0);
|
|
1925
|
+
expect(calls).toHaveLength(0);
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
test("the error names the key of the provider the node uses", async () => {
|
|
1929
|
+
const { calls, transport } = makeTransport([jsonResponse(openRouterBody())]);
|
|
1930
|
+
// A TypeSafe key exists; the node asks for OpenRouter, which has none.
|
|
1931
|
+
await upsertSwarmConfig({
|
|
1932
|
+
scope: "global",
|
|
1933
|
+
key: "TYPESAFE_API_KEY",
|
|
1934
|
+
value: API_KEY,
|
|
1935
|
+
isSecret: true,
|
|
1936
|
+
});
|
|
1937
|
+
const registry = engineRegistry(
|
|
1938
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} }),
|
|
1939
|
+
);
|
|
1940
|
+
const runId = await startWorkflowExecution(
|
|
1941
|
+
await makeWorkflow(markerThenSystemOne({ config: mixedConfig({ provider: "openrouter" }) })),
|
|
1942
|
+
{},
|
|
1943
|
+
registry,
|
|
1944
|
+
);
|
|
1945
|
+
|
|
1946
|
+
const run = await getWorkflowRun(runId);
|
|
1947
|
+
expect(run?.status).toBe("failed");
|
|
1948
|
+
expect(run?.error).toContain("OPENROUTER_API_KEY is not configured");
|
|
1949
|
+
expect(run?.error).not.toContain("TYPESAFE_API_KEY");
|
|
1950
|
+
expect(await getWorkflowRunStepsByRunId(runId)).toHaveLength(0);
|
|
1951
|
+
expect(calls).toHaveLength(0);
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
test("with the key configured the same definition runs to completion", async () => {
|
|
1955
|
+
await upsertSwarmConfig({
|
|
1956
|
+
scope: "global",
|
|
1957
|
+
key: "TYPESAFE_API_KEY",
|
|
1958
|
+
value: API_KEY,
|
|
1959
|
+
isSecret: true,
|
|
1960
|
+
});
|
|
1961
|
+
const { calls, transport } = makeTransport([jsonResponse(mixedBody())]);
|
|
1962
|
+
const registry = engineRegistry(
|
|
1963
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} }),
|
|
1964
|
+
);
|
|
1965
|
+
const runId = await startWorkflowExecution(
|
|
1966
|
+
await makeWorkflow(markerThenSystemOne()),
|
|
1967
|
+
{},
|
|
1968
|
+
registry,
|
|
1969
|
+
);
|
|
1970
|
+
|
|
1971
|
+
expect((await getWorkflowRun(runId))?.status).toBe("completed");
|
|
1972
|
+
expect((await getWorkflowRunStepsByRunId(runId)).map((s) => s.nodeId).sort()).toEqual([
|
|
1973
|
+
"after",
|
|
1974
|
+
"before",
|
|
1975
|
+
"qualify",
|
|
1976
|
+
]);
|
|
1977
|
+
expect(calls).toHaveLength(1);
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
test("a definition without a system-one-decision node starts whether or not any key exists", async () => {
|
|
1981
|
+
const registry = engineRegistry(new SystemOneDecisionExecutor(deps, { env: {} }));
|
|
1982
|
+
const runId = await startWorkflowExecution(
|
|
1983
|
+
await makeWorkflow({ nodes: [{ id: "only", type: "marker", config: { label: "x" } }] }),
|
|
1984
|
+
{},
|
|
1985
|
+
registry,
|
|
1986
|
+
);
|
|
1987
|
+
expect((await getWorkflowRun(runId))?.status).toBe("completed");
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
test("a rejected key then a rotated key: retry is refused while the key is gone, then succeeds", async () => {
|
|
1991
|
+
const upsertKey = () =>
|
|
1992
|
+
upsertSwarmConfig({
|
|
1993
|
+
scope: "global",
|
|
1994
|
+
key: "TYPESAFE_API_KEY",
|
|
1995
|
+
value: API_KEY,
|
|
1996
|
+
isSecret: true,
|
|
1997
|
+
});
|
|
1998
|
+
await upsertKey();
|
|
1999
|
+
const { calls, transport } = makeTransport([
|
|
2000
|
+
jsonResponse({ detail: { error_type: "authentication_error" } }, 401),
|
|
2001
|
+
jsonResponse(mixedBody()),
|
|
2002
|
+
]);
|
|
2003
|
+
const registry = engineRegistry(
|
|
2004
|
+
new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} }),
|
|
2005
|
+
);
|
|
2006
|
+
const runId = await startWorkflowExecution(
|
|
2007
|
+
await makeWorkflow(markerThenSystemOne()),
|
|
2008
|
+
{},
|
|
2009
|
+
registry,
|
|
2010
|
+
);
|
|
2011
|
+
|
|
2012
|
+
// Distinct rejected-key error on the step; the marker ahead of it did run.
|
|
2013
|
+
const failedRun = await getWorkflowRun(runId);
|
|
2014
|
+
const qualify = (await getWorkflowRunStepsByRunId(runId)).find((s) => s.nodeId === "qualify");
|
|
2015
|
+
expect(failedRun?.status).toBe("failed");
|
|
2016
|
+
expect(qualify?.error).toContain("TYPESAFE_API_KEY was rejected by TypeSafe (HTTP 401");
|
|
2017
|
+
expect(calls).toHaveLength(1);
|
|
2018
|
+
|
|
2019
|
+
// Key removed: the retry is refused up front and the run stays failed.
|
|
2020
|
+
await getDbClient().run("DELETE FROM swarm_config WHERE key = 'TYPESAFE_API_KEY'");
|
|
2021
|
+
await expect(retryFailedRun(runId, registry)).rejects.toThrow(
|
|
2022
|
+
/Retry not started, run left failed: TYPESAFE_API_KEY is not configured/,
|
|
2023
|
+
);
|
|
2024
|
+
expect((await getWorkflowRun(runId))?.status).toBe("failed");
|
|
2025
|
+
expect(calls).toHaveLength(1);
|
|
2026
|
+
|
|
2027
|
+
// Key replaced: the retry goes through.
|
|
2028
|
+
await upsertKey();
|
|
2029
|
+
await retryFailedRun(runId, registry);
|
|
2030
|
+
expect((await getWorkflowRun(runId))?.status).toBe("completed");
|
|
2031
|
+
expect(calls).toHaveLength(2);
|
|
2032
|
+
});
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
// ─── The skill's key probe ──────────────────────────────────
|
|
2036
|
+
|
|
2037
|
+
describe("the workflow-iterate key probe", () => {
|
|
2038
|
+
test("the probe definition in the skill validates and makes exactly one one-question call", async () => {
|
|
2039
|
+
const skill = await Bun.file(
|
|
2040
|
+
new URL("../../templates/skills/workflow-iterate/content.md", import.meta.url).pathname,
|
|
2041
|
+
).text();
|
|
2042
|
+
const blocks = [...skill.matchAll(/```json\n([\s\S]*?)```/g)].map((match) => match[1] ?? "");
|
|
2043
|
+
const probe = blocks.find((block) => block.includes('"id": "ping"'));
|
|
2044
|
+
expect(probe).toBeDefined();
|
|
2045
|
+
const definition = JSON.parse(probe as string) as WorkflowDefinition;
|
|
2046
|
+
|
|
2047
|
+
const registry = createExecutorRegistry(deps);
|
|
2048
|
+
expect(validateDefinition(definition, registry)).toEqual({ valid: true, errors: [] });
|
|
2049
|
+
|
|
2050
|
+
const { calls, transport } = makeTransport([
|
|
2051
|
+
jsonResponse(withAnswers({ ping: noulAnswer }, { model: "jev-1.13.0" })),
|
|
2052
|
+
]);
|
|
2053
|
+
const node = definition.nodes[0] as WorkflowNode;
|
|
2054
|
+
const result = await new SystemOneDecisionExecutor(deps, {
|
|
2055
|
+
fetch: transport,
|
|
2056
|
+
getApiKey: async () => API_KEY,
|
|
2057
|
+
}).run({ config: node.config, context: {}, meta: meta("ping") });
|
|
2058
|
+
|
|
2059
|
+
expect(result.status).toBe("success");
|
|
2060
|
+
expect(calls).toHaveLength(1);
|
|
2061
|
+
expect(Object.keys(calls[0]?.body.questions as object)).toEqual(["ping"]);
|
|
2062
|
+
});
|
|
2063
|
+
});
|