@desplega.ai/agent-swarm 2.7.4 → 2.7.5
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-g3ads55s.js +15 -0
- package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
- package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
- package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
- package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
- package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
- package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
- package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
- package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
- package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
- package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
- package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
- package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
- package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
- package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
- package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
- package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
- package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
- package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
- package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
- package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
- package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
- package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
- package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
- package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
- package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
- package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
- package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
- package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
- package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
- package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
- package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
- package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
- package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
- package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
- package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
- package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
- package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
- package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
- package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
- package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
- package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
- package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
- package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
- package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
- package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
- package/dist/cli-fw2w0kva.js +47 -0
- package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
- package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
- package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
- package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
- package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
- package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
- package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
- package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
- package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
- package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
- package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
- package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
- package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
- package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
- package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
- package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
- package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
- package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
- package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
- package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
- package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
- package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
- package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
- package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
- package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
- package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
- package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
- package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
- package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
- package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
- package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
- package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
- package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
- package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
- package/dist/cli-zh3fp8v2.js +151 -0
- package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
- package/dist/cli.js +18 -18
- package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
- package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
- package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
- package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
- package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
- package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
- package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
- package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
- package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
- package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
- package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
- package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
- package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
- package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
- package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
- package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
- package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
- package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
- package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
- package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
- package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
- package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
- package/dist/fx-adapter-gt6pncj2.js +18 -0
- package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
- package/dist/handlers-vwmba1m2.js +31 -0
- package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
- package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
- package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
- package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
- package/dist/index-5r71rf98.js +32 -0
- package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
- package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
- package/dist/index-anmwxs87.js +29 -0
- package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
- package/dist/index-spp0ra5c.js +36 -0
- package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
- package/dist/lead-fjy418cw.js +69 -0
- package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
- package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
- package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
- package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
- package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
- package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
- package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
- package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
- package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
- package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
- package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
- package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
- package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
- package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
- package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
- package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
- package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
- package/dist/worker-0c5tyzqe.js +69 -0
- package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
- package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
- package/openapi.json +97 -32
- package/package.json +1 -1
- package/src/be/db/session-summaries.ts +8 -2
- package/src/be/db/session-tree.ts +174 -61
- package/src/be/db/session-visibility.ts +5 -4
- package/src/be/db.ts +8 -4
- package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
- package/src/be/model-validation.ts +5 -1
- package/src/be/pricing-normalize.ts +7 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed/schedules-seeder.ts +135 -9
- package/src/be/seed/workflows-seeder.ts +65 -1
- package/src/be/seed-pricing.ts +23 -0
- package/src/be/steering.ts +50 -3
- package/src/be/swarm-config-guard.ts +1 -0
- package/src/commands/provider-credentials.ts +29 -2
- package/src/commands/runner.ts +86 -5
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +27 -1
- package/src/extensions/dispatcher.ts +4 -0
- package/src/http/agents.ts +1 -0
- package/src/http/mcp-bridge.ts +4 -0
- package/src/integrations/chat-sdk/inbound.ts +15 -5
- package/src/integrations/chat-sdk/steering.ts +3 -1
- package/src/kv-reserved-namespaces.ts +2 -0
- package/src/prompts/base-prompt.ts +4 -1
- package/src/prompts/session-templates.ts +26 -0
- package/src/providers/acp-adapter.ts +190 -26
- package/src/providers/acp-targets.ts +306 -6
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +9 -44
- package/src/providers/codex-oauth/flow.ts +24 -3
- package/src/providers/codex-oauth/storage.ts +56 -17
- package/src/providers/codex-oauth/subscription.ts +67 -0
- package/src/providers/fx-adapter.ts +211 -0
- package/src/providers/index.ts +5 -1
- package/src/providers/mcp-loopback-proxy.ts +94 -0
- package/src/providers/pi-mono-adapter.ts +98 -13
- package/src/providers/pricing-sources.md +8 -0
- package/src/providers/reasoning-effort.ts +17 -1
- package/src/providers/types.ts +2 -1
- package/src/scheduler/scheduler.ts +36 -3
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +5 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
- package/src/server.ts +7 -0
- package/src/slack/assistant.ts +17 -1
- package/src/slack/handlers.ts +18 -5
- package/src/slack/left-threads.ts +65 -0
- package/src/slack/steering.ts +3 -0
- package/src/slack/thread-buffer.ts +40 -16
- package/src/tests/acp-adapter.test.ts +79 -0
- package/src/tests/base-prompt.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +30 -0
- package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
- package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
- package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
- package/src/tests/codex-oauth-credential-info.test.ts +40 -0
- package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/explicit-model-validation.test.ts +99 -0
- package/src/tests/extensions-run-context.test.ts +11 -0
- package/src/tests/extensions-steering.test.ts +353 -0
- package/src/tests/extensions-validate.test.ts +5 -0
- package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
- package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
- package/src/tests/fixtures/fake-fx.ts +167 -0
- package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
- package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
- package/src/tests/fx-adapter.test.ts +771 -0
- package/src/tests/grok-adapter.test.ts +36 -0
- package/src/tests/mcp-loopback-proxy.test.ts +193 -0
- package/src/tests/model-tier-resolution.test.ts +3 -3
- package/src/tests/pricing-refresh.test.ts +5 -5
- package/src/tests/prompt-template-session.test.ts +5 -3
- package/src/tests/provider-registration.test.ts +2 -1
- package/src/tests/query-audit-plans.test.ts +3 -2
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/seed-model-profiles.test.ts +2 -2
- package/src/tests/seed-schedules.test.ts +138 -0
- package/src/tests/session-cost-recompute-unit.test.ts +39 -0
- package/src/tests/session-costs-golden.test.ts +26 -0
- package/src/tests/session-summaries-plans.test.ts +120 -0
- package/src/tests/session-summaries.test.ts +72 -12
- package/src/tests/sessions-tree.test.ts +11 -3
- package/src/tests/slack-leave-thread.test.ts +431 -0
- package/src/tests/tool-annotations.test.ts +2 -1
- package/src/tests/workflow-engine-v2.test.ts +42 -0
- package/src/tests/workflow-script-principal.test.ts +207 -7
- package/src/tools/slack-leave-thread.ts +104 -0
- package/src/tools/steer-task.ts +3 -2
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +19 -0
- package/src/utils/codex-auth-failure.ts +30 -0
- package/src/utils/context-window.ts +4 -1
- package/src/utils/credentials.ts +7 -0
- package/src/utils/fx-models.ts +43 -0
- package/src/workflows/cooldown.ts +3 -2
- package/src/workflows/engine.ts +24 -9
- package/templates/schedules/dreaming-daily/config.json +1 -1
- package/templates/schedules/dreaming-daily/content.md +6 -14
- package/templates/skills/swarm-docs/content.md +1 -0
- package/templates/workflows/dreaming/content.md +5 -1
- package/dist/acp-adapter-96dc64m2.js +0 -15
- package/dist/handlers-jg7v74ty.js +0 -31
- package/dist/index-8c1zbz59.js +0 -29
- package/dist/index-8k8tmfxd.js +0 -32
- package/dist/index-hq617xdj.js +0 -36
- package/dist/lead-567kw7x3.js +0 -68
- package/dist/worker-fjdqra6q.js +0 -68
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { createServer, type Server } from "node:http";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { normalizeModelKey } from "../be/pricing-normalize";
|
|
15
|
+
import { buildPricingSeedRows } from "../be/seed-pricing";
|
|
16
|
+
import { createProviderAdapter } from "../providers";
|
|
17
|
+
import {
|
|
18
|
+
FX_SYSTEM_PROMPT_HEAD_BYTES,
|
|
19
|
+
FX_SYSTEM_PROMPT_INLINE_MAX_BYTES,
|
|
20
|
+
fxRunDir,
|
|
21
|
+
fxSessionExists,
|
|
22
|
+
fxSystemPrompt,
|
|
23
|
+
fxTargetProfile,
|
|
24
|
+
rewriteFxEvent,
|
|
25
|
+
steerFx,
|
|
26
|
+
} from "../providers/acp-targets";
|
|
27
|
+
import {
|
|
28
|
+
checkFxCredentials,
|
|
29
|
+
ensureFxSettings,
|
|
30
|
+
FxAdapter,
|
|
31
|
+
mergeFxSettings,
|
|
32
|
+
} from "../providers/fx-adapter";
|
|
33
|
+
import type { ProviderEvent, ProviderSession, ProviderSessionConfig } from "../providers/types";
|
|
34
|
+
import { PROVIDER_STEER_CAPABILITIES } from "../types";
|
|
35
|
+
import { getModelAwareCredentialVars } from "../utils/credentials";
|
|
36
|
+
import { requireFxRoute, resolveFxRoute } from "../utils/fx-models";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Recorded from a live `fx acp` session (fx 0.0.15, openrouter/openai/gpt-5-mini,
|
|
40
|
+
* 2026-10-29) running a prompt that calls the swarm MCP `get-swarm` and a
|
|
41
|
+
* shell command, with one mid-turn steer. Consecutive message and
|
|
42
|
+
* thought chunks are merged, long strings trimmed, ids and paths scrubbed.
|
|
43
|
+
*/
|
|
44
|
+
const FIXTURE_DIR = join(import.meta.dir, "fixtures", "fx");
|
|
45
|
+
const FAKE_FX = join(import.meta.dir, "fixtures", "fake-fx.ts");
|
|
46
|
+
|
|
47
|
+
const tmpDirs: string[] = [];
|
|
48
|
+
|
|
49
|
+
function makeTempDir(): string {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "agent-swarm-fx-"));
|
|
51
|
+
tmpDirs.push(dir);
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let taskCounter = 0;
|
|
60
|
+
|
|
61
|
+
function baseConfig(overrides: Partial<ProviderSessionConfig> = {}): ProviderSessionConfig {
|
|
62
|
+
taskCounter += 1;
|
|
63
|
+
return {
|
|
64
|
+
prompt: "how many agents?",
|
|
65
|
+
systemPrompt: "You are a swarm worker.",
|
|
66
|
+
model: "openrouter/openai/gpt-5-mini",
|
|
67
|
+
role: "worker",
|
|
68
|
+
agentId: "agent-1",
|
|
69
|
+
taskId: `fx-test-${process.pid}-${taskCounter}`,
|
|
70
|
+
apiUrl: "http://swarm.example",
|
|
71
|
+
apiKey: "api-key",
|
|
72
|
+
cwd: makeTempDir(),
|
|
73
|
+
logFile: "/tmp/fx.log",
|
|
74
|
+
env: { PATH: process.env.PATH ?? "", HOME: makeTempDir() },
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ApiStub {
|
|
80
|
+
server: Server;
|
|
81
|
+
/** Resolves once the adapter revoked the session token it minted. */
|
|
82
|
+
revoked: Promise<void>;
|
|
83
|
+
/** Reachable but not an fx loopback name, so the adapter forwards MCP through 127.0.0.1. */
|
|
84
|
+
apiUrl: string;
|
|
85
|
+
mcpRequests: Array<{ authorization: string | undefined; agentId: string | undefined }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function startApiStub(): Promise<ApiStub> {
|
|
89
|
+
const mcpRequests: ApiStub["mcpRequests"] = [];
|
|
90
|
+
let markRevoked!: () => void;
|
|
91
|
+
const revoked = new Promise<void>((resolve) => {
|
|
92
|
+
markRevoked = resolve;
|
|
93
|
+
});
|
|
94
|
+
const server = createServer((req, res) => {
|
|
95
|
+
if (req.method === "POST" && req.url === "/api/sessions/tokens") {
|
|
96
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ tokenId: "tok-1", plaintext: "aseph_fxstubtoken1234567890" }));
|
|
98
|
+
} else if (req.method === "POST" && req.url === "/mcp") {
|
|
99
|
+
mcpRequests.push({
|
|
100
|
+
authorization: req.headers.authorization,
|
|
101
|
+
agentId: req.headers["x-agent-id"] as string | undefined,
|
|
102
|
+
});
|
|
103
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { tools: [] } }));
|
|
105
|
+
} else if (req.method === "DELETE") {
|
|
106
|
+
res.writeHead(204);
|
|
107
|
+
res.end();
|
|
108
|
+
markRevoked();
|
|
109
|
+
} else {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
115
|
+
const { port } = server.address() as import("node:net").AddressInfo;
|
|
116
|
+
// 0.0.0.0 reaches the 127.0.0.1 listener on Linux, but fx would reject it
|
|
117
|
+
// as an MCP URL, like `http://api:3013` in Docker.
|
|
118
|
+
return { server, revoked, apiUrl: `http://0.0.0.0:${port}`, mcpRequests };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A fake `fx` executable: a shell wrapper around `fixtures/fake-fx.ts`. The
|
|
123
|
+
* adapter passes fx an env allowlist, so the fake's own settings ride in the
|
|
124
|
+
* wrapper instead.
|
|
125
|
+
*/
|
|
126
|
+
async function fakeFxBinary(
|
|
127
|
+
mode: "ok" | "steer-ignored" | "wait-for-cancel" = "ok",
|
|
128
|
+
mcpCall = false,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
const binPath = join(makeTempDir(), "fx");
|
|
131
|
+
const fakeEnv = [
|
|
132
|
+
`FAKE_FX_FIXTURE_DIR=${JSON.stringify(FIXTURE_DIR)}`,
|
|
133
|
+
`FAKE_FX_MODE=${mode}`,
|
|
134
|
+
`FAKE_FX_MCP_CALL=${mcpCall ? "1" : "0"}`,
|
|
135
|
+
].join(" ");
|
|
136
|
+
await Bun.write(binPath, `#!/bin/sh\nexec env ${fakeEnv} bun ${JSON.stringify(FAKE_FX)} "$@"\n`);
|
|
137
|
+
chmodSync(binPath, 0o755);
|
|
138
|
+
return binPath;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fxEnv(
|
|
142
|
+
binary: string,
|
|
143
|
+
extra: Record<string, string> = {},
|
|
144
|
+
home: string = makeTempDir(),
|
|
145
|
+
): Record<string, string> {
|
|
146
|
+
return { PATH: process.env.PATH ?? "", HOME: home, FX_BINARY: binary, ...extra };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface Capture {
|
|
150
|
+
argv: string[];
|
|
151
|
+
env: Record<string, string>;
|
|
152
|
+
settings: Record<string, unknown> | null;
|
|
153
|
+
requests: Array<{ method: string; params: Record<string, unknown> }>;
|
|
154
|
+
mcpCall?: { url: string; status: number };
|
|
155
|
+
childPid?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The fake rewrites capture.json as requests arrive, so retry a read that lands mid-write. */
|
|
159
|
+
async function readCapture(cwd: string): Promise<Capture> {
|
|
160
|
+
const deadline = Date.now() + 5_000;
|
|
161
|
+
while (true) {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(await Bun.file(join(cwd, "capture.json")).text());
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (Date.now() > deadline) throw error;
|
|
166
|
+
await Bun.sleep(10);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function collect(session: ProviderSession): ProviderEvent[] {
|
|
172
|
+
const events: ProviderEvent[] = [];
|
|
173
|
+
session.onEvent((event) => events.push(event));
|
|
174
|
+
return events;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
|
|
178
|
+
const deadline = Date.now() + timeoutMs;
|
|
179
|
+
while (!predicate()) {
|
|
180
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the fake fx");
|
|
181
|
+
await Bun.sleep(10);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Close the stub once the token revoke landed (bounded, so a failed test still tears down). */
|
|
186
|
+
async function closeStub(stub: ApiStub): Promise<void> {
|
|
187
|
+
await Promise.race([stub.revoked, Bun.sleep(3_000)]);
|
|
188
|
+
await new Promise<void>((resolve) => stub.server.close(() => resolve()));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
describe("FxAdapter registration", () => {
|
|
192
|
+
test("is the fx provider on the ACP client, with steer only", async () => {
|
|
193
|
+
const adapter = await createProviderAdapter("fx");
|
|
194
|
+
expect(adapter).toBeInstanceOf(FxAdapter);
|
|
195
|
+
expect(adapter.name).toBe("fx");
|
|
196
|
+
expect(adapter.traits).toMatchObject({
|
|
197
|
+
hasMcp: true,
|
|
198
|
+
hasLocalEnvironment: true,
|
|
199
|
+
nativeSkillDiscovery: true,
|
|
200
|
+
steerModes: ["steer"],
|
|
201
|
+
});
|
|
202
|
+
expect(adapter.traits.steerModes).toEqual(PROVIDER_STEER_CAPABILITIES.fx);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("credential readiness needs one of the three route keys", () => {
|
|
206
|
+
expect(checkFxCredentials({})).toMatchObject({
|
|
207
|
+
ready: false,
|
|
208
|
+
missing: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "AI_GATEWAY_API_KEY"],
|
|
209
|
+
});
|
|
210
|
+
expect(checkFxCredentials({ OPENROUTER_API_KEY: " " }).ready).toBe(false);
|
|
211
|
+
for (const key of ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "AI_GATEWAY_API_KEY"]) {
|
|
212
|
+
expect(checkFxCredentials({ [key]: "k" })).toMatchObject({ ready: true, satisfiedBy: "env" });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("fx model routes", () => {
|
|
218
|
+
test("each route names its fx provider, model and key", () => {
|
|
219
|
+
expect(resolveFxRoute("openrouter/deepseek/deepseek-v4.1-flash")).toEqual({
|
|
220
|
+
model: "openrouter/deepseek/deepseek-v4.1-flash",
|
|
221
|
+
fxProvider: "openrouter",
|
|
222
|
+
fxModel: "deepseek/deepseek-v4.1-flash",
|
|
223
|
+
keyVar: "OPENROUTER_API_KEY",
|
|
224
|
+
});
|
|
225
|
+
expect(resolveFxRoute("vercel/anthropic/claude-opus-5.5")).toEqual({
|
|
226
|
+
model: "vercel/anthropic/claude-opus-5.5",
|
|
227
|
+
fxProvider: "gateway",
|
|
228
|
+
fxModel: "anthropic/claude-opus-5.5",
|
|
229
|
+
keyVar: "AI_GATEWAY_API_KEY",
|
|
230
|
+
});
|
|
231
|
+
expect(resolveFxRoute("gpt-5-mini")).toEqual({
|
|
232
|
+
model: "gpt-5-mini",
|
|
233
|
+
fxProvider: "openai",
|
|
234
|
+
fxModel: "gpt-5-mini",
|
|
235
|
+
keyVar: "OPENAI_API_KEY",
|
|
236
|
+
});
|
|
237
|
+
// No model means the regular tier default.
|
|
238
|
+
expect(resolveFxRoute("")).toMatchObject({
|
|
239
|
+
model: "openrouter/deepseek/deepseek-v4.1-flash",
|
|
240
|
+
fxProvider: "openrouter",
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("nan/, helmcode/ and anthropic/ are rejected with a reason", () => {
|
|
245
|
+
expect(() => requireFxRoute("nan/glm5.3-flash")).toThrow("InconsistentFinishReason");
|
|
246
|
+
expect(() => requireFxRoute("helmcode/qwen3.6")).toThrow("Helmcode is not verified on fx");
|
|
247
|
+
expect(() => requireFxRoute("anthropic/claude-opus-5-5")).toThrow("Anthropic Messages API");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("the credential pool picks the key the model's route bills", () => {
|
|
251
|
+
expect(getModelAwareCredentialVars("fx", "openrouter/openai/gpt-5-mini")).toEqual([
|
|
252
|
+
"OPENROUTER_API_KEY",
|
|
253
|
+
]);
|
|
254
|
+
expect(getModelAwareCredentialVars("fx", "vercel/openai/gpt-5-mini")).toEqual([
|
|
255
|
+
"AI_GATEWAY_API_KEY",
|
|
256
|
+
]);
|
|
257
|
+
expect(getModelAwareCredentialVars("fx", "gpt-5-mini")).toEqual(["OPENAI_API_KEY"]);
|
|
258
|
+
expect(getModelAwareCredentialVars("fx")).toEqual([
|
|
259
|
+
"OPENROUTER_API_KEY",
|
|
260
|
+
"OPENAI_API_KEY",
|
|
261
|
+
"AI_GATEWAY_API_KEY",
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("each route's session env carries only that route's key", () => {
|
|
266
|
+
const keys = {
|
|
267
|
+
OPENROUTER_API_KEY: "or-k",
|
|
268
|
+
OPENAI_API_KEY: "oa-k",
|
|
269
|
+
AI_GATEWAY_API_KEY: "gw-k",
|
|
270
|
+
ANTHROPIC_API_KEY: "must-not-leak",
|
|
271
|
+
XAI_API_KEY: "must-not-leak",
|
|
272
|
+
};
|
|
273
|
+
const cases = [
|
|
274
|
+
["openrouter/openai/gpt-5-mini", "openrouter", "OPENROUTER_API_KEY"],
|
|
275
|
+
["gpt-5-mini", "openai", "OPENAI_API_KEY"],
|
|
276
|
+
["vercel/openai/gpt-5-mini", "gateway", "AI_GATEWAY_API_KEY"],
|
|
277
|
+
] as const;
|
|
278
|
+
for (const [model, fxProvider, keyVar] of cases) {
|
|
279
|
+
const config = baseConfig({ model, env: { PATH: "/bin", HOME: "/home/w", ...keys } });
|
|
280
|
+
const env = fxTargetProfile.env(config);
|
|
281
|
+
expect(env).toMatchObject({
|
|
282
|
+
PATH: "/bin",
|
|
283
|
+
HOME: "/home/w",
|
|
284
|
+
[keyVar]: keys[keyVar],
|
|
285
|
+
FX_PROVIDER: fxProvider,
|
|
286
|
+
FX_PERMISSION_MODE: "full-access",
|
|
287
|
+
FX_AUTO_UPGRADE: "0",
|
|
288
|
+
FX_NO_OPEN_BROWSER: "1",
|
|
289
|
+
});
|
|
290
|
+
expect(Object.keys(env).filter((name) => name.endsWith("_API_KEY"))).toEqual([keyVar]);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("effort is set only on the AI Gateway route; mode, provider and model never", () => {
|
|
295
|
+
const gateway = baseConfig({ model: "vercel/openai/gpt-5-mini", reasoningEffort: "high" });
|
|
296
|
+
expect(fxTargetProfile.configuredOptions(gateway)).toEqual({ effort: "high" });
|
|
297
|
+
expect(
|
|
298
|
+
fxTargetProfile.configuredOptions(
|
|
299
|
+
baseConfig({ model: "openrouter/openai/gpt-5-mini", reasoningEffort: "high" }),
|
|
300
|
+
),
|
|
301
|
+
).toEqual({});
|
|
302
|
+
expect(fxTargetProfile.configuredOptions(baseConfig({ model: "gpt-5-mini" }))).toEqual({});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("pricing strips the route prefix and fx has rows on every route", () => {
|
|
306
|
+
expect(normalizeModelKey("fx", "openrouter/openai/gpt-5-mini")).toBe("openai/gpt-5-mini");
|
|
307
|
+
expect(normalizeModelKey("fx", "vercel/acme/gateway-only")).toBe("acme/gateway-only");
|
|
308
|
+
expect(normalizeModelKey("fx", "gpt-5-mini")).toBe("gpt-5-mini");
|
|
309
|
+
const rows = buildPricingSeedRows({
|
|
310
|
+
openai: { models: { "gpt-5-mini": { cost: { input: 0.25, output: 2 } } } },
|
|
311
|
+
openrouter: { models: { "openai/gpt-5-mini": { cost: { input: 0.25, output: 2 } } } },
|
|
312
|
+
vercel: {
|
|
313
|
+
models: {
|
|
314
|
+
"openai/gpt-5-mini": { cost: { input: 9, output: 9 } },
|
|
315
|
+
"acme/gateway-only": { cost: { input: 1, output: 3 } },
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
}).filter((row) => row.provider === "fx" && row.tokenClass === "input");
|
|
319
|
+
expect(Object.fromEntries(rows.map((row) => [row.model, row.pricePerMillionUsd]))).toEqual({
|
|
320
|
+
"gpt-5-mini": 0.25,
|
|
321
|
+
// OpenRouter's row wins where both list the model.
|
|
322
|
+
"openai/gpt-5-mini": 0.25,
|
|
323
|
+
"acme/gateway-only": 1,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("fx settings", () => {
|
|
329
|
+
const route = requireFxRoute("openrouter/openai/gpt-5-mini");
|
|
330
|
+
|
|
331
|
+
test("merges the swarm connections and keeps every user key", () => {
|
|
332
|
+
const user = {
|
|
333
|
+
provider: "gateway",
|
|
334
|
+
theme: "dark",
|
|
335
|
+
providers: {
|
|
336
|
+
local: { protocol: "openai-chat-completions", base_url: "http://localhost:11434/v1" },
|
|
337
|
+
openrouter: {
|
|
338
|
+
base_url: "https://stale.example/v1",
|
|
339
|
+
tool_choice_mode: "send",
|
|
340
|
+
model_metadata: { "openai/gpt-5-mini": { context_window: 1234 } },
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
workspaces: { "/repo": { max_agent_steps: 80 } },
|
|
344
|
+
};
|
|
345
|
+
const merged = mergeFxSettings(user, {
|
|
346
|
+
route,
|
|
347
|
+
contextWindow: 400_000,
|
|
348
|
+
openRouterBaseUrl: "https://openrouter.ai/api/v1",
|
|
349
|
+
});
|
|
350
|
+
expect(merged).toEqual({
|
|
351
|
+
provider: "gateway",
|
|
352
|
+
theme: "dark",
|
|
353
|
+
providers: {
|
|
354
|
+
local: user.providers.local,
|
|
355
|
+
openrouter: {
|
|
356
|
+
base_url: "https://openrouter.ai/api/v1",
|
|
357
|
+
tool_choice_mode: "send",
|
|
358
|
+
// An existing entry for the model wins.
|
|
359
|
+
model_metadata: { "openai/gpt-5-mini": { context_window: 1234 } },
|
|
360
|
+
protocol: "openai-chat-completions",
|
|
361
|
+
auth: { type: "bearer", env: "OPENROUTER_API_KEY" },
|
|
362
|
+
},
|
|
363
|
+
openai: {
|
|
364
|
+
protocol: "openai-chat-completions",
|
|
365
|
+
base_url: "https://api.openai.com/v1",
|
|
366
|
+
auth: { type: "bearer", env: "OPENAI_API_KEY" },
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
workspaces: user.workspaces,
|
|
370
|
+
auto_upgrade: false,
|
|
371
|
+
session_titles: false,
|
|
372
|
+
});
|
|
373
|
+
// The input is not mutated.
|
|
374
|
+
expect(user.providers.openrouter.base_url).toBe("https://stale.example/v1");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("writes atomically, names key vars only, and is idempotent", async () => {
|
|
378
|
+
const home = makeTempDir();
|
|
379
|
+
const input = {
|
|
380
|
+
home,
|
|
381
|
+
route,
|
|
382
|
+
contextWindow: 400_000,
|
|
383
|
+
openRouterBaseUrl: "https://openrouter.ai/api/v1",
|
|
384
|
+
};
|
|
385
|
+
const first = await ensureFxSettings(input);
|
|
386
|
+
expect(first.changed).toBe(true);
|
|
387
|
+
const text = await Bun.file(first.path).text();
|
|
388
|
+
const settings = JSON.parse(text);
|
|
389
|
+
expect(settings.providers.openrouter.model_metadata).toEqual({
|
|
390
|
+
"openai/gpt-5-mini": { context_window: 400_000 },
|
|
391
|
+
});
|
|
392
|
+
expect(text).not.toContain("sk-");
|
|
393
|
+
expect((await Bun.file(first.path).stat()).mode & 0o777).toBe(0o600);
|
|
394
|
+
const second = await ensureFxSettings(input);
|
|
395
|
+
expect(second.changed).toBe(false);
|
|
396
|
+
expect(await Bun.file(first.path).text()).toBe(text);
|
|
397
|
+
// No temp file is left behind.
|
|
398
|
+
expect(readdirSync(join(home, ".fx"))).toEqual(["settings.json"]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("refuses to overwrite a settings file that is not a JSON object", async () => {
|
|
402
|
+
const home = makeTempDir();
|
|
403
|
+
mkdirSync(join(home, ".fx"));
|
|
404
|
+
const path = join(home, ".fx", "settings.json");
|
|
405
|
+
await Bun.write(path, "{ not json");
|
|
406
|
+
await expect(
|
|
407
|
+
ensureFxSettings({ home, route, openRouterBaseUrl: "https://openrouter.ai/api/v1" }),
|
|
408
|
+
).rejects.toThrow("is not valid JSON");
|
|
409
|
+
expect(await Bun.file(path).text()).toBe("{ not json");
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("fx system prompt", () => {
|
|
414
|
+
test("up to 60 KiB goes inline", async () => {
|
|
415
|
+
const dir = join(makeTempDir(), "run");
|
|
416
|
+
const prompt = "é".repeat(FX_SYSTEM_PROMPT_INLINE_MAX_BYTES / 2);
|
|
417
|
+
expect(await fxSystemPrompt(prompt, dir)).toEqual({ blocks: [{ type: "text", text: prompt }] });
|
|
418
|
+
expect(existsSync(join(dir, "SYSTEM.md"))).toBe(false);
|
|
419
|
+
expect(await fxSystemPrompt(" ", dir)).toBeUndefined();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("over 60 KiB sends the first 56 KiB plus a pointer to the full file", async () => {
|
|
423
|
+
const dir = join(makeTempDir(), "run");
|
|
424
|
+
// A two-byte character straddles the 56 KiB cut.
|
|
425
|
+
const prompt = `a${"é".repeat(40_000)}TAIL-MARKER`;
|
|
426
|
+
const plan = await fxSystemPrompt(prompt, dir);
|
|
427
|
+
const artifactPath = join(dir, "SYSTEM.md");
|
|
428
|
+
expect(plan?.artifactPath).toBe(artifactPath);
|
|
429
|
+
expect(await Bun.file(artifactPath).text()).toBe(prompt);
|
|
430
|
+
const block = plan?.blocks[0];
|
|
431
|
+
const text = block?.type === "text" ? block.text : "";
|
|
432
|
+
const [head, pointer] = text.split("\n\n");
|
|
433
|
+
expect(new TextEncoder().encode(head).length).toBeLessThanOrEqual(FX_SYSTEM_PROMPT_HEAD_BYTES);
|
|
434
|
+
expect(new TextEncoder().encode(head).length).toBeGreaterThan(FX_SYSTEM_PROMPT_HEAD_BYTES - 2);
|
|
435
|
+
expect(head).not.toContain("�");
|
|
436
|
+
expect(prompt.startsWith(head ?? "")).toBe(true);
|
|
437
|
+
expect(pointer).toContain(`Read ${artifactPath} before you start`);
|
|
438
|
+
expect(new TextEncoder().encode(text).length).toBeLessThan(64 * 1024);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("fx events and steering", () => {
|
|
443
|
+
test("the [context] notice becomes a custom event; tool starts carry fx's tool name", () => {
|
|
444
|
+
expect(
|
|
445
|
+
rewriteFxEvent({
|
|
446
|
+
type: "message",
|
|
447
|
+
role: "assistant",
|
|
448
|
+
content: "[context] 114 always-loaded MCP tools exceeded the budget",
|
|
449
|
+
messageId: "m1",
|
|
450
|
+
}),
|
|
451
|
+
).toEqual({
|
|
452
|
+
type: "custom",
|
|
453
|
+
name: "fx_context_notice",
|
|
454
|
+
data: { text: "[context] 114 always-loaded MCP tools exceeded the budget", messageId: "m1" },
|
|
455
|
+
});
|
|
456
|
+
const plain = { type: "message", role: "assistant", content: "[context] is fine" } as const;
|
|
457
|
+
expect(rewriteFxEvent({ ...plain, content: "Hello" })).toEqual({ ...plain, content: "Hello" });
|
|
458
|
+
const start = { type: "tool_start", toolCallId: "t", toolName: "Running", args: {} } as const;
|
|
459
|
+
expect(
|
|
460
|
+
rewriteFxEvent(start, {
|
|
461
|
+
sessionUpdate: "tool_call",
|
|
462
|
+
toolCallId: "t",
|
|
463
|
+
title: "Running",
|
|
464
|
+
name: "shell",
|
|
465
|
+
} as never),
|
|
466
|
+
).toEqual({ ...start, toolName: "shell" });
|
|
467
|
+
expect(
|
|
468
|
+
rewriteFxEvent(start, {
|
|
469
|
+
sessionUpdate: "tool_call",
|
|
470
|
+
toolCallId: "t",
|
|
471
|
+
title: "Get the agent swarm",
|
|
472
|
+
name: "mcp_swarm_get-swarm",
|
|
473
|
+
_meta: { fx: { toolCall: { mcp: { server: "swarm", tool: "get-swarm" } } } },
|
|
474
|
+
} as never),
|
|
475
|
+
).toEqual({ ...start, toolName: "mcp__swarm__get-swarm" });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("steering needs the advertised capability and the steer mode", async () => {
|
|
479
|
+
const prompt = () => Promise.reject(new Error("must not be called"));
|
|
480
|
+
const context = {
|
|
481
|
+
sessionId: "s",
|
|
482
|
+
prompt,
|
|
483
|
+
onUpdate: () => () => {},
|
|
484
|
+
};
|
|
485
|
+
expect(
|
|
486
|
+
await steerFx({
|
|
487
|
+
...context,
|
|
488
|
+
delivery: { mode: "steer", text: "x" },
|
|
489
|
+
initResult: { protocolVersion: 1 },
|
|
490
|
+
}),
|
|
491
|
+
).toEqual({ delivered: false, reason: "fx did not advertise _meta.fx.steering" });
|
|
492
|
+
expect(
|
|
493
|
+
await steerFx({
|
|
494
|
+
...context,
|
|
495
|
+
delivery: { mode: "queue", text: "x" },
|
|
496
|
+
initResult: {
|
|
497
|
+
protocolVersion: 1,
|
|
498
|
+
agentCapabilities: { _meta: { fx: { steering: true } } },
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
).toEqual({ delivered: false, reason: "fx supports steer only, not queue" });
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("FxAdapter sessions", () => {
|
|
506
|
+
test("runs a recorded session: loopback MCP, system prompt, steer, usage, no mode writes", async () => {
|
|
507
|
+
const fx = await fakeFxBinary("ok", true);
|
|
508
|
+
const stub = await startApiStub();
|
|
509
|
+
try {
|
|
510
|
+
const home = makeTempDir();
|
|
511
|
+
const config = baseConfig({
|
|
512
|
+
apiUrl: stub.apiUrl,
|
|
513
|
+
env: fxEnv(
|
|
514
|
+
fx,
|
|
515
|
+
{
|
|
516
|
+
OPENROUTER_API_KEY: "or-test-key",
|
|
517
|
+
OPENAI_API_KEY: "must-not-leak",
|
|
518
|
+
ANTHROPIC_API_KEY: "must-not-leak",
|
|
519
|
+
},
|
|
520
|
+
home,
|
|
521
|
+
),
|
|
522
|
+
});
|
|
523
|
+
const session = await new FxAdapter().createSession(config);
|
|
524
|
+
const events = collect(session);
|
|
525
|
+
const getSwarmDone = () =>
|
|
526
|
+
events.some(
|
|
527
|
+
(e) => e.type === "tool_end" && e.toolCallId === "call_kK1rqCaYaXn16uKH9e6fmb7v",
|
|
528
|
+
);
|
|
529
|
+
await waitFor(getSwarmDone);
|
|
530
|
+
expect(session.deliverSteering).toBeDefined();
|
|
531
|
+
const steer = await session.deliverSteering!({ mode: "steer", text: "Also say STEERED." });
|
|
532
|
+
expect(steer).toEqual({ delivered: true, mode: "steer" });
|
|
533
|
+
const result = await session.waitForCompletion();
|
|
534
|
+
|
|
535
|
+
expect(result.isError).toBe(false);
|
|
536
|
+
// The [context] notice is not task output.
|
|
537
|
+
expect(result.output).not.toContain("[context]");
|
|
538
|
+
expect(result.output).toContain("STEERED BANANA");
|
|
539
|
+
expect(
|
|
540
|
+
events.find((e) => e.type === "custom" && e.name === "fx_context_notice"),
|
|
541
|
+
).toBeDefined();
|
|
542
|
+
expect(result.cost).toMatchObject({
|
|
543
|
+
provider: "fx",
|
|
544
|
+
model: "openrouter/openai/gpt-5-mini",
|
|
545
|
+
inputTokens: 58620,
|
|
546
|
+
outputTokens: 1183,
|
|
547
|
+
totalCostUsd: 0,
|
|
548
|
+
});
|
|
549
|
+
expect(events.find((e) => e.type === "session_init")).toMatchObject({
|
|
550
|
+
provider: "fx",
|
|
551
|
+
sessionId: "fxSession001",
|
|
552
|
+
harnessVariantMeta: { version: "0.0.15" },
|
|
553
|
+
});
|
|
554
|
+
expect(
|
|
555
|
+
events
|
|
556
|
+
.filter((e) => e.type === "tool_start")
|
|
557
|
+
.map((e) => e.type === "tool_start" && e.toolName),
|
|
558
|
+
).toEqual(["mcp__swarm__get-swarm", "shell"]);
|
|
559
|
+
expect(events.find((e) => e.type === "context_usage")).toMatchObject({
|
|
560
|
+
contextUsedTokens: 20196,
|
|
561
|
+
contextTotalTokens: 400000,
|
|
562
|
+
});
|
|
563
|
+
// The steer echo carries the delivered text.
|
|
564
|
+
expect(
|
|
565
|
+
events.some(
|
|
566
|
+
(e) => e.type === "message" && e.role === "user" && e.content === "Also say STEERED.",
|
|
567
|
+
),
|
|
568
|
+
).toBe(true);
|
|
569
|
+
|
|
570
|
+
const capture = await readCapture(config.cwd);
|
|
571
|
+
expect(capture.argv).toEqual([
|
|
572
|
+
"acp",
|
|
573
|
+
"--model",
|
|
574
|
+
"openai/gpt-5-mini",
|
|
575
|
+
"--log-file",
|
|
576
|
+
join(fxRunDir(config), "acp.log"),
|
|
577
|
+
]);
|
|
578
|
+
// Only the route's key crosses the env allowlist, next to fx's own switches.
|
|
579
|
+
expect(capture.env).toEqual({
|
|
580
|
+
OPENROUTER_API_KEY: "or-test-key",
|
|
581
|
+
FX_PROVIDER: "openrouter",
|
|
582
|
+
FX_PERMISSION_MODE: "full-access",
|
|
583
|
+
FX_AUTO_UPGRADE: "0",
|
|
584
|
+
FX_NO_OPEN_BROWSER: "1",
|
|
585
|
+
});
|
|
586
|
+
const methods = capture.requests.map((r) => r.method);
|
|
587
|
+
expect(methods).toEqual(["initialize", "session/new", "session/prompt", "session/prompt"]);
|
|
588
|
+
const newSession = capture.requests[1]!.params as {
|
|
589
|
+
systemPrompt: unknown;
|
|
590
|
+
mcpServers: Array<{ name: string; url: string }>;
|
|
591
|
+
};
|
|
592
|
+
expect(newSession.systemPrompt).toEqual([{ type: "text", text: "You are a swarm worker." }]);
|
|
593
|
+
// fx rejects http://<non-loopback>/mcp, so the swarm MCP goes through a 127.0.0.1 forwarder.
|
|
594
|
+
const swarm = newSession.mcpServers.find((s) => s.name === "swarm");
|
|
595
|
+
expect(swarm?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
|
|
596
|
+
expect(capture.mcpCall).toMatchObject({ status: 200 });
|
|
597
|
+
expect(stub.mcpRequests).toEqual([
|
|
598
|
+
{ authorization: "Bearer aseph_fxstubtoken1234567890", agentId: "agent-1" },
|
|
599
|
+
]);
|
|
600
|
+
const steerRequest = capture.requests[3]!.params as { _meta: unknown };
|
|
601
|
+
expect(steerRequest._meta).toEqual({ fx: { steer: true } });
|
|
602
|
+
// The settings file names the key's env var, never its value.
|
|
603
|
+
expect(capture.settings).toMatchObject({
|
|
604
|
+
providers: {
|
|
605
|
+
openrouter: {
|
|
606
|
+
auth: { type: "bearer", env: "OPENROUTER_API_KEY" },
|
|
607
|
+
model_metadata: { "openai/gpt-5-mini": { context_window: expect.any(Number) } },
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
auto_upgrade: false,
|
|
611
|
+
session_titles: false,
|
|
612
|
+
});
|
|
613
|
+
expect(JSON.stringify(capture.settings)).not.toContain("or-test-key");
|
|
614
|
+
|
|
615
|
+
// The forwarder and the per-task run dir are gone once the session settles.
|
|
616
|
+
await Bun.sleep(50);
|
|
617
|
+
await expect(fetch(swarm!.url, { method: "POST", body: "{}" })).rejects.toThrow();
|
|
618
|
+
expect(existsSync(fxRunDir(config))).toBe(false);
|
|
619
|
+
} finally {
|
|
620
|
+
await closeStub(stub);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("a steer fx does not absorb is undeliverable", async () => {
|
|
625
|
+
const fx = await fakeFxBinary("steer-ignored");
|
|
626
|
+
const stub = await startApiStub();
|
|
627
|
+
try {
|
|
628
|
+
const config = baseConfig({
|
|
629
|
+
apiUrl: stub.apiUrl,
|
|
630
|
+
env: fxEnv(fx, { OPENROUTER_API_KEY: "k" }),
|
|
631
|
+
});
|
|
632
|
+
const session = await new FxAdapter().createSession(config);
|
|
633
|
+
const events = collect(session);
|
|
634
|
+
await waitFor(() => events.some((e) => e.type === "tool_end"));
|
|
635
|
+
const steer = await session.deliverSteering!({ mode: "steer", text: "ignored" });
|
|
636
|
+
expect(steer).toMatchObject({ delivered: false });
|
|
637
|
+
expect(steer.delivered === false && steer.reason).toContain("fx did not absorb the steer");
|
|
638
|
+
await session.waitForCompletion();
|
|
639
|
+
expect(await session.deliverSteering!({ mode: "steer", text: "too late" })).toEqual({
|
|
640
|
+
delivered: false,
|
|
641
|
+
reason: "The ACP session has already finished",
|
|
642
|
+
});
|
|
643
|
+
} finally {
|
|
644
|
+
await closeStub(stub);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("an oversized system prompt is sent cut, with the full prompt on disk", async () => {
|
|
649
|
+
const fx = await fakeFxBinary();
|
|
650
|
+
const stub = await startApiStub();
|
|
651
|
+
try {
|
|
652
|
+
const systemPrompt = `${"x".repeat(FX_SYSTEM_PROMPT_INLINE_MAX_BYTES)}END`;
|
|
653
|
+
const config = baseConfig({
|
|
654
|
+
apiUrl: stub.apiUrl,
|
|
655
|
+
systemPrompt,
|
|
656
|
+
env: fxEnv(fx, { OPENROUTER_API_KEY: "k" }),
|
|
657
|
+
});
|
|
658
|
+
const session = await new FxAdapter().createSession(config);
|
|
659
|
+
const capture = await readCapture(config.cwd);
|
|
660
|
+
const sent = (capture.requests[1]!.params.systemPrompt as Array<{ text: string }>)[0]!.text;
|
|
661
|
+
const artifact = join(fxRunDir(config), "SYSTEM.md");
|
|
662
|
+
expect(sent).toContain(`Read ${artifact} before you start`);
|
|
663
|
+
expect(sent).not.toContain("END");
|
|
664
|
+
expect(await Bun.file(artifact).text()).toBe(systemPrompt);
|
|
665
|
+
await session.waitForCompletion();
|
|
666
|
+
await Bun.sleep(50);
|
|
667
|
+
expect(existsSync(artifact)).toBe(false);
|
|
668
|
+
} finally {
|
|
669
|
+
await closeStub(stub);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("resumes a saved session with session/resume instead of session/new", async () => {
|
|
674
|
+
const fx = await fakeFxBinary();
|
|
675
|
+
const stub = await startApiStub();
|
|
676
|
+
try {
|
|
677
|
+
const home = makeTempDir();
|
|
678
|
+
const config = baseConfig({
|
|
679
|
+
apiUrl: stub.apiUrl,
|
|
680
|
+
resumeSessionId: "fxSession001",
|
|
681
|
+
env: fxEnv(fx, { OPENROUTER_API_KEY: "k" }, home),
|
|
682
|
+
});
|
|
683
|
+
const session = await new FxAdapter().createSession(config);
|
|
684
|
+
expect(session.sessionId).toBe("fxSession001");
|
|
685
|
+
await session.waitForCompletion();
|
|
686
|
+
const capture = await readCapture(config.cwd);
|
|
687
|
+
expect(capture.requests.map((r) => r.method)).toEqual([
|
|
688
|
+
"initialize",
|
|
689
|
+
"session/resume",
|
|
690
|
+
"session/prompt",
|
|
691
|
+
]);
|
|
692
|
+
expect(capture.requests[1]!.params).toMatchObject({
|
|
693
|
+
sessionId: "fxSession001",
|
|
694
|
+
cwd: config.cwd,
|
|
695
|
+
mcpServers: [expect.objectContaining({ name: "swarm" })],
|
|
696
|
+
});
|
|
697
|
+
expect(capture.requests[1]!.params.systemPrompt).toBeUndefined();
|
|
698
|
+
|
|
699
|
+
const adapter = new FxAdapter({ home });
|
|
700
|
+
expect(await adapter.canResume("fxSession001")).toBe(false);
|
|
701
|
+
mkdirSync(join(home, ".fx", "sessions", "v2", "fxSession001"), { recursive: true });
|
|
702
|
+
await Bun.write(join(home, ".fx", "sessions", "v2", "fxSession001", "log.jsonl"), "{}\n");
|
|
703
|
+
expect(await adapter.canResume("fxSession001")).toBe(true);
|
|
704
|
+
expect(await adapter.canResume("../escape")).toBe(false);
|
|
705
|
+
expect(await fxSessionExists("fxSession001", undefined)).toBe(false);
|
|
706
|
+
} finally {
|
|
707
|
+
await closeStub(stub);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("an abort cancels the turn, keeps its usage and kills fx's process tree", async () => {
|
|
712
|
+
const fx = await fakeFxBinary("wait-for-cancel");
|
|
713
|
+
const stub = await startApiStub();
|
|
714
|
+
try {
|
|
715
|
+
const config = baseConfig({
|
|
716
|
+
apiUrl: stub.apiUrl,
|
|
717
|
+
env: fxEnv(fx, { OPENROUTER_API_KEY: "k" }),
|
|
718
|
+
});
|
|
719
|
+
const session = await new FxAdapter().createSession(config);
|
|
720
|
+
let childPid: number | undefined;
|
|
721
|
+
await waitFor(() => {
|
|
722
|
+
try {
|
|
723
|
+
const text = readFileSync(join(config.cwd, "capture.json"), "utf8");
|
|
724
|
+
childPid = JSON.parse(text).childPid;
|
|
725
|
+
} catch {
|
|
726
|
+
// Not written yet.
|
|
727
|
+
}
|
|
728
|
+
return !!childPid;
|
|
729
|
+
});
|
|
730
|
+
await session.abort();
|
|
731
|
+
const result = await session.waitForCompletion();
|
|
732
|
+
expect(result.isError).toBe(true);
|
|
733
|
+
expect(result.cost).toMatchObject({ inputTokens: 1200, outputTokens: 30 });
|
|
734
|
+
const capture = await readCapture(config.cwd);
|
|
735
|
+
expect(capture.requests.map((r) => r.method)).toContain("session/cancel");
|
|
736
|
+
const deadline = Date.now() + 5_000;
|
|
737
|
+
let alive = true;
|
|
738
|
+
while (alive && Date.now() < deadline) {
|
|
739
|
+
try {
|
|
740
|
+
process.kill(childPid!, 0);
|
|
741
|
+
await Bun.sleep(20);
|
|
742
|
+
} catch {
|
|
743
|
+
alive = false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
expect(alive).toBe(false);
|
|
747
|
+
} finally {
|
|
748
|
+
await closeStub(stub);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("refuses a model with no fx route, and a route without its key", async () => {
|
|
753
|
+
await expect(
|
|
754
|
+
new FxAdapter().createSession(baseConfig({ model: "nan/glm5.3-flash" })),
|
|
755
|
+
).rejects.toThrow("InconsistentFinishReason");
|
|
756
|
+
const saved = process.env.OPENAI_API_KEY;
|
|
757
|
+
delete process.env.OPENAI_API_KEY;
|
|
758
|
+
try {
|
|
759
|
+
await expect(
|
|
760
|
+
new FxAdapter().createSession(
|
|
761
|
+
baseConfig({
|
|
762
|
+
model: "gpt-5-mini",
|
|
763
|
+
env: { PATH: process.env.PATH ?? "", HOME: makeTempDir(), OPENROUTER_API_KEY: "k" },
|
|
764
|
+
}),
|
|
765
|
+
),
|
|
766
|
+
).rejects.toThrow("fx requires OPENAI_API_KEY for gpt-5-mini");
|
|
767
|
+
} finally {
|
|
768
|
+
if (saved !== undefined) process.env.OPENAI_API_KEY = saved;
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
});
|