@desplega.ai/agent-swarm 1.158.0 → 1.160.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/README.md +1 -1
- package/dist/{acp-adapter-xaq4n6pc.js → acp-adapter-2mjwcsfh.js} +124 -27
- package/dist/{actions-p9hz5yat.js → actions-e43rxskr.js} +35 -30
- package/dist/{anthropic-messages-51pz26qe.js → anthropic-messages-5qx80j9k.js} +106 -16
- package/dist/{app-g26wdcz6.js → app-mc7xfagn.js} +12 -10
- package/dist/{assistant-wydfqkb3.js → assistant-sedkzv3b.js} +42 -31
- package/dist/{azure-openai-responses-8gksqtmz.js → azure-openai-responses-nfb21z9a.js} +13 -14
- package/dist/{boot-reembed-se8zmdcp.js → boot-reembed-cqz9swn5.js} +13 -12
- package/dist/{boot-reembed-g5ckg9yb.js → boot-reembed-m3656j2n.js} +14 -13
- package/dist/{boot-scrub-logs-wfxqan61.js → boot-scrub-logs-xbxf5d7w.js} +11 -9
- package/dist/boot-seeds-gsetbs5y.js +28 -0
- package/dist/{claude-adapter-tv2x4f36.js → claude-adapter-dc6289mc.js} +28 -23
- package/dist/{claude-managed-adapter-41b2s7fd.js → claude-managed-adapter-naaw51me.js} +4 -3
- package/dist/{claude-managed-setup-60g8aazx.js → claude-managed-setup-eacvchx0.js} +12 -3
- package/dist/claude-sdk-session-bqp2xvym.js +29722 -0
- package/dist/{cli-987hg0aa.js → cli-0jwe9034.js} +2 -2
- package/dist/{cli-vj6h9kc2.js → cli-0m7wct0d.js} +59 -1
- package/dist/{cli-c07r9a2m.js → cli-0q3avq3s.js} +12 -11
- package/dist/{cli-19jenss3.js → cli-0wgxmwf6.js} +31 -10
- package/dist/cli-0x01kwrd.js +491 -0
- package/dist/cli-0xqde53b.js +176 -0
- package/dist/cli-10nhj6xq.js +8355 -0
- package/dist/{cli-tt9k8r35.js → cli-12tszpk9.js} +479 -910
- package/dist/{cli-5b6b62tz.js → cli-13hw4z91.js} +1 -1
- package/dist/{cli-cqt19wg6.js → cli-1a2j50m0.js} +11 -2
- package/dist/{cli-xberxa1r.js → cli-25fcq4b4.js} +2 -2
- package/dist/{cli-npssxgaz.js → cli-2pdaysab.js} +52 -2
- package/dist/cli-3qhyh31j.js +166 -0
- package/dist/cli-447dt1h3.js +3828 -0
- package/dist/cli-4derpgyk.js +115 -0
- package/dist/{cli-00w2c66p.js → cli-523679eh.js} +13 -2
- package/dist/{cli-y95wp3tg.js → cli-58e8yr0d.js} +1 -1
- package/dist/{cli-24k806sc.js → cli-5nbmpy8w.js} +3 -3
- package/dist/{cli-53sz8gkz.js → cli-6cs6wzxt.js} +1 -1
- package/dist/cli-741yb0xx.js +1027 -0
- package/dist/cli-74h9hscv.js +69 -0
- package/dist/cli-7cxw8b60.js +14910 -0
- package/dist/{cli-xhaeg2nc.js → cli-7e398j1h.js} +1 -1
- package/dist/cli-7kyk9jr3.js +22 -0
- package/dist/cli-89s4ngw3.js +183 -0
- package/dist/{cli-9b13ggd1.js → cli-89v6kyq7.js} +1 -0
- package/dist/{cli-ftfvggzx.js → cli-9hsgwz4w.js} +1045 -581
- package/dist/{cli-rqz8qbw7.js → cli-a8dvhzrt.js} +59 -20
- package/dist/{cli-j57qkrpq.js → cli-azhgzetg.js} +395 -56
- package/dist/cli-br0qdrnp.js +2150 -0
- package/dist/{cli-rj19hc34.js → cli-btfv8y88.js} +1 -1
- package/dist/{cli-8qmme6vd.js → cli-c8a36vcc.js} +2 -2
- package/dist/cli-cpvsaqsw.js +333 -0
- package/dist/cli-d5czme6m.js +17805 -0
- package/dist/{cli-h38x0qhg.js → cli-et8m9xsv.js} +4 -3
- package/dist/{cli-x24pshtq.js → cli-evvtk35y.js} +6 -6
- package/dist/{cli-7s3syk22.js → cli-f8gy6r2r.js} +5 -5
- package/dist/{cli-zt1227k1.js → cli-fkqhbg8g.js} +5 -3
- package/dist/{cli-9httdpyq.js → cli-g2whhdrf.js} +1 -1
- package/dist/cli-gt4hxb8p.js +99 -0
- package/dist/{cli-s8eet254.js → cli-jajnsm8q.js} +37184 -53838
- package/dist/{cli-7gtvvpbh.js → cli-jayjpjmb.js} +21 -12
- package/dist/{cli-68w09sjw.js → cli-jyk0ynw2.js} +27 -89
- package/dist/{cli-aehm1rbq.js → cli-jz4w45qt.js} +1152 -61
- package/dist/cli-kmcpfwn2.js +333856 -0
- package/dist/{cli-4198qc9v.js → cli-ktmyrtpn.js} +19 -3
- package/dist/{cli-j55n64b6.js → cli-mfe5btp9.js} +505 -189
- package/dist/{cli-q67y4wzm.js → cli-phsaj4xq.js} +24 -2
- package/dist/{cli-1hkrch3k.js → cli-pm1rst0h.js} +46 -7
- package/dist/{cli-pw9rj5t9.js → cli-pqetb83f.js} +1 -176
- package/dist/{cli-ggm92wcn.js → cli-ptr8h7kp.js} +20 -1
- package/dist/{cli-s8s499xy.js → cli-qbksc292.js} +3 -2
- package/dist/{cli-47m6dkd3.js → cli-qydf22qq.js} +597 -346
- package/dist/{cli-3gmcwfyx.js → cli-r1mxd1xh.js} +13 -6
- package/dist/cli-rb9m6rpe.js +4980 -0
- package/dist/cli-rykfmgwg.js +102 -0
- package/dist/{cli-5d10vd2m.js → cli-shkxm1vn.js} +6 -5
- package/dist/{cli-eetrvv91.js → cli-t3p77qk1.js} +1 -1
- package/dist/cli-tfky2838.js +756 -0
- package/dist/{cli-g8djp6qe.js → cli-tmqt7dpj.js} +40 -0
- package/dist/{cli-fgqxwrpv.js → cli-tpq83z89.js} +1631 -127
- package/dist/cli-trc24wk2.js +143 -0
- package/dist/{cli-aky8tctm.js → cli-tzx2ke1v.js} +3 -3
- package/dist/cli-v7fn5gms.js +1425 -0
- package/dist/{cli-wscdvcqz.js → cli-vctdqs5h.js} +1 -1
- package/dist/{cli-cw9zck8j.js → cli-xjcywfsf.js} +8 -4
- package/dist/{cli-k0g1sjzm.js → cli-ybgv4kw2.js} +18 -11
- package/dist/{cli-rjehvrts.js → cli-ydmcgve1.js} +8 -8
- package/dist/{cli-vhjwh6cz.js → cli-ygwm91nt.js} +1 -1
- package/dist/{cli-mcnhgnn1.js → cli-ykk5jaax.js} +184 -26
- package/dist/{cli-de1396j2.js → cli-ywybs5as.js} +7 -1
- package/dist/cli-zpep9q5e.js +491 -0
- package/dist/cli.js +41 -32
- package/dist/cloudflare-workers-ai-system-one-k6fbxw9d.js +46 -0
- package/dist/{codex-adapter-cnm8wk11.js → codex-adapter-nmxcatww.js} +26 -17
- package/dist/{codex-hook-9hptmdhj.js → codex-hook-20me6byw.js} +2 -2
- package/dist/{codex-login-x3jk4etn.js → codex-login-w6fz4xbz.js} +50 -5
- package/dist/{codex-session-runner-gfg547zj.js → codex-session-runner-90aa80ev.js} +24 -17
- package/dist/{commands-e3bn0p75.js → commands-8wcq1kn7.js} +11 -9
- package/dist/{db-03v9kzr2.js → db-j40kc4se.js} +25 -11
- package/dist/{devin-adapter-8vgkwh29.js → devin-adapter-dpsv6hd9.js} +1 -1
- package/dist/{dispatcher-nrmg8yht.js → dispatcher-4zqy768d.js} +34 -29
- package/dist/{dsh-adapter-k03nfqa6.js → dsh-adapter-mpnzgztj.js} +3 -3
- package/dist/{e2b-93x1k0j5.js → e2b-ez07znam.js} +4 -3
- package/dist/execute-6e7qeyk4.js +1017 -0
- package/dist/{google-generative-ai-bxmv50nc.js → google-generative-ai-k6n5nbpp.js} +6 -5
- package/dist/{google-vertex-809af514.js → google-vertex-44xxtcvp.js} +6 -5
- package/dist/{handlers-aqcedbyb.js → handlers-kjdce1w5.js} +37 -32
- package/dist/{hook-ybvmrmhq.js → hook-czx13n12.js} +19 -15
- package/dist/{hook-7vkskkre.js → hook-r2t4bqfg.js} +18 -14
- package/dist/{http-2eqa8t24.js → http-3bbb19be.js} +900 -145
- package/dist/{index-f6cryxwe.js → index-4a5njnm1.js} +2 -59
- package/dist/{index-x71bvq0p.js → index-668me3f0.js} +35 -30
- package/dist/{index-c6zsv1bd.js → index-7aj368tw.js} +13 -12
- package/dist/{index-1yks3qcr.js → index-fg0dsp6c.js} +34 -29
- package/dist/{index-chgdkq36.js → index-kkv84jq9.js} +34 -29
- package/dist/{index-a3vn96n8.js → index-zg7vp46x.js} +198 -54
- package/dist/{keepalive-vtkf5n99.js → keepalive-yra8dy57.js} +13 -11
- package/dist/{lead-h3sp357x.js → lead-va0pad2c.js} +54 -49
- package/dist/llama-cpp-classify-tgj35k8d.js +335 -0
- package/dist/{maintenance-jzeqtk3q.js → maintenance-hqewtmyg.js} +14 -13
- package/dist/{mistral-conversations-zx0kn554.js → mistral-conversations-pc3ntnxt.js} +14 -17
- package/dist/{oauth-refresh-sweep-qp3dzmdp.js → oauth-refresh-sweep-7rdt1m9n.js} +12 -10
- package/dist/{onboard-tmsp0qbz.js → onboard-j6ms6d0x.js} +6 -5
- package/dist/{openai-codex-responses-4vjqzvjp.js → openai-codex-responses-bbancrr0.js} +21 -8
- package/dist/{openai-completions-9hjrqbw8.js → openai-completions-jpaj5g2b.js} +9 -12
- package/dist/{openai-responses-7b1pn0xt.js → openai-responses-200k1wx4.js} +23 -18
- package/dist/{opencode-adapter-x6x2da1z.js → opencode-adapter-89rabkw2.js} +8 -7
- package/dist/{openrouter-images-9f9ek4rw.js → openrouter-images-8yx3487g.js} +5 -7
- package/dist/{otel-impl-9mz73q65.js → otel-impl-a792rjz0.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-g7qzvr3v.js} +295 -70
- package/dist/pricing-refresh-wx0tp7cb.js +39 -0
- package/dist/quickjs-r1pq2107.wasm +0 -0
- package/dist/{rbac-roles-c9wqz83x.js → rbac-roles-8tzawacv.js} +12 -10
- package/dist/{render-v2-gryrh9h8.js → render-v2-mm7zswb8.js} +12 -10
- package/dist/runtime-zyssgse0.js +24 -0
- package/dist/{setup-q9720bt6.js → setup-8y70ys6t.js} +6 -5
- package/dist/typesafe-system-one-2ef30pec.js +27 -0
- package/dist/{virtual-modules-3htpgn8x.js → virtual-modules-6nmk7h3n.js} +22 -13
- package/dist/{work-objects-b76x7deb.js → work-objects-fg6xkb0n.js} +37 -32
- package/dist/{worker-6vrxbv41.js → worker-wmwhxsph.js} +54 -49
- package/openapi.json +1314 -91
- package/package.json +12 -11
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/api-keys.ts +70 -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 +274 -188
- package/src/be/harness-model-support.ts +182 -0
- package/src/be/human-free-drain.ts +67 -0
- package/src/be/memory/providers/sqlite-store.ts +51 -15
- 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/migrations/185_api_key_auth_failures.sql +6 -0
- package/src/be/migrations/186_api_key_seat_mismatch.sql +8 -0
- package/src/be/model-catalog-store.ts +644 -0
- package/src/be/model-tier-keys.ts +46 -0
- package/src/be/model-tier-resolution.ts +534 -0
- package/src/be/model-validation.ts +188 -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/runner.ts +18 -12
- package/src/be/seed/types.ts +1 -0
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +61 -9
- package/src/be/swarm-config-guard.ts +8 -0
- package/src/cli.tsx +10 -0
- package/src/commands/claude-managed-setup.ts +3 -1
- package/src/commands/codex-login.ts +67 -0
- package/src/commands/credential-outcome-report.ts +191 -0
- package/src/commands/rate-limit-outcome.ts +34 -4
- package/src/commands/runner.ts +357 -186
- package/src/commands/seat-mismatch-report.ts +46 -0
- package/src/extensions/apply-task-create.ts +27 -1
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +79 -17
- package/src/heartbeat/templates.ts +2 -2
- package/src/http/agents.ts +62 -1
- package/src/http/api-keys.ts +140 -2
- package/src/http/codex-oauth-keep-warm.ts +3 -5
- package/src/http/index.ts +8 -0
- package/src/http/memory.ts +12 -5
- package/src/http/models-catalog.ts +357 -8
- package/src/http/poll.ts +54 -3
- package/src/http/schedules.ts +51 -6
- package/src/http/script-runs.ts +12 -0
- package/src/http/session-data.ts +2 -5
- package/src/http/stats.ts +6 -8
- package/src/http/status.ts +15 -1
- package/src/http/tasks.ts +90 -7
- package/src/http/usage-cache.ts +56 -15
- package/src/http/workflows.ts +46 -8
- package/src/mcp-client/http-client.ts +5 -0
- package/src/memory/recall-query.ts +74 -0
- package/src/prompts/base-prompt.ts +15 -4
- package/src/prompts/session-templates.ts +23 -1
- package/src/providers/acp-adapter.ts +1 -0
- package/src/providers/claude-adapter.ts +14 -5
- package/src/providers/claude-managed-adapter.ts +1 -0
- package/src/providers/claude-managed-models.ts +47 -161
- package/src/providers/claude-sdk-session.ts +3 -1
- package/src/providers/codex-adapter.ts +14 -14
- package/src/providers/codex-models.ts +65 -198
- package/src/providers/devin-adapter.ts +1 -0
- package/src/providers/dsh-adapter.ts +2 -1
- package/src/providers/harness-version.ts +83 -7
- package/src/providers/opencode-adapter.ts +53 -1
- package/src/providers/pi-binary-root/src/extensions/codemode/worker.ts +11 -0
- package/src/providers/pi-codemode-runtime.ts +17 -0
- package/src/providers/pi-mono-adapter.ts +361 -83
- package/src/providers/pi-mono-extension.ts +16 -1
- package/src/providers/reasoning-effort.ts +44 -140
- package/src/providers/types.ts +8 -0
- package/src/rbac/legacy-policy.ts +43 -0
- package/src/rbac/permissions.ts +15 -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/assistant.ts +13 -0
- package/src/slack/message-text.ts +56 -2
- package/src/slack/render-v2.ts +87 -37
- package/src/slack/session-status.ts +541 -0
- package/src/slack/thread-buffer.ts +10 -2
- package/src/tasks/sibling-awareness.ts +3 -2
- package/src/telemetry.ts +5 -4
- package/src/templates.d.ts +6 -0
- package/src/tests/agents-list-model-display.test.ts +4 -6
- package/src/tests/api-key-auth-failure-route.test.ts +142 -0
- package/src/tests/api-key-tracking.test.ts +368 -1
- package/src/tests/base-prompt.test.ts +43 -2
- package/src/tests/claude-adapter.test.ts +68 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -18
- package/src/tests/codex-adapter.test.ts +2 -1
- package/src/tests/codex-auth-failure.test.ts +52 -0
- package/src/tests/codex-gpt6-release.test.ts +66 -5
- package/src/tests/codex-login.test.ts +116 -0
- package/src/tests/context-window.test.ts +7 -5
- package/src/tests/credential-exhaustion-policy.test.ts +118 -3
- package/src/tests/credential-outcome-report.test.ts +171 -0
- package/src/tests/credential-status-api.test.ts +103 -2
- package/src/tests/db-extraction-contract.test.ts +1 -0
- package/src/tests/db-task-read-contract.test.ts +10 -1
- package/src/tests/db-task-write-contract.test.ts +535 -2
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dokploy-deploy.test.ts +315 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/explicit-model-validation.test.ts +583 -0
- package/src/tests/extensions-pre-task.test.ts +73 -0
- package/src/tests/fetch-resolved-env.test.ts +46 -0
- package/src/tests/hang-watchdog-worker.ts +47 -0
- package/src/tests/hang-watchdog.test.ts +45 -0
- package/src/tests/hang-watchdog.ts +30 -0
- package/src/tests/harness-model-guard.test.ts +123 -0
- package/src/tests/harness-model-support.test.ts +374 -0
- package/src/tests/harness-version.test.ts +70 -3
- 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/http-task-write-ownership.test.ts +172 -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/memory-fts-populate.test.ts +139 -0
- package/src/tests/memory-http-recall-gating.test.ts +125 -1
- 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-rate-limit-windows.test.ts +19 -0
- package/src/tests/model-tier-resolution.test.ts +611 -0
- package/src/tests/models-catalog.test.ts +1 -1
- package/src/tests/multi-runtime-enabled-stats.test.ts +65 -1
- package/src/tests/opencode-adapter.test.ts +157 -2
- package/src/tests/package-publish.test.ts +137 -36
- package/src/tests/pi-codemode.test.ts +246 -0
- package/src/tests/pi-mono-adapter.test.ts +3 -0
- package/src/tests/pi-native-mcp.test.ts +193 -0
- package/src/tests/pi-tool-exposure.test.ts +374 -0
- package/src/tests/preload.ts +3 -0
- package/src/tests/pricing-routes.test.ts +2 -2
- package/src/tests/prompt-template-session.test.ts +5 -3
- package/src/tests/rate-limit-event.test.ts +65 -0
- package/src/tests/rate-limit-outcome.test.ts +90 -0
- package/src/tests/rbac-engine.test.ts +77 -3
- package/src/tests/reasoning-effort.test.ts +109 -0
- package/src/tests/refresh-modelsdev-carryforward.test.ts +5 -5
- package/src/tests/runner-key-completion-outcome.test.ts +112 -0
- package/src/tests/runner-key-outcome-late-reset.test.ts +224 -0
- package/src/tests/runner-registration-retry.test.ts +149 -0
- package/src/tests/runtime-model-catalog.test.ts +86 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/seat-mismatch-report.test.ts +85 -0
- package/src/tests/seed-scripts.test.ts +138 -0
- package/src/tests/seed.test.ts +25 -0
- package/src/tests/session-costs.test.ts +14 -11
- package/src/tests/slack-message-text.test.ts +101 -0
- package/src/tests/slack-render-v2.test.ts +96 -0
- package/src/tests/slack-session-status.test.ts +626 -0
- package/src/tests/slack-thread-buffer-context.test.ts +93 -0
- package/src/tests/status.test.ts +29 -0
- package/src/tests/steering-promotion.test.ts +51 -0
- package/src/tests/store-progress-ownership.test.ts +136 -0
- package/src/tests/subscription-plans.test.ts +28 -0
- package/src/tests/task-cascade-fail.test.ts +148 -2
- package/src/tests/task-citations.test.ts +3 -2
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/usage-cache.test.ts +166 -0
- package/src/tests/workflow-agent-task.test.ts +30 -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/tests/workflow-triggers-v2.test.ts +320 -0
- package/src/tools/model-catalog/index.ts +137 -0
- package/src/tools/schedules/create-schedule.ts +17 -2
- package/src/tools/schedules/patch-schedule.ts +31 -1
- package/src/tools/schedules/update-schedule.ts +31 -1
- package/src/tools/send-task.ts +18 -1
- package/src/tools/store-progress.ts +49 -4
- package/src/tools/task-action.ts +14 -1
- package/src/tools/tool-config.ts +3 -1
- package/src/tools/workflows/create-workflow.ts +17 -4
- 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 +17 -4
- package/src/types.ts +41 -1
- package/src/utils/codex-auth-failure.ts +34 -0
- package/src/utils/context-window.ts +47 -53
- package/src/utils/credentials.ts +72 -30
- package/src/utils/error-tracker.ts +80 -0
- package/src/utils/harness-cli-version.ts +115 -0
- package/src/utils/harness-model-error.ts +33 -0
- package/src/utils/model-rate-limit-windows.ts +12 -0
- package/src/utils/runtime-model-catalog.ts +152 -0
- package/src/utils/spawn-model-guard.ts +43 -0
- package/src/utils/subscription-plans.ts +22 -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 +16 -6
- 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/model-validation.ts +45 -0
- package/src/workflows/patch-definition.ts +5 -0
- package/src/workflows/readiness.ts +86 -0
- package/src/workflows/recovery.ts +12 -12
- package/src/workflows/resume.ts +23 -9
- package/src/workflows/triggers.ts +85 -0
- 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 +82 -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/boot-seeds-s3q01xj3.js +0 -26
- package/dist/claude-sdk-session-t442qz6f.js +0 -39108
- package/dist/cli-0xjkj1j7.js +0 -61
- package/dist/cli-20cj37vt.js +0 -335561
- package/dist/cli-2djtr51b.js +0 -48
- 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/{index-f24115ge.js → index-2wsdsnmm.js} +3 -3
|
@@ -0,0 +1,1021 @@
|
|
|
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
|
+
getApprovalRequestByStepId,
|
|
9
|
+
getDbClient,
|
|
10
|
+
getWorkflowRun,
|
|
11
|
+
getWorkflowRunStepsByRunId,
|
|
12
|
+
initDb,
|
|
13
|
+
resolveApprovalRequest,
|
|
14
|
+
updateWorkflowRunStep,
|
|
15
|
+
} from "../be/db";
|
|
16
|
+
import type { ApprovalRequest, WorkflowDefinition, WorkflowNode } from "../types";
|
|
17
|
+
import { shapeApprovalResolution } from "../workflows/approval-resolution";
|
|
18
|
+
import { validateDefinition } from "../workflows/definition";
|
|
19
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
20
|
+
import { InProcessEventBus } from "../workflows/event-bus";
|
|
21
|
+
import {
|
|
22
|
+
BaseExecutor,
|
|
23
|
+
type ExecutorDependencies,
|
|
24
|
+
type ExecutorResult,
|
|
25
|
+
} from "../workflows/executors/base";
|
|
26
|
+
import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
27
|
+
import {
|
|
28
|
+
answerConfidence,
|
|
29
|
+
resolveReviewedDecision,
|
|
30
|
+
SystemOneDecisionConfigSchema,
|
|
31
|
+
SystemOneDecisionExecutor,
|
|
32
|
+
SystemOneDecisionOutputSchema,
|
|
33
|
+
systemOneStaticShapeViolations,
|
|
34
|
+
validateSystemOneResponse,
|
|
35
|
+
} from "../workflows/executors/system-one-decision";
|
|
36
|
+
import { SYSTEM_ONE_PROVIDERS } from "../workflows/executors/system-one-providers";
|
|
37
|
+
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
38
|
+
import { setupWorkflowResumeListener } from "../workflows/resume";
|
|
39
|
+
import { interpolate } from "../workflows/template";
|
|
40
|
+
|
|
41
|
+
const TEST_DB_PATH = "./test-workflow-system-one-decision-review.sqlite";
|
|
42
|
+
const API_KEY = "tsk_example-review-test-key.0123456789";
|
|
43
|
+
|
|
44
|
+
// ─── Harness ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
let deps: ExecutorDependencies;
|
|
47
|
+
let agentId: string;
|
|
48
|
+
const bus = new InProcessEventBus();
|
|
49
|
+
let stopListening: (() => void) | undefined;
|
|
50
|
+
|
|
51
|
+
async function removeDbFiles(): Promise<void> {
|
|
52
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
53
|
+
try {
|
|
54
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class MarkerExecutor extends BaseExecutor<
|
|
62
|
+
typeof MarkerExecutor.schema,
|
|
63
|
+
typeof MarkerExecutor.outSchema
|
|
64
|
+
> {
|
|
65
|
+
static readonly schema = z.object({ label: z.string() });
|
|
66
|
+
static readonly outSchema = z.object({ label: z.string() });
|
|
67
|
+
readonly type = "marker";
|
|
68
|
+
readonly mode = "instant" as const;
|
|
69
|
+
readonly configSchema = MarkerExecutor.schema;
|
|
70
|
+
readonly outputSchema = MarkerExecutor.outSchema;
|
|
71
|
+
protected async execute(
|
|
72
|
+
config: z.infer<typeof MarkerExecutor.schema>,
|
|
73
|
+
): Promise<ExecutorResult<z.infer<typeof MarkerExecutor.outSchema>>> {
|
|
74
|
+
return { status: "success", output: { label: config.label } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const noulAnswer = { type: "noul", noul: 0.82 };
|
|
79
|
+
const choiceAnswer = {
|
|
80
|
+
type: "choice",
|
|
81
|
+
choice: "buyer",
|
|
82
|
+
confidence: 0.75,
|
|
83
|
+
probabilities: { buyer: 0.9, champion: 0.07, unknown: 0.03 },
|
|
84
|
+
};
|
|
85
|
+
const scoreAnswer = {
|
|
86
|
+
type: "score",
|
|
87
|
+
score: 1.7,
|
|
88
|
+
confidence: 0.65,
|
|
89
|
+
legend: { "0": "No timeline", "1": "This quarter", "2": "Blocked now" },
|
|
90
|
+
probabilities: { "0": 0.05, "1": 0.2, "2": 0.75 },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function mixedConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
94
|
+
return {
|
|
95
|
+
model: "jev-1.13.0",
|
|
96
|
+
state: { lead: { name: "Ada", message: "We are blocked on agent workflow tooling now" } },
|
|
97
|
+
questions: {
|
|
98
|
+
fit: {
|
|
99
|
+
type: "noul",
|
|
100
|
+
instructions: "Is this a real engineering team seeking agent workflow automation?",
|
|
101
|
+
},
|
|
102
|
+
authority: {
|
|
103
|
+
type: "choice",
|
|
104
|
+
instructions: "What purchasing authority is supported by this lead's message?",
|
|
105
|
+
criteria: {
|
|
106
|
+
buyer: "Can approve the purchase",
|
|
107
|
+
champion: "Influences the decision",
|
|
108
|
+
unknown: "The message does not establish authority",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
urgency: {
|
|
112
|
+
type: "score",
|
|
113
|
+
instructions: "How urgent is the stated need?",
|
|
114
|
+
criteria: ["No timeline", "This quarter", "Blocked now"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
returns: {
|
|
118
|
+
fit: { type: "noul" },
|
|
119
|
+
authority: { type: "choice" },
|
|
120
|
+
urgency: { type: "score" },
|
|
121
|
+
},
|
|
122
|
+
...overrides,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mixedBody(answers: Record<string, unknown> = {}) {
|
|
127
|
+
return {
|
|
128
|
+
model: "jev-1.13.0",
|
|
129
|
+
answers: { fit: noulAnswer, authority: choiceAnswer, urgency: scoreAnswer, ...answers },
|
|
130
|
+
usage: { input_tokens: 400, output_tokens: 80 },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const APPROVERS = { users: ["reviewer@example.com"], policy: "any" as const };
|
|
135
|
+
/** authority (0.75) and urgency (0.65) are in this band; fit (0.82) is not. */
|
|
136
|
+
const REVIEW = { band: { min: 0.5, max: 0.8 }, approvers: APPROVERS };
|
|
137
|
+
|
|
138
|
+
const PORTS = { approved: "accepted", rejected: "declined", timeout: "timed-out" };
|
|
139
|
+
|
|
140
|
+
function reviewedDefinition(
|
|
141
|
+
humanReview: Record<string, unknown> = REVIEW,
|
|
142
|
+
next: WorkflowNode["next"] = PORTS,
|
|
143
|
+
): WorkflowDefinition {
|
|
144
|
+
const marker = (id: string): WorkflowNode => ({
|
|
145
|
+
id,
|
|
146
|
+
type: "marker",
|
|
147
|
+
inputs: { decision: "decide" },
|
|
148
|
+
config: { label: `${id}:{{decision.answers.authority.choice}}` },
|
|
149
|
+
});
|
|
150
|
+
// Only the successors `next` names, or the rest would be unreachable entry nodes.
|
|
151
|
+
const targets = [
|
|
152
|
+
...new Set(typeof next === "string" ? [next] : Object.values(next ?? {}).flat()),
|
|
153
|
+
];
|
|
154
|
+
return {
|
|
155
|
+
nodes: [
|
|
156
|
+
{ id: "decide", type: "system-one-decision", config: mixedConfig({ humanReview }), next },
|
|
157
|
+
...targets.map(marker),
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function makeRegistry(body: unknown = mixedBody()) {
|
|
163
|
+
let calls = 0;
|
|
164
|
+
const fetcher = (async () => {
|
|
165
|
+
calls++;
|
|
166
|
+
return new Response(JSON.stringify(body), {
|
|
167
|
+
status: 200,
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
});
|
|
170
|
+
}) as unknown as typeof fetch;
|
|
171
|
+
const executor = new SystemOneDecisionExecutor(deps, {
|
|
172
|
+
fetch: fetcher,
|
|
173
|
+
getApiKey: async () => API_KEY,
|
|
174
|
+
});
|
|
175
|
+
const registry = new ExecutorRegistry();
|
|
176
|
+
registry.register(executor);
|
|
177
|
+
registry.register(new MarkerExecutor(deps));
|
|
178
|
+
return { registry, executor, calls: () => calls };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function startRun(
|
|
182
|
+
definition: WorkflowDefinition,
|
|
183
|
+
registry: ExecutorRegistry,
|
|
184
|
+
listen = true,
|
|
185
|
+
): Promise<string> {
|
|
186
|
+
if (listen) {
|
|
187
|
+
stopListening?.();
|
|
188
|
+
stopListening = setupWorkflowResumeListener(bus, registry);
|
|
189
|
+
}
|
|
190
|
+
const workflow = await createWorkflow({
|
|
191
|
+
name: `sod-review-${crypto.randomUUID()}`,
|
|
192
|
+
definition,
|
|
193
|
+
createdByAgentId: agentId,
|
|
194
|
+
});
|
|
195
|
+
return startWorkflowExecution(workflow, {}, registry);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function stepOf(runId: string, nodeId: string) {
|
|
199
|
+
const step = (await getWorkflowRunStepsByRunId(runId)).find((s) => s.nodeId === nodeId);
|
|
200
|
+
if (!step) throw new Error(`no step for ${nodeId}`);
|
|
201
|
+
return step;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function approvalOf(runId: string, nodeId = "decide"): Promise<ApprovalRequest> {
|
|
205
|
+
const approval = await getApprovalRequestByStepId((await stepOf(runId, nodeId)).id);
|
|
206
|
+
if (!approval) throw new Error("no approval request raised");
|
|
207
|
+
return approval;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Answer the request the way the HTTP route does, then tell the resume listener. */
|
|
211
|
+
async function respond(
|
|
212
|
+
runId: string,
|
|
213
|
+
status: "approved" | "rejected" | "timeout",
|
|
214
|
+
responses: Record<string, unknown> | null = null,
|
|
215
|
+
{ emit = true }: { emit?: boolean } = {},
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const approval = await approvalOf(runId);
|
|
218
|
+
const updated = await resolveApprovalRequest(
|
|
219
|
+
approval.id,
|
|
220
|
+
{ status, responses, resolvedBy: "reviewer@example.com" },
|
|
221
|
+
{ requireActionableWorkflow: true },
|
|
222
|
+
);
|
|
223
|
+
if (!updated) throw new Error("approval could not be resolved");
|
|
224
|
+
if (emit) {
|
|
225
|
+
bus.emit("approval.resolved", {
|
|
226
|
+
requestId: updated.id,
|
|
227
|
+
status: updated.status,
|
|
228
|
+
responses: updated.responses,
|
|
229
|
+
workflowRunId: updated.workflowRunId,
|
|
230
|
+
workflowRunStepId: updated.workflowRunStepId,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function settle(runId: string): Promise<string> {
|
|
236
|
+
for (let i = 0; i < 200; i++) {
|
|
237
|
+
const run = await getWorkflowRun(runId);
|
|
238
|
+
if (run && run.status !== "waiting" && run.status !== "running") return run.status;
|
|
239
|
+
await Bun.sleep(25);
|
|
240
|
+
}
|
|
241
|
+
throw new Error("run did not settle");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type Output = z.infer<typeof SystemOneDecisionOutputSchema>;
|
|
245
|
+
|
|
246
|
+
async function decideOutput(runId: string): Promise<Output> {
|
|
247
|
+
return SystemOneDecisionOutputSchema.parse((await stepOf(runId, "decide")).output);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
beforeAll(async () => {
|
|
251
|
+
await removeDbFiles();
|
|
252
|
+
initDb(TEST_DB_PATH);
|
|
253
|
+
const agent = await createAgent({ name: "review-test-agent", isLead: true, status: "idle" });
|
|
254
|
+
agentId = agent.id;
|
|
255
|
+
const db = await import("../be/db");
|
|
256
|
+
deps = {
|
|
257
|
+
db,
|
|
258
|
+
eventBus: new InProcessEventBus(),
|
|
259
|
+
interpolate: (template, ctx) => interpolate(template, ctx).result,
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterAll(async () => {
|
|
264
|
+
stopListening?.();
|
|
265
|
+
closeDb();
|
|
266
|
+
await removeDbFiles();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
beforeEach(async () => {
|
|
270
|
+
stopListening?.();
|
|
271
|
+
stopListening = undefined;
|
|
272
|
+
const client = getDbClient();
|
|
273
|
+
await client.run("DELETE FROM approval_requests");
|
|
274
|
+
await client.run("DELETE FROM workflow_run_steps");
|
|
275
|
+
await client.run("DELETE FROM workflow_runs");
|
|
276
|
+
await client.run("DELETE FROM workflows");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ─── Authoring ──────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe("humanReview authoring", () => {
|
|
282
|
+
const registry = () => makeRegistry().registry;
|
|
283
|
+
|
|
284
|
+
test("a valid band with approvers is accepted, and the field is optional", () => {
|
|
285
|
+
expect(validateDefinition(reviewedDefinition(), registry()).errors).toEqual([]);
|
|
286
|
+
const plain = reviewedDefinition();
|
|
287
|
+
plain.nodes[0].config = mixedConfig();
|
|
288
|
+
plain.nodes[0].next = "accepted";
|
|
289
|
+
plain.nodes = plain.nodes.slice(0, 2);
|
|
290
|
+
expect(validateDefinition(plain, registry()).errors).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("an inverted band is rejected", () => {
|
|
294
|
+
const { errors } = validateDefinition(
|
|
295
|
+
reviewedDefinition({ band: { min: 0.9, max: 0.4 }, approvers: APPROVERS }),
|
|
296
|
+
registry(),
|
|
297
|
+
);
|
|
298
|
+
expect(errors.join("\n")).toContain("band.min must not exceed band.max");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("a band outside 0..1, a missing edge, and an unknown key are rejected", () => {
|
|
302
|
+
for (const humanReview of [
|
|
303
|
+
{ band: { min: -0.1, max: 0.5 }, approvers: APPROVERS },
|
|
304
|
+
{ band: { min: 0.2, max: 1.5 }, approvers: APPROVERS },
|
|
305
|
+
{ band: { min: 0.2 }, approvers: APPROVERS },
|
|
306
|
+
{ band: { min: 0.2, max: 0.5, step: 1 }, approvers: APPROVERS },
|
|
307
|
+
{ band: { min: 0.2, max: 0.5 } },
|
|
308
|
+
{ ...REVIEW, threshold: 0.5 },
|
|
309
|
+
]) {
|
|
310
|
+
const { errors } = validateDefinition(reviewedDefinition(humanReview), registry());
|
|
311
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
312
|
+
expect(errors.join("\n")).toContain("humanReview");
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a bad timeout is rejected by the same schema human-in-the-loop uses", () => {
|
|
317
|
+
const { errors } = validateDefinition(
|
|
318
|
+
reviewedDefinition({ ...REVIEW, timeout: { seconds: 0, action: "reject" } }),
|
|
319
|
+
registry(),
|
|
320
|
+
);
|
|
321
|
+
expect(errors.join("\n")).toContain("humanReview.timeout.seconds");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("next must map ports so a rejection cannot run the accepted path", () => {
|
|
325
|
+
for (const next of ["accepted", ["accepted", "declined"]] as WorkflowNode["next"][]) {
|
|
326
|
+
const { errors } = validateDefinition(reviewedDefinition(REVIEW, next), registry());
|
|
327
|
+
expect(errors.join("\n")).toContain("sets humanReview, so next must map output ports");
|
|
328
|
+
}
|
|
329
|
+
const noApproved = validateDefinition(
|
|
330
|
+
reviewedDefinition(REVIEW, { rejected: "declined" }),
|
|
331
|
+
registry(),
|
|
332
|
+
);
|
|
333
|
+
expect(noApproved.errors.join("\n")).toContain('must define the "approved" port');
|
|
334
|
+
const stray = validateDefinition(
|
|
335
|
+
reviewedDefinition(REVIEW, { approved: "accepted", pass: "declined" }),
|
|
336
|
+
registry(),
|
|
337
|
+
);
|
|
338
|
+
expect(stray.errors.join("\n")).toContain('got "pass"');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("approved alone, all three ports, and no next at all are valid", () => {
|
|
342
|
+
for (const next of [
|
|
343
|
+
{ approved: "accepted" },
|
|
344
|
+
{ approved: "accepted", rejected: "declined" },
|
|
345
|
+
PORTS,
|
|
346
|
+
]) {
|
|
347
|
+
expect(validateDefinition(reviewedDefinition(REVIEW, next), registry()).errors).toEqual([]);
|
|
348
|
+
}
|
|
349
|
+
expect(
|
|
350
|
+
systemOneStaticShapeViolations({
|
|
351
|
+
id: "decide",
|
|
352
|
+
type: "system-one-decision",
|
|
353
|
+
config: mixedConfig({ humanReview: REVIEW }),
|
|
354
|
+
}),
|
|
355
|
+
).toEqual([]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a node without humanReview may still use a list next", () => {
|
|
359
|
+
expect(
|
|
360
|
+
systemOneStaticShapeViolations({
|
|
361
|
+
id: "decide",
|
|
362
|
+
type: "system-one-decision",
|
|
363
|
+
config: mixedConfig(),
|
|
364
|
+
next: ["a", "b"],
|
|
365
|
+
}),
|
|
366
|
+
).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ─── Confidence ─────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
describe("answerConfidence", () => {
|
|
373
|
+
test("choice and score use the confidence the provider reported", () => {
|
|
374
|
+
expect(answerConfidence(choiceAnswer as never)).toBe(0.75);
|
|
375
|
+
expect(answerConfidence(scoreAnswer as never)).toBe(0.65);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("noul is the probability of the side the model took, and the output gains no field", () => {
|
|
379
|
+
expect(answerConfidence({ type: "noul", noul: 0.9 })).toBe(0.9);
|
|
380
|
+
expect(answerConfidence({ type: "noul", noul: 0.1 })).toBe(0.9);
|
|
381
|
+
expect(answerConfidence({ type: "noul", noul: 0.5 })).toBe(0.5);
|
|
382
|
+
// 1 - 0.35 is 0.6499999999999999 in floating point; it must still count as 0.65.
|
|
383
|
+
expect(answerConfidence({ type: "noul", noul: 0.35 })).toBe(0.65);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ─── Run behaviour ──────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
describe("in the band: the run parks", () => {
|
|
390
|
+
test("waits for a person, with a card built for the answers that need one", async () => {
|
|
391
|
+
const { registry, calls } = makeRegistry();
|
|
392
|
+
const runId = await startRun(
|
|
393
|
+
reviewedDefinition({
|
|
394
|
+
...REVIEW,
|
|
395
|
+
title: "Check the lead",
|
|
396
|
+
timeout: { seconds: 3600, action: "reject" },
|
|
397
|
+
notifications: [{ channel: "slack", target: "C123" }],
|
|
398
|
+
}),
|
|
399
|
+
registry,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
403
|
+
const steps = await getWorkflowRunStepsByRunId(runId);
|
|
404
|
+
expect(steps.map((s) => [s.nodeId, s.status])).toEqual([["decide", "waiting"]]);
|
|
405
|
+
expect(calls()).toBe(1);
|
|
406
|
+
|
|
407
|
+
const approval = await approvalOf(runId);
|
|
408
|
+
expect(approval.status).toBe("pending");
|
|
409
|
+
expect(approval.title).toBe("Check the lead");
|
|
410
|
+
expect(approval.approvers).toEqual(APPROVERS);
|
|
411
|
+
expect(approval.timeoutSeconds).toBe(3600);
|
|
412
|
+
expect(approval.notificationChannels).toEqual([{ channel: "slack", target: "C123" }]);
|
|
413
|
+
expect(approval.workflowRunId).toBe(runId);
|
|
414
|
+
expect(approval.workflowRunStepId).toBe(steps[0]?.id);
|
|
415
|
+
|
|
416
|
+
// Only the answers in the band are asked; `fit` (0.82) is not.
|
|
417
|
+
const questions = approval.questions as Array<Record<string, unknown>>;
|
|
418
|
+
expect(questions.map((q) => q.id)).toEqual(["$confirm", "authority", "urgency"]);
|
|
419
|
+
const [confirm, authority, urgency] = questions;
|
|
420
|
+
expect(confirm).toMatchObject({ type: "approval", required: true });
|
|
421
|
+
expect(String(confirm?.description)).toContain("Ada");
|
|
422
|
+
expect(authority).toMatchObject({ type: "single-select", required: false });
|
|
423
|
+
expect((authority?.options as Array<{ value: string }>).map((o) => o.value)).toEqual([
|
|
424
|
+
"buyer",
|
|
425
|
+
"champion",
|
|
426
|
+
"unknown",
|
|
427
|
+
]);
|
|
428
|
+
expect(String(authority?.description)).toContain('"buyer" at confidence 0.75');
|
|
429
|
+
expect((urgency?.options as Array<{ value: string }>).map((o) => o.value)).toEqual([
|
|
430
|
+
"0",
|
|
431
|
+
"1",
|
|
432
|
+
"2",
|
|
433
|
+
]);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("the model's answer is stored on the waiting step, marked pending", async () => {
|
|
437
|
+
const { registry } = makeRegistry();
|
|
438
|
+
const runId = await startRun(reviewedDefinition(), registry);
|
|
439
|
+
const parked = await decideOutput(runId);
|
|
440
|
+
expect(parked.review?.status).toBe("pending");
|
|
441
|
+
expect(parked.answers.authority).toMatchObject({ choice: "buyer" });
|
|
442
|
+
expect(parked.review?.questions).toEqual({
|
|
443
|
+
fit: { confidence: 0.82, inBand: false, decidedBy: "model" },
|
|
444
|
+
authority: { confidence: 0.75, inBand: true, decidedBy: "model" },
|
|
445
|
+
urgency: { confidence: 0.65, inBand: true, decidedBy: "model" },
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("one answer in the band is enough, and the card asks only about it", async () => {
|
|
450
|
+
const { registry } = makeRegistry();
|
|
451
|
+
const runId = await startRun(
|
|
452
|
+
reviewedDefinition({ band: { min: 0.7, max: 0.8 }, approvers: APPROVERS }),
|
|
453
|
+
registry,
|
|
454
|
+
);
|
|
455
|
+
const questions = (await approvalOf(runId)).questions as Array<{ id: string }>;
|
|
456
|
+
expect(questions.map((q) => q.id)).toEqual(["$confirm", "authority"]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("both ends of the band count as inside it", async () => {
|
|
460
|
+
for (const band of [
|
|
461
|
+
{ min: 0.75, max: 0.9 },
|
|
462
|
+
{ min: 0.5, max: 0.75 },
|
|
463
|
+
]) {
|
|
464
|
+
const { registry } = makeRegistry(
|
|
465
|
+
mixedBody({ urgency: { ...scoreAnswer, confidence: 0.1 } }),
|
|
466
|
+
);
|
|
467
|
+
const runId = await startRun(reviewedDefinition({ band, approvers: APPROVERS }), registry);
|
|
468
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
469
|
+
expect(
|
|
470
|
+
((await approvalOf(runId)).questions as Array<{ id: string }>).map((q) => q.id),
|
|
471
|
+
).toContain("authority");
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a noul answer in the band gets a switch that starts on the model's side", async () => {
|
|
476
|
+
const { registry } = makeRegistry(mixedBody({ fit: { type: "noul", noul: 0.4 } }));
|
|
477
|
+
const runId = await startRun(
|
|
478
|
+
reviewedDefinition({ band: { min: 0.55, max: 0.65 }, approvers: APPROVERS }),
|
|
479
|
+
registry,
|
|
480
|
+
);
|
|
481
|
+
const questions = (await approvalOf(runId)).questions as Array<Record<string, unknown>>;
|
|
482
|
+
const fit = questions.find((q) => q.id === "fit");
|
|
483
|
+
expect(fit).toMatchObject({ type: "boolean", required: false, defaultValue: false });
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("outside the band: the answer passes straight through", () => {
|
|
488
|
+
test("no approval request, and the node leaves through approved", async () => {
|
|
489
|
+
const { registry } = makeRegistry();
|
|
490
|
+
const runId = await startRun(
|
|
491
|
+
reviewedDefinition({ band: { min: 0.1, max: 0.2 }, approvers: APPROVERS }),
|
|
492
|
+
registry,
|
|
493
|
+
);
|
|
494
|
+
expect(await settle(runId)).toBe("completed");
|
|
495
|
+
expect((await getWorkflowRunStepsByRunId(runId)).map((s) => s.nodeId).sort()).toEqual([
|
|
496
|
+
"accepted",
|
|
497
|
+
"decide",
|
|
498
|
+
]);
|
|
499
|
+
const done = await decideOutput(runId);
|
|
500
|
+
expect(done.review).toEqual({
|
|
501
|
+
status: "not_required",
|
|
502
|
+
questions: {
|
|
503
|
+
fit: { confidence: 0.82, inBand: false, decidedBy: "model" },
|
|
504
|
+
authority: { confidence: 0.75, inBand: false, decidedBy: "model" },
|
|
505
|
+
urgency: { confidence: 0.65, inBand: false, decidedBy: "model" },
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
expect(done.answers.authority).toMatchObject({ choice: "buyer" });
|
|
509
|
+
const client = getDbClient();
|
|
510
|
+
expect(
|
|
511
|
+
(await client.get<{ n: number }>("SELECT COUNT(*) AS n FROM approval_requests"))?.n,
|
|
512
|
+
).toBe(0);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("without humanReview nothing changes: no review block, no port", async () => {
|
|
516
|
+
const { registry } = makeRegistry();
|
|
517
|
+
const definition = reviewedDefinition();
|
|
518
|
+
definition.nodes[0].config = mixedConfig();
|
|
519
|
+
definition.nodes[0].next = "accepted";
|
|
520
|
+
definition.nodes = definition.nodes.slice(0, 2);
|
|
521
|
+
const runId = await startRun(definition, registry);
|
|
522
|
+
expect(await settle(runId)).toBe("completed");
|
|
523
|
+
const done = await decideOutput(runId);
|
|
524
|
+
expect(done.review).toBeUndefined();
|
|
525
|
+
expect((await stepOf(runId, "decide")).nextPort ?? null).toBeNull();
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe("approve", () => {
|
|
530
|
+
test("confirming as is: the answers stay, and they are marked human-decided", async () => {
|
|
531
|
+
const { registry } = makeRegistry();
|
|
532
|
+
const runId = await startRun(reviewedDefinition(), registry);
|
|
533
|
+
const approval = await approvalOf(runId);
|
|
534
|
+
|
|
535
|
+
await respond(runId, "approved", { $confirm: { approved: true } });
|
|
536
|
+
expect(await settle(runId)).toBe("completed");
|
|
537
|
+
|
|
538
|
+
const done = await decideOutput(runId);
|
|
539
|
+
expect(done.review).toMatchObject({ status: "approved", approvalRequestId: approval.id });
|
|
540
|
+
expect(done.answers).toEqual(mixedBody().answers);
|
|
541
|
+
expect(done.review?.questions).toEqual({
|
|
542
|
+
fit: { confidence: 0.82, inBand: false, decidedBy: "model" },
|
|
543
|
+
authority: { confidence: 0.75, inBand: true, decidedBy: "human", modelAnswer: "buyer" },
|
|
544
|
+
urgency: { confidence: 0.65, inBand: true, decidedBy: "human", modelAnswer: 1.7 },
|
|
545
|
+
});
|
|
546
|
+
// The accepted branch ran, and it read the final answer through the alias.
|
|
547
|
+
expect((await stepOf(runId, "accepted")).output).toEqual({ label: "accepted:buyer" });
|
|
548
|
+
expect((await stepOf(runId, "decide")).nextPort).toBe("approved");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("a person's answers become the node output, the model's numbers stay the model's", async () => {
|
|
552
|
+
const { registry } = makeRegistry();
|
|
553
|
+
const runId = await startRun(reviewedDefinition(), registry);
|
|
554
|
+
|
|
555
|
+
await respond(runId, "approved", {
|
|
556
|
+
$confirm: { approved: true },
|
|
557
|
+
authority: "champion",
|
|
558
|
+
urgency: "2",
|
|
559
|
+
});
|
|
560
|
+
expect(await settle(runId)).toBe("completed");
|
|
561
|
+
|
|
562
|
+
const done = await decideOutput(runId);
|
|
563
|
+
expect(done.answers.authority).toEqual({
|
|
564
|
+
...choiceAnswer,
|
|
565
|
+
choice: "champion",
|
|
566
|
+
});
|
|
567
|
+
expect(done.answers.urgency).toEqual({ ...scoreAnswer, score: 2 });
|
|
568
|
+
// The answer outside the band is untouched.
|
|
569
|
+
expect(done.answers.fit).toEqual(noulAnswer);
|
|
570
|
+
expect(done.review?.questions.authority).toMatchObject({
|
|
571
|
+
decidedBy: "human",
|
|
572
|
+
modelAnswer: "buyer",
|
|
573
|
+
});
|
|
574
|
+
expect(done.review?.questions.fit?.decidedBy).toBe("model");
|
|
575
|
+
expect((await stepOf(runId, "accepted")).output).toEqual({ label: "accepted:champion" });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("a replaced yes/no becomes certain, a confirmed one keeps the model's probability", async () => {
|
|
579
|
+
const { registry } = makeRegistry(mixedBody({ fit: { type: "noul", noul: 0.6 } }));
|
|
580
|
+
const definition = reviewedDefinition({ band: { min: 0.55, max: 0.65 }, approvers: APPROVERS });
|
|
581
|
+
const flipped = await startRun(definition, registry);
|
|
582
|
+
await respond(flipped, "approved", { $confirm: { approved: true }, fit: false });
|
|
583
|
+
expect(await settle(flipped)).toBe("completed");
|
|
584
|
+
expect((await decideOutput(flipped)).answers.fit).toEqual({ type: "noul", noul: 0 });
|
|
585
|
+
|
|
586
|
+
const kept = await startRun(definition, registry);
|
|
587
|
+
await respond(kept, "approved", { $confirm: { approved: true }, fit: true });
|
|
588
|
+
expect(await settle(kept)).toBe("completed");
|
|
589
|
+
const keptOutput = await decideOutput(kept);
|
|
590
|
+
expect(keptOutput.answers.fit).toEqual({ type: "noul", noul: 0.6 });
|
|
591
|
+
expect(keptOutput.review?.questions.fit).toMatchObject({
|
|
592
|
+
decidedBy: "human",
|
|
593
|
+
modelAnswer: 0.6,
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe("reject and timeout", () => {
|
|
599
|
+
test("a rejection leaves through rejected with the model's answers untouched", async () => {
|
|
600
|
+
const { registry } = makeRegistry();
|
|
601
|
+
const runId = await startRun(reviewedDefinition(), registry);
|
|
602
|
+
const approval = await approvalOf(runId);
|
|
603
|
+
|
|
604
|
+
await respond(runId, "rejected", { $confirm: { approved: false }, authority: "unknown" });
|
|
605
|
+
expect(await settle(runId)).toBe("completed");
|
|
606
|
+
|
|
607
|
+
const done = await decideOutput(runId);
|
|
608
|
+
expect(done.review).toMatchObject({ status: "rejected", approvalRequestId: approval.id });
|
|
609
|
+
// Nobody accepted an answer, so even a selection made on a rejected card is ignored.
|
|
610
|
+
expect(done.answers).toEqual(mixedBody().answers);
|
|
611
|
+
expect(Object.values(done.review?.questions ?? {}).every((q) => q.decidedBy === "model")).toBe(
|
|
612
|
+
true,
|
|
613
|
+
);
|
|
614
|
+
expect((await getWorkflowRunStepsByRunId(runId)).map((s) => s.nodeId).sort()).toEqual([
|
|
615
|
+
"decide",
|
|
616
|
+
"declined",
|
|
617
|
+
]);
|
|
618
|
+
expect((await stepOf(runId, "decide")).nextPort).toBe("rejected");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("no answer in time leaves through timeout", async () => {
|
|
622
|
+
const { registry } = makeRegistry();
|
|
623
|
+
const runId = await startRun(
|
|
624
|
+
reviewedDefinition({ ...REVIEW, timeout: { seconds: 60, action: "reject" } }),
|
|
625
|
+
registry,
|
|
626
|
+
);
|
|
627
|
+
await respond(runId, "timeout");
|
|
628
|
+
expect(await settle(runId)).toBe("completed");
|
|
629
|
+
const done = await decideOutput(runId);
|
|
630
|
+
expect(done.review?.status).toBe("timeout");
|
|
631
|
+
expect(done.answers).toEqual(mixedBody().answers);
|
|
632
|
+
expect((await getWorkflowRunStepsByRunId(runId)).map((s) => s.nodeId).sort()).toEqual([
|
|
633
|
+
"decide",
|
|
634
|
+
"timed-out",
|
|
635
|
+
]);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("a port that `next` does not map ends the branch instead of running another", async () => {
|
|
639
|
+
const { registry } = makeRegistry();
|
|
640
|
+
const runId = await startRun(reviewedDefinition(REVIEW, { approved: "accepted" }), registry);
|
|
641
|
+
await respond(runId, "rejected", { $confirm: { approved: false } });
|
|
642
|
+
expect(await settle(runId)).toBe("completed");
|
|
643
|
+
expect((await getWorkflowRunStepsByRunId(runId)).map((s) => s.nodeId)).toEqual(["decide"]);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe("recovery and re-execution", () => {
|
|
648
|
+
test("an answer that landed while the API was down is applied the same way", async () => {
|
|
649
|
+
const { registry } = makeRegistry();
|
|
650
|
+
const runId = await startRun(reviewedDefinition(), registry);
|
|
651
|
+
// Resolved in the database, but the resume event never reached the listener.
|
|
652
|
+
await respond(
|
|
653
|
+
runId,
|
|
654
|
+
"approved",
|
|
655
|
+
{ $confirm: { approved: true }, authority: "champion" },
|
|
656
|
+
{ emit: false },
|
|
657
|
+
);
|
|
658
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
659
|
+
|
|
660
|
+
await recoverIncompleteRuns(registry);
|
|
661
|
+
expect(await settle(runId)).toBe("completed");
|
|
662
|
+
const done = await decideOutput(runId);
|
|
663
|
+
expect(done.review?.status).toBe("approved");
|
|
664
|
+
expect(done.answers.authority).toMatchObject({ choice: "champion" });
|
|
665
|
+
expect((await stepOf(runId, "accepted")).output).toEqual({ label: "accepted:champion" });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("running the step again reuses the parked decision: no second paid call, no second card", async () => {
|
|
669
|
+
const { registry, executor, calls } = makeRegistry();
|
|
670
|
+
const runId = await startRun(reviewedDefinition(), registry, false);
|
|
671
|
+
const step = await stepOf(runId, "decide");
|
|
672
|
+
const approval = await approvalOf(runId);
|
|
673
|
+
const rerun = () =>
|
|
674
|
+
executor.run({
|
|
675
|
+
config: mixedConfig({ humanReview: REVIEW }),
|
|
676
|
+
context: {},
|
|
677
|
+
meta: {
|
|
678
|
+
runId,
|
|
679
|
+
stepId: step.id,
|
|
680
|
+
nodeId: "decide",
|
|
681
|
+
workflowId: "wf",
|
|
682
|
+
dryRun: false,
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const again = await rerun();
|
|
687
|
+
expect(again).toMatchObject({ status: "success", async: true, correlationId: approval.id });
|
|
688
|
+
expect(calls()).toBe(1);
|
|
689
|
+
expect(
|
|
690
|
+
(await getDbClient().get<{ n: number }>("SELECT COUNT(*) AS n FROM approval_requests"))?.n,
|
|
691
|
+
).toBe(1);
|
|
692
|
+
|
|
693
|
+
// Answered while the step was between attempts: the re-run finishes it.
|
|
694
|
+
await respond(runId, "approved", { $confirm: { approved: true } }, { emit: false });
|
|
695
|
+
const answered = await rerun();
|
|
696
|
+
expect(answered.status).toBe("success");
|
|
697
|
+
expect(answered.nextPort).toBe("approved");
|
|
698
|
+
expect((answered.output as Output).review?.status).toBe("approved");
|
|
699
|
+
expect(calls()).toBe(1);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("a step that lost its parked decision fails with a message, and does not buy another", async () => {
|
|
703
|
+
const { registry, executor, calls } = makeRegistry();
|
|
704
|
+
const runId = await startRun(reviewedDefinition(), registry, false);
|
|
705
|
+
const step = await stepOf(runId, "decide");
|
|
706
|
+
await updateWorkflowRunStep(step.id, { output: {} });
|
|
707
|
+
|
|
708
|
+
const result = await executor.run({
|
|
709
|
+
config: mixedConfig({ humanReview: REVIEW }),
|
|
710
|
+
context: {},
|
|
711
|
+
meta: { runId, stepId: step.id, nodeId: "decide", workflowId: "wf", dryRun: false },
|
|
712
|
+
});
|
|
713
|
+
expect(result.status).toBe("failed");
|
|
714
|
+
expect(result.error).toContain("parked with is missing");
|
|
715
|
+
expect(calls()).toBe(1);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// ─── The resolution step on its own ─────────────────────────
|
|
720
|
+
|
|
721
|
+
describe("resolveReviewedDecision", () => {
|
|
722
|
+
const parked = (): Output =>
|
|
723
|
+
SystemOneDecisionOutputSchema.parse({
|
|
724
|
+
...mixedBody(),
|
|
725
|
+
review: {
|
|
726
|
+
status: "pending",
|
|
727
|
+
questions: {
|
|
728
|
+
fit: { confidence: 0.82, inBand: false, decidedBy: "model" },
|
|
729
|
+
authority: { confidence: 0.75, inBand: true, decidedBy: "model" },
|
|
730
|
+
urgency: { confidence: 0.65, inBand: true, decidedBy: "model" },
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
const approval = (
|
|
735
|
+
status: "approved" | "rejected" | "timeout",
|
|
736
|
+
responses: Record<string, unknown> | null,
|
|
737
|
+
) => ({ requestId: "req-1", status, responses });
|
|
738
|
+
|
|
739
|
+
test("an approved response outside the options is a rejection, never a confirmation", () => {
|
|
740
|
+
for (const responses of [
|
|
741
|
+
{ authority: "nobody" },
|
|
742
|
+
{ authority: 7 },
|
|
743
|
+
{ urgency: "9" },
|
|
744
|
+
{ urgency: 2 },
|
|
745
|
+
]) {
|
|
746
|
+
const outcome = resolveReviewedDecision(parked(), approval("approved", responses));
|
|
747
|
+
expect(outcome?.nextPort).toBe("rejected");
|
|
748
|
+
const output = outcome?.output as Output;
|
|
749
|
+
expect(output.review?.status).toBe("rejected");
|
|
750
|
+
expect(output.review?.reason).toContain("is not one of its options");
|
|
751
|
+
expect(output.answers).toEqual(mixedBody().answers);
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("empty and absent responses confirm the model's answer", () => {
|
|
756
|
+
for (const responses of [null, {}, { authority: "", urgency: null }]) {
|
|
757
|
+
const outcome = resolveReviewedDecision(parked(), approval("approved", responses));
|
|
758
|
+
expect(outcome?.nextPort).toBe("approved");
|
|
759
|
+
expect((outcome?.output as Output).answers).toEqual(mixedBody().answers);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("the result is always a valid node output", () => {
|
|
764
|
+
for (const [status, responses] of [
|
|
765
|
+
["approved", { authority: "champion", urgency: "0" }],
|
|
766
|
+
["rejected", null],
|
|
767
|
+
["timeout", null],
|
|
768
|
+
] as const) {
|
|
769
|
+
const outcome = resolveReviewedDecision(parked(), approval(status, responses));
|
|
770
|
+
expect(SystemOneDecisionOutputSchema.safeParse(outcome?.output).success).toBe(true);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("anything that is not a pending decision returns null", () => {
|
|
775
|
+
for (const value of [null, undefined, {}, { requestId: "x" }, mixedBody()]) {
|
|
776
|
+
expect(resolveReviewedDecision(value, approval("approved", null))).toBeNull();
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("the resume paths fall back to the plain approval output rather than wedge the run", () => {
|
|
781
|
+
const { registry } = makeRegistry();
|
|
782
|
+
const definition = reviewedDefinition();
|
|
783
|
+
const outcome = shapeApprovalResolution(registry, definition, "decide", null, {
|
|
784
|
+
requestId: "req-1",
|
|
785
|
+
status: "rejected",
|
|
786
|
+
responses: { $confirm: { approved: false } },
|
|
787
|
+
});
|
|
788
|
+
expect(outcome).toEqual({
|
|
789
|
+
output: {
|
|
790
|
+
requestId: "req-1",
|
|
791
|
+
status: "rejected",
|
|
792
|
+
responses: { $confirm: { approved: false } },
|
|
793
|
+
},
|
|
794
|
+
nextPort: "rejected",
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// ─── A second backend on the same contract ──────────────────
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* What `@desplega.ai/laya-server` returns from `POST /v1/systemone`: the
|
|
803
|
+
* `agent.predict` result (`SystemOneResult`) plus `routing`. Values follow a live
|
|
804
|
+
* call to laya.agent-swarm.dev (2026-09-29): probabilities rounded to 4 places,
|
|
805
|
+
* a fractional score, an entropy-based `confidence` next to `answer_confidence`
|
|
806
|
+
* (the top probability), and the extra fields laya adds.
|
|
807
|
+
*/
|
|
808
|
+
const layaResult = {
|
|
809
|
+
model: "laya-rl-agent",
|
|
810
|
+
answers: {
|
|
811
|
+
team: {
|
|
812
|
+
type: "choice",
|
|
813
|
+
choice: "technical",
|
|
814
|
+
probabilities: { billing: 0.1801, technical: 0.7302, sales: 0.0897 },
|
|
815
|
+
confidence: 0.5211,
|
|
816
|
+
answer_confidence: 0.7302,
|
|
817
|
+
action: { act_probability: 0.9712 },
|
|
818
|
+
},
|
|
819
|
+
urgent: {
|
|
820
|
+
type: "noul",
|
|
821
|
+
noul: 0.7649,
|
|
822
|
+
confidence: 0.7649,
|
|
823
|
+
answer_confidence: 0.7649,
|
|
824
|
+
action: { act_probability: 0.9 },
|
|
825
|
+
},
|
|
826
|
+
frustration: {
|
|
827
|
+
type: "score",
|
|
828
|
+
score: 1.8249,
|
|
829
|
+
legend: { "0": "calm", "1": "annoyed", "2": "furious" },
|
|
830
|
+
probabilities: { "0": 0.0412, "1": 0.1127, "2": 0.8461 },
|
|
831
|
+
confidence: 0.4183,
|
|
832
|
+
answer_confidence: 0.8461,
|
|
833
|
+
action: { act_probability: 0.88 },
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
usage: { input_tokens: 512, output_tokens: 0 },
|
|
837
|
+
routing: {
|
|
838
|
+
model: "english",
|
|
839
|
+
repo: "desplega/laya-onnx/english/fp32",
|
|
840
|
+
reason: "English Latin text",
|
|
841
|
+
detection: { script: "latin", language: "en", isEnglish: true },
|
|
842
|
+
workflow: null,
|
|
843
|
+
},
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const layaQuestions = {
|
|
847
|
+
team: {
|
|
848
|
+
type: "choice",
|
|
849
|
+
instructions: "Which team handles `message`?",
|
|
850
|
+
criteria: { billing: "charges, invoices", technical: "bugs, outages", sales: "pricing" },
|
|
851
|
+
},
|
|
852
|
+
urgent: { type: "noul", instructions: "Is the customer blocked or on a deadline?" },
|
|
853
|
+
frustration: {
|
|
854
|
+
type: "score",
|
|
855
|
+
instructions: "How frustrated is the customer?",
|
|
856
|
+
criteria: ["calm", "annoyed", "furious"],
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const LAYA_URL = "https://laya.example.test";
|
|
861
|
+
const layaField = SYSTEM_ONE_PROVIDERS.laya.confidenceField;
|
|
862
|
+
|
|
863
|
+
describe("a laya result fits the node's output contract", () => {
|
|
864
|
+
const config = () =>
|
|
865
|
+
SystemOneDecisionConfigSchema.parse({
|
|
866
|
+
provider: "laya",
|
|
867
|
+
state: { message: "Production orders stopped syncing" },
|
|
868
|
+
questions: layaQuestions,
|
|
869
|
+
returns: {
|
|
870
|
+
team: { type: "choice" },
|
|
871
|
+
urgent: { type: "noul" },
|
|
872
|
+
frustration: { type: "score" },
|
|
873
|
+
},
|
|
874
|
+
humanReview: { band: { min: 0.7, max: 0.8 }, approvers: APPROVERS },
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("questions written for laya are valid node questions", () => {
|
|
878
|
+
expect(config().questions.team?.type).toBe("choice");
|
|
879
|
+
expect(config().provider).toBe("laya");
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("the confidence of a choice or score is laya's answer_confidence, the top probability", () => {
|
|
883
|
+
expect(layaField).toBe("answer_confidence");
|
|
884
|
+
const output = validateSystemOneResponse(config().questions, layaResult, undefined, layaField);
|
|
885
|
+
expect(output.model).toBe("laya-rl-agent");
|
|
886
|
+
expect(output.usage).toEqual({ input_tokens: 512, output_tokens: 0 });
|
|
887
|
+
expect(output.answers.team).toEqual({
|
|
888
|
+
type: "choice",
|
|
889
|
+
choice: "technical",
|
|
890
|
+
probabilities: layaResult.answers.team.probabilities,
|
|
891
|
+
confidence: 0.7302,
|
|
892
|
+
});
|
|
893
|
+
expect(output.answers.frustration).toMatchObject({
|
|
894
|
+
type: "score",
|
|
895
|
+
score: 1.8249,
|
|
896
|
+
confidence: 0.8461,
|
|
897
|
+
});
|
|
898
|
+
// laya's own entropy-based number is not kept, and neither is its action head.
|
|
899
|
+
expect(JSON.stringify(output)).not.toContain("0.5211");
|
|
900
|
+
expect(JSON.stringify(output)).not.toContain("answer_confidence");
|
|
901
|
+
expect(JSON.stringify(output)).not.toContain("act_probability");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("the checkpoint that answered is kept as routing.model; the rest of routing is dropped", () => {
|
|
905
|
+
const output = validateSystemOneResponse(config().questions, layaResult, undefined, layaField);
|
|
906
|
+
expect(output.routing).toEqual({ model: "english" });
|
|
907
|
+
expect(output.model).toBe("laya-rl-agent");
|
|
908
|
+
expect(JSON.stringify(output)).not.toContain("laya-onnx");
|
|
909
|
+
expect(JSON.stringify(output)).not.toContain("detection");
|
|
910
|
+
expect(SystemOneDecisionOutputSchema.safeParse(output).success).toBe(true);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("a missing or malformed routing is left out, not a failure", () => {
|
|
914
|
+
const { routing: _routing, ...noRouting } = layaResult;
|
|
915
|
+
for (const routing of [undefined, null, "english", {}, { model: "" }, { model: 3 }]) {
|
|
916
|
+
const body = routing === undefined ? noRouting : { ...layaResult, routing };
|
|
917
|
+
const output = validateSystemOneResponse(config().questions, body, undefined, layaField);
|
|
918
|
+
expect(output.routing).toBeUndefined();
|
|
919
|
+
expect("routing" in output).toBe(false);
|
|
920
|
+
}
|
|
921
|
+
const long = { ...layaResult, routing: { model: "x".repeat(129) } };
|
|
922
|
+
expect(
|
|
923
|
+
validateSystemOneResponse(config().questions, long, undefined, layaField).routing,
|
|
924
|
+
).toBeUndefined();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("usage.windows from predictLong is dropped, not rejected", () => {
|
|
928
|
+
const output = validateSystemOneResponse(
|
|
929
|
+
config().questions,
|
|
930
|
+
{ ...layaResult, usage: { input_tokens: 900, output_tokens: 0, windows: 3 } },
|
|
931
|
+
undefined,
|
|
932
|
+
layaField,
|
|
933
|
+
);
|
|
934
|
+
expect(output.usage).toEqual({ input_tokens: 900, output_tokens: 0 });
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("one band means the same on every question type: team 0.7302 and urgent 0.7649 in 0.7 to 0.8", () => {
|
|
938
|
+
const output = validateSystemOneResponse(config().questions, layaResult, undefined, layaField);
|
|
939
|
+
const inBand = Object.entries(output.answers)
|
|
940
|
+
.filter(([, answer]) => {
|
|
941
|
+
const c = answerConfidence(answer);
|
|
942
|
+
return c >= 0.7 && c <= 0.8;
|
|
943
|
+
})
|
|
944
|
+
.map(([id]) => id);
|
|
945
|
+
expect(inBand).toEqual(["team", "urgent"]);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
const nodeConfig = (overrides: Record<string, unknown> = {}) => ({
|
|
949
|
+
provider: "laya",
|
|
950
|
+
state: { message: "Production orders stopped syncing" },
|
|
951
|
+
questions: layaQuestions,
|
|
952
|
+
returns: {
|
|
953
|
+
team: { type: "choice" },
|
|
954
|
+
urgent: { type: "noul" },
|
|
955
|
+
frustration: { type: "score" },
|
|
956
|
+
},
|
|
957
|
+
...overrides,
|
|
958
|
+
});
|
|
959
|
+
const layaFetch = (calls: { url: string; init: RequestInit }[]) =>
|
|
960
|
+
(async (url: string, init?: RequestInit) => {
|
|
961
|
+
calls.push({ url: String(url), init: init ?? {} });
|
|
962
|
+
return new Response(JSON.stringify(layaResult), {
|
|
963
|
+
status: 200,
|
|
964
|
+
headers: { "content-type": "application/json" },
|
|
965
|
+
});
|
|
966
|
+
}) as unknown as typeof fetch;
|
|
967
|
+
|
|
968
|
+
test("a humanReview band tested on answer_confidence parks, and routing survives the wait", async () => {
|
|
969
|
+
const calls: { url: string; init: RequestInit }[] = [];
|
|
970
|
+
const registry = new ExecutorRegistry();
|
|
971
|
+
registry.register(
|
|
972
|
+
new SystemOneDecisionExecutor(deps, {
|
|
973
|
+
fetch: layaFetch(calls),
|
|
974
|
+
getApiKey: async () => API_KEY,
|
|
975
|
+
getServerUrl: async () => LAYA_URL,
|
|
976
|
+
}),
|
|
977
|
+
);
|
|
978
|
+
registry.register(new MarkerExecutor(deps));
|
|
979
|
+
const marker = (id: string): WorkflowNode => ({ id, type: "marker", config: { label: id } });
|
|
980
|
+
const runId = await startRun(
|
|
981
|
+
{
|
|
982
|
+
nodes: [
|
|
983
|
+
{
|
|
984
|
+
id: "decide",
|
|
985
|
+
type: "system-one-decision",
|
|
986
|
+
config: nodeConfig({
|
|
987
|
+
humanReview: { band: { min: 0.7, max: 0.8 }, approvers: APPROVERS },
|
|
988
|
+
}),
|
|
989
|
+
next: PORTS,
|
|
990
|
+
},
|
|
991
|
+
...Object.values(PORTS).map(marker),
|
|
992
|
+
],
|
|
993
|
+
},
|
|
994
|
+
registry,
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
// team (0.7302) and urgent (0.7649) are in the band; frustration (0.8461) is not.
|
|
998
|
+
expect((await getWorkflowRun(runId))?.status).toBe("waiting");
|
|
999
|
+
const parked = await decideOutput(runId);
|
|
1000
|
+
expect(parked.routing).toEqual({ model: "english" });
|
|
1001
|
+
expect(parked.review?.questions.team).toMatchObject({ confidence: 0.7302, inBand: true });
|
|
1002
|
+
expect(parked.review?.questions.urgent).toMatchObject({ confidence: 0.7649, inBand: true });
|
|
1003
|
+
expect(parked.review?.questions.frustration).toMatchObject({
|
|
1004
|
+
confidence: 0.8461,
|
|
1005
|
+
inBand: false,
|
|
1006
|
+
});
|
|
1007
|
+
const approval = await approvalOf(runId);
|
|
1008
|
+
expect((approval.questions as Array<{ id: string }>).map((q) => q.id)).toEqual([
|
|
1009
|
+
"$confirm",
|
|
1010
|
+
"team",
|
|
1011
|
+
"urgent",
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
1014
|
+
await respond(runId, "approved");
|
|
1015
|
+
expect(await settle(runId)).toBe("completed");
|
|
1016
|
+
const finished = await decideOutput(runId);
|
|
1017
|
+
expect(finished.review?.status).toBe("approved");
|
|
1018
|
+
expect(finished.routing).toEqual({ model: "english" });
|
|
1019
|
+
expect(calls).toHaveLength(1);
|
|
1020
|
+
});
|
|
1021
|
+
});
|