@desplega.ai/agent-swarm 1.171.0 → 1.172.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{acp-adapter-1z9p3fvx.js → acp-adapter-fpecx1g1.js} +23 -5
- package/dist/{actions-rc2yjk8a.js → actions-pw87a1a8.js} +24 -25
- package/dist/{amp-adapter-ypyf5s1x.js → amp-adapter-8jem2bc0.js} +1 -1
- package/dist/{anthropic-messages-etkbxhhy.js → anthropic-messages-8ee2f22q.js} +55 -22
- package/dist/{app-04ab2s0z.js → app-wmf0m843.js} +7 -8
- package/dist/{assistant-vhmjd8wf.js → assistant-emp1e108.js} +26 -27
- package/dist/{azure-openai-responses-xap57x4b.js → azure-openai-responses-ap1gwxgr.js} +3 -3
- package/dist/{boot-reembed-3p0m259x.js → boot-reembed-1de4f4aj.js} +6 -7
- package/dist/{boot-reembed-wew3gwk8.js → boot-reembed-4ymn9atn.js} +6 -7
- package/dist/{boot-scrub-sweep-5ytwtq24.js → boot-scrub-sweep-adhk1s8c.js} +7 -8
- package/dist/{boot-seeds-s8p2xry8.js → boot-seeds-8q637aew.js} +9 -10
- package/dist/{claude-adapter-akrzg6jh.js → claude-adapter-t26cv8a4.js} +16 -17
- package/dist/{claude-managed-adapter-kqqqhp2q.js → claude-managed-adapter-xgxhqrt2.js} +2 -2
- package/dist/{claude-sdk-session-exq1jadx.js → claude-sdk-session-gsfsh662.js} +16 -17
- package/dist/{cli-n8ybnv1k.js → cli-04emfe2q.js} +1 -1
- package/dist/{cli-w0bwte75.js → cli-104zsxba.js} +2 -1
- package/dist/{cli-x6z90nq9.js → cli-19njxg5g.js} +2 -4
- package/dist/{cli-8mr695sb.js → cli-1hgat6pa.js} +5 -5
- package/dist/{cli-1qtgd1h1.js → cli-1m2xxxe2.js} +2 -2
- package/dist/{cli-231av4y6.js → cli-1x7da0de.js} +2 -4
- package/dist/{cli-x3fy2tgp.js → cli-388egyf6.js} +1 -1
- package/dist/{cli-j0w59p15.js → cli-4fnb6884.js} +4 -4
- package/dist/{cli-y1j1ggbf.js → cli-4nz8p1p5.js} +1372 -588
- package/dist/{cli-s88my3br.js → cli-5rv1j9ga.js} +6 -6
- package/dist/{cli-mn8qkd8v.js → cli-61625k3x.js} +1 -1
- package/dist/{cli-mzj3aqac.js → cli-6jb2rf77.js} +3 -3
- package/dist/{cli-ec7hrgqp.js → cli-8hzfb6xz.js} +8 -8
- package/dist/{cli-n805bb98.js → cli-8vs9trd4.js} +1 -1
- package/dist/{cli-xf713zz0.js → cli-9fxpr83v.js} +1 -1
- package/dist/{cli-q1vnz8wv.js → cli-9s17naqf.js} +1 -1
- package/dist/{cli-nkjwe4z7.js → cli-9xsmdpdt.js} +59 -54
- package/dist/{cli-6xxxb8qt.js → cli-aspcaef3.js} +5 -5
- package/dist/cli-bf7485pm.js +514 -0
- package/dist/{cli-s9y81s7j.js → cli-bt8k5h3s.js} +625 -150
- package/dist/{cli-jxsgrnmq.js → cli-c2pq2rjb.js} +2 -2
- package/dist/{cli-13xwcqvj.js → cli-c5km5znr.js} +2 -2
- package/dist/{cli-5y67yrt6.js → cli-cceskcvt.js} +112 -176
- package/dist/{cli-ph3145b7.js → cli-cf69s3ew.js} +4 -4
- package/dist/{cli-k2v5x13t.js → cli-d603qf86.js} +1 -1
- package/dist/{cli-r455yd58.js → cli-e0a9sk00.js} +1 -1
- package/dist/{cli-cgj4hc12.js → cli-eh1ytktb.js} +1872 -190
- package/dist/{cli-erbp6b4g.js → cli-esnpehf5.js} +7 -8
- package/dist/cli-ev91t6xp.js +148 -0
- package/dist/{cli-e2prcx5m.js → cli-fdvbjtbw.js} +235 -5
- package/dist/{cli-62bwwc2q.js → cli-g4f5pj26.js} +3 -3
- package/dist/{cli-tg8q0b5y.js → cli-g6t9texm.js} +5 -5
- package/dist/{cli-p2pjfq1r.js → cli-gj81e47t.js} +1 -1
- package/dist/{cli-srmej5tp.js → cli-gp1rtyvk.js} +2 -2
- package/dist/{cli-rb9m6rpe.js → cli-h6vtvt5c.js} +139 -1
- package/dist/cli-hd27d2md.js +1057 -0
- package/dist/{cli-hsk5gd15.js → cli-hwwvy36w.js} +3 -3
- package/dist/{cli-2rbt7at8.js → cli-hz99wfxp.js} +1 -1
- package/dist/cli-j1pmc07z.js +706 -0
- package/dist/{cli-01qndrvw.js → cli-j6kpj9az.js} +14 -8
- package/dist/{cli-2qbs2tvf.js → cli-k46r9hmk.js} +6 -6
- package/dist/{cli-wfvedq5w.js → cli-kb4ehzx5.js} +5 -5
- package/dist/{cli-agamgm0p.js → cli-mvzyxwg3.js} +1 -1
- package/dist/{cli-280gnz4v.js → cli-nv4zd0b9.js} +1 -1
- package/dist/{cli-rrbctenq.js → cli-nyhb1r6x.js} +3 -5
- package/dist/{cli-g7k7zbcz.js → cli-p7z6q2m6.js} +2 -1
- package/dist/{cli-yn0a417a.js → cli-phre6gz0.js} +22 -4
- package/dist/{cli-m4csbs67.js → cli-pj6x9qce.js} +1 -1
- package/dist/{cli-w0zx9ya1.js → cli-prrt9fa0.js} +4 -4
- package/dist/{cli-ctret352.js → cli-pstq9888.js} +33 -33
- package/dist/{cli-y195hv1a.js → cli-pxeeyc53.js} +1 -1
- package/dist/{cli-4bm25xtw.js → cli-qsztpfry.js} +1 -1
- package/dist/{cli-5t5my0h8.js → cli-qybkr9n3.js} +278 -1267
- package/dist/{cli-xezn9nkh.js → cli-r44t9wqc.js} +29 -472
- package/dist/{cli-x207g4w1.js → cli-r8smj8b8.js} +1 -1
- package/dist/{cli-bxqez1p0.js → cli-rcp5aqdw.js} +9 -39
- package/dist/{cli-vr5avem2.js → cli-smcrrmhy.js} +14 -18
- package/dist/{cli-0d2rqhvq.js → cli-tgpfnm6r.js} +15 -17
- package/dist/cli-vb8g5x02.js +549 -0
- package/dist/{cli-gt4hxb8p.js → cli-vv0qtfpx.js} +1 -2
- package/dist/{cli-95zs84qw.js → cli-wd0hz6ah.js} +671 -388
- package/dist/{cli-nkqjj42q.js → cli-x4m6feqt.js} +1 -1
- package/dist/{cli-x61srfpp.js → cli-xczpdp9e.js} +2 -2
- package/dist/cli-z1kd834t.js +36 -0
- package/dist/{cli-p8f7dqgt.js → cli-ztrv9y21.js} +48 -33
- package/dist/cli.js +24 -25
- package/dist/{cloudflare-workers-ai-system-one-sta45jf9.js → cloudflare-workers-ai-system-one-vnpyfr7e.js} +3 -3
- package/dist/{codex-adapter-9ag75whm.js → codex-adapter-m4tgdysy.js} +12 -12
- package/dist/{codex-hook-7ba15hjd.js → codex-hook-xch6z5wt.js} +2 -3
- package/dist/{codex-session-runner-9fp7y4cf.js → codex-session-runner-ymwzm6ea.js} +12 -12
- package/dist/{commands-d7eah0y9.js → commands-n0hshcvs.js} +6 -7
- package/dist/{controllers-connect-8t0k65x2.js → controllers-connect-y07cqcvy.js} +24 -25
- package/dist/{cursor-adapter-t4pr1m3c.js → cursor-adapter-q8pgv5qa.js} +2 -3
- package/dist/{db-sp39qv72.js → db-4w7tmt1g.js} +6 -7
- package/dist/{db-2rmd23wq.js → db-hkqvrdcb.js} +7 -8
- package/dist/{dispatcher-g7fsybsd.js → dispatcher-5sd69y14.js} +23 -24
- package/dist/{easter-egg-3d-66zxervv.js → easter-egg-3d-m8rvz40s.js} +4 -3
- package/dist/execute-107xtsp1.js +3745 -0
- package/dist/{google-generative-ai-pvhm0dq1.js → google-generative-ai-7pavrbv7.js} +3 -3
- package/dist/{google-vertex-tek9sqe2.js → google-vertex-3f1jk41f.js} +3 -3
- package/dist/{grok-adapter-1err1me7.js → grok-adapter-8m9yqzbq.js} +24 -6
- package/dist/{handlers-c7yggm37.js → handlers-s20xr10r.js} +26 -27
- package/dist/{heartbeat-dkm924fk.js → heartbeat-3k31q4g2.js} +25 -26
- package/dist/{hook-wgx4exw6.js → hook-gdn6a2fn.js} +13 -14
- package/dist/{hook-g8e7sfnf.js → hook-ygwerrdh.js} +14 -15
- package/dist/{http-1d23k928.js → http-jdteeg81.js} +53 -55
- package/dist/{index-y5vvczrs.js → index-6ksewd5y.js} +20 -18
- package/dist/{index-kkeftv5h.js → index-82rk11v7.js} +4 -4
- package/dist/{index-gm10tmye.js → index-e0snym4t.js} +23 -24
- package/dist/{index-vertaxs1.js → index-ffqzqgwb.js} +6 -7
- package/dist/{index-q7cj9bn2.js → index-tsszt7g9.js} +25 -26
- package/dist/{index-w097eg0x.js → index-vccv018g.js} +1 -1
- package/dist/{index-va4zbvzp.js → index-vm3bm9t7.js} +24 -25
- package/dist/{keepalive-1jc5e7ct.js → keepalive-jekc896t.js} +8 -9
- package/dist/{lead-yxw2f1sg.js → lead-4k9s9szx.js} +40 -41
- package/dist/{maintenance-h7w2m10y.js → maintenance-shazch4n.js} +6 -7
- package/dist/{mistral-conversations-rjxa9s1j.js → mistral-conversations-vjc1ggpd.js} +29 -15
- package/dist/{oauth-refresh-sweep-wh9ypd1b.js → oauth-refresh-sweep-5gb36kn8.js} +7 -8
- package/dist/{onboard-dfr9j762.js → onboard-ydrbnvww.js} +2 -2
- package/dist/{openai-codex-responses-pgvtddvv.js → openai-codex-responses-33qhkkdy.js} +7 -39
- package/dist/{openai-completions-44gb0zsk.js → openai-completions-s88xm3ns.js} +2 -2
- package/dist/{openai-decisions-vj9nkpf8.js → openai-decisions-fctnj8xd.js} +2 -2
- package/dist/{openai-responses-9dyzwzkr.js → openai-responses-7xd2f2e2.js} +3 -3
- package/dist/opencode-adapter-5zh0he20.js +55 -0
- package/dist/{otel-impl-and31m5s.js → otel-impl-6pf5cv86.js} +1 -1
- package/dist/{pi-mono-adapter-fx6k5wwh.js → pi-mono-adapter-ef9wwx9m.js} +25 -500
- package/dist/{pricing-refresh-23zr6j3w.js → pricing-refresh-s52nsa5q.js} +8 -9
- package/dist/{rbac-roles-wnx35x4x.js → rbac-roles-aq3msh4e.js} +6 -7
- package/dist/{rbac-roles-nc7k9d0t.js → rbac-roles-q5gb5e8v.js} +7 -8
- package/dist/{render-v2-81cg1y52.js → render-v2-pd1rg54q.js} +7 -8
- package/dist/{resume-d4b6j2n2.js → resume-gw4a2c6v.js} +14 -15
- package/dist/{runtime-h9ha1mq6.js → runtime-qfry88v2.js} +4 -7
- package/dist/{setup-g68r1w28.js → setup-jda1ksdh.js} +2 -2
- package/dist/{typesafe-system-one-e988vty7.js → typesafe-system-one-zzzcx4s3.js} +3 -3
- package/dist/{virtual-modules-rdjmcg7q.js → virtual-modules-b3ga2m65.js} +13 -12
- package/dist/{work-objects-0zzdnbt9.js → work-objects-tb7ycye8.js} +26 -27
- package/dist/{worker-p1g8qm6h.js → worker-njjccz5d.js} +40 -41
- package/openapi.json +1 -1
- package/package.json +7 -8
- package/src/be/scripts/extract-schema.ts +4 -0
- package/src/github/handlers.ts +61 -18
- package/src/github/types.ts +2 -1
- package/src/prompts/goal-reminder.ts +30 -0
- package/src/prompts/session-templates.ts +22 -0
- package/src/providers/acp-targets.ts +3 -4
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/harness-version.ts +0 -1
- package/src/providers/opencode-adapter.ts +442 -723
- package/src/providers/opencode-swarm-plugin.ts +88 -0
- package/src/providers/pi-mono-extension.ts +58 -37
- package/src/scripts-runtime/executors/native.ts +6 -5
- package/src/scripts-runtime/executors/quickjs-bundle.ts +18 -11
- package/src/scripts-runtime/extract-args-schema.ts +22 -4
- package/src/scripts-runtime/import-allowlist.ts +1 -1
- package/src/scripts-runtime/zod-mini-bundle.ts +5 -0
- package/src/slack/render-v2.ts +2 -2
- package/src/tests/entrypoint-acp-target-binary.test.ts +19 -1
- package/src/tests/github-handlers-inline-comments.test.ts +131 -2
- package/src/tests/github-handlers-reactions.test.ts +9 -0
- package/src/tests/harness-version.test.ts +0 -20
- package/src/tests/opencode-adapter.test.ts +536 -1389
- package/src/tests/opencode-mcp-admission-sdk.test.ts +106 -0
- package/src/tests/opencode-swarm-plugin.test.ts +130 -0
- package/src/tests/pi-mono-extension.test.ts +59 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/provider-registration.test.ts +3 -0
- package/src/tests/script-executor-quickjs.test.ts +17 -12
- package/src/tests/scripts-http.test.ts +24 -16
- package/src/tests/scripts-import-allowlist.test.ts +1 -1
- package/src/tests/scripts-runtime.test.ts +78 -40
- package/src/tests/seed-scripts.test.ts +13 -0
- package/src/tests/slack-render-v2.test.ts +4 -2
- package/src/tests/ui-logs-parser.test.ts +28 -0
- package/src/tools/script-upsert.ts +1 -1
- package/src/utils/memory-raters.ts +7 -3
- package/src/utils/openrouter-base-url.ts +0 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +4 -6
- package/templates/skills/heartbeat-runbook/content.md +2 -0
- package/templates/skills/swarm-scripts/SKILL.md +1 -0
- package/templates/skills/swarm-scripts/content.md +1 -0
- package/dist/cli-5bfdm1ah.js +0 -462
- package/dist/cli-5c2j5yvx.js +0 -222
- package/dist/cli-rhy7z1rp.js +0 -532
- package/dist/cli-ywh2a87k.js +0 -2359
- package/dist/execute-h4jgevyw.js +0 -1387
- package/dist/opencode-adapter-f1zby94p.js +0 -34
- package/plugin/opencode-plugins/lib/memory-raters.ts +0 -7
- package/src/tests/opencode-plugin.test.ts +0 -580
- package/src/tests/providers/opencode-cost.test.ts +0 -74
|
@@ -1,1511 +1,658 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for
|
|
2
|
+
* Unit tests for the OpenCode 2 adapter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* A fake embedded host stands in for `@opencode/sdk` (injected through
|
|
5
|
+
* `_setOpencodeSdkLoaderForTests`), so these tests drive canned OpenCode 2
|
|
6
|
+
* events through the adapter and check the ProviderEvent mapping, cost,
|
|
7
|
+
* config wiring, steering, abort, and stream-death handling.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import { afterEach,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from "../providers/
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import type { OpenCodeEvent } from "@opencode/sdk";
|
|
12
|
+
import { normalizeSessionLogs, type SessionLogRecord } from "../../apps/ui/src/logs-parser";
|
|
13
|
+
import {
|
|
14
|
+
_setOpencodeSdkLoaderForTests,
|
|
15
|
+
_setOpencodeSessionHooksForTests,
|
|
16
|
+
applyOpenRouterBaseUrlOverride,
|
|
17
|
+
OpencodeAdapter,
|
|
18
|
+
type OpencodeSession,
|
|
19
|
+
resolveOpencodeSdkSpecifier,
|
|
20
|
+
} from "../providers/opencode-adapter";
|
|
21
|
+
import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
|
|
22
|
+
|
|
23
|
+
const SESSION_ID = "ses_test";
|
|
22
24
|
|
|
23
25
|
function testConfig(overrides: Partial<ProviderSessionConfig> = {}): ProviderSessionConfig {
|
|
24
26
|
return {
|
|
25
27
|
prompt: "do something",
|
|
26
28
|
systemPrompt: "be helpful",
|
|
27
|
-
model: "
|
|
29
|
+
model: "openrouter/google/gemini-3-flash-preview",
|
|
28
30
|
role: "worker",
|
|
29
31
|
agentId: "agent-1",
|
|
30
32
|
taskId: "task-1",
|
|
31
|
-
apiUrl: "http://
|
|
33
|
+
apiUrl: "http://127.0.0.1:9",
|
|
32
34
|
apiKey: "example-test-key",
|
|
33
|
-
cwd: "/tmp/test",
|
|
34
|
-
logFile: "/tmp/test.log",
|
|
35
|
+
cwd: "/tmp/opencode-adapter-test",
|
|
36
|
+
logFile: "/tmp/opencode-adapter-test.log",
|
|
35
37
|
...overrides,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
/**
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
for (const ev of events) yield ev;
|
|
43
|
-
}
|
|
44
|
-
return gen();
|
|
41
|
+
/** Builds a typed event; the adapter only reads `type` and `data`. */
|
|
42
|
+
function ev(type: string, data: Record<string, unknown>): OpenCodeEvent {
|
|
43
|
+
return { id: `evt_${type}`, created: 0, type, data } as unknown as OpenCodeEvent;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const tokens = (input: number, output: number, read = 0, write = 0) => ({
|
|
47
|
+
input,
|
|
48
|
+
output,
|
|
49
|
+
reasoning: 0,
|
|
50
|
+
cache: { read, write },
|
|
51
|
+
});
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
let lastCreateOpencodeConfig: unknown;
|
|
53
|
+
type FakeHost = ReturnType<typeof makeFakeHost>;
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
cfg: ProviderSessionConfig = testConfig(),
|
|
57
|
-
): Promise<{ emitted: ProviderEvent[]; result: ProviderResult; serverCloseCalls: () => number }> {
|
|
58
|
-
const emitted: ProviderEvent[] = [];
|
|
55
|
+
type McpStatus =
|
|
56
|
+
| { status: "connected" | "pending" | "disabled" }
|
|
57
|
+
| { status: "failed" | "needs_auth"; error: string };
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
type FakeHostOptions = {
|
|
60
|
+
endStream?: boolean;
|
|
61
|
+
promptError?: Error;
|
|
62
|
+
/** Swarm MCP status per `mcp.list` call; the last entry repeats. Omit the server with `null`. */
|
|
63
|
+
mcpStatuses?: Array<McpStatus | null>;
|
|
64
|
+
/** Registered tool ids the plugin's tool lister returns. */
|
|
65
|
+
tools?: string[];
|
|
66
|
+
};
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* An embedded host whose event stream replays `events`, then either ends
|
|
70
|
+
* (`endStream`) or stays open until `close()`. By default its swarm MCP
|
|
71
|
+
* server is connected with `store-progress` registered.
|
|
72
|
+
*/
|
|
73
|
+
function makeFakeHost(events: OpenCodeEvent[], opts: FakeHostOptions = {}) {
|
|
74
|
+
const statuses = opts.mcpStatuses ?? [{ status: "connected" }];
|
|
75
|
+
let release: () => void = () => {};
|
|
76
|
+
const closed = new Promise<void>((resolve) => {
|
|
77
|
+
release = resolve;
|
|
78
|
+
});
|
|
79
|
+
const host = {
|
|
80
|
+
createOptions: undefined as unknown as Record<string, unknown>,
|
|
81
|
+
created: [] as unknown[],
|
|
82
|
+
prompts: [] as Array<Record<string, unknown>>,
|
|
83
|
+
interrupts: [] as unknown[],
|
|
84
|
+
closeCalls: 0,
|
|
85
|
+
mcpListCalls: 0,
|
|
86
|
+
mcp: {
|
|
87
|
+
list: async () => {
|
|
88
|
+
const status = statuses[Math.min(host.mcpListCalls, statuses.length - 1)];
|
|
89
|
+
host.mcpListCalls++;
|
|
90
|
+
return { location: {}, data: status ? [{ name: "swarm", status }] : [] };
|
|
70
91
|
},
|
|
71
92
|
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
tools: opts.tools ?? ["read", "swarm_store-progress"],
|
|
94
|
+
sessions: {
|
|
95
|
+
create: async (input: unknown) => {
|
|
96
|
+
host.created.push(input);
|
|
97
|
+
return { id: SESSION_ID };
|
|
98
|
+
},
|
|
99
|
+
prompt: async (input: Record<string, unknown>) => {
|
|
100
|
+
if (opts.promptError) throw opts.promptError;
|
|
101
|
+
host.prompts.push(input);
|
|
102
|
+
return {};
|
|
103
|
+
},
|
|
104
|
+
interrupt: async (input: unknown) => {
|
|
105
|
+
host.interrupts.push(input);
|
|
106
|
+
return {};
|
|
107
|
+
},
|
|
85
108
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const adapter = new OpencodeAdapter();
|
|
91
|
-
const session = await adapter.createSession(cfg);
|
|
92
|
-
session.onEvent((e) => emitted.push(e));
|
|
93
|
-
|
|
94
|
-
// Give microtasks (session_init) a chance to run
|
|
95
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
96
|
-
|
|
97
|
-
const result = await session.waitForCompletion();
|
|
98
|
-
return { emitted, result, serverCloseCalls: () => closeServer.mock.calls.length };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function inspectSessionBeforeIdle(
|
|
102
|
-
cfg: ProviderSessionConfig,
|
|
103
|
-
inspect: () => Promise<void>,
|
|
104
|
-
): Promise<void> {
|
|
105
|
-
const fakeSessionId = "sess-abc-123";
|
|
106
|
-
let releaseIdle!: () => void;
|
|
107
|
-
const idleReleased = new Promise<void>((resolve) => {
|
|
108
|
-
releaseIdle = resolve;
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const fakeClient = {
|
|
112
|
-
session: {
|
|
113
|
-
create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
|
|
114
|
-
prompt: async (args: unknown) => {
|
|
115
|
-
lastPromptArgs = args;
|
|
116
|
-
return { data: {}, error: undefined };
|
|
109
|
+
events: {
|
|
110
|
+
async *subscribe() {
|
|
111
|
+
for (const event of events) yield event;
|
|
112
|
+
if (!opts.endStream) await closed;
|
|
117
113
|
},
|
|
118
114
|
},
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
await idleReleased;
|
|
123
|
-
yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
|
|
124
|
-
})(),
|
|
125
|
-
}),
|
|
115
|
+
close: async () => {
|
|
116
|
+
host.closeCalls++;
|
|
117
|
+
release();
|
|
126
118
|
},
|
|
127
119
|
};
|
|
120
|
+
return host;
|
|
121
|
+
}
|
|
128
122
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
123
|
+
function installHost(host: FakeHost) {
|
|
124
|
+
_setOpencodeSdkLoaderForTests(async () => ({
|
|
125
|
+
version: "2.0.28",
|
|
126
|
+
sdk: {
|
|
127
|
+
OpenCode: {
|
|
128
|
+
create: async (options: Record<string, unknown>) => {
|
|
129
|
+
host.createOptions = options;
|
|
130
|
+
// Set the plugins up the way OpenCode does, with a stub context.
|
|
131
|
+
const plugins = options.plugins as Array<{ setup: (ctx: unknown) => Promise<void> }>;
|
|
132
|
+
for (const plugin of plugins) {
|
|
133
|
+
await plugin.setup({
|
|
134
|
+
tool: {
|
|
135
|
+
hook: async () => {},
|
|
136
|
+
list: async () => host.tools.map((id) => ({ id })),
|
|
137
|
+
},
|
|
138
|
+
session: { hook: async () => {} },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return host;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
} as never,
|
|
136
145
|
}));
|
|
137
|
-
|
|
138
|
-
const { OpencodeAdapter } = await import("../providers/opencode-adapter");
|
|
139
|
-
const adapter = new OpencodeAdapter();
|
|
140
|
-
const session = await adapter.createSession(cfg);
|
|
141
|
-
session.onEvent(() => {});
|
|
142
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
143
|
-
await inspect();
|
|
144
|
-
releaseIdle();
|
|
145
|
-
await session.waitForCompletion();
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
async function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const fakeClient = {
|
|
161
|
-
session: {
|
|
162
|
-
create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
|
|
163
|
-
prompt: async () => ({ data: {}, error: undefined }),
|
|
164
|
-
...sessionMethods,
|
|
165
|
-
},
|
|
166
|
-
event: {
|
|
167
|
-
subscribe: async () => ({
|
|
168
|
-
stream: (async function* (): AsyncGenerator<OpencodeEvent> {
|
|
169
|
-
await idleReleased;
|
|
170
|
-
yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
|
|
171
|
-
})(),
|
|
172
|
-
}),
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
const closeServer = mock(() => {});
|
|
176
|
-
const fakeServer = { url: "http://127.0.0.1:12345", close: closeServer };
|
|
177
|
-
|
|
178
|
-
mock.module("@opencode-ai/sdk", () => ({
|
|
179
|
-
createOpencode: async () => ({ client: fakeClient, server: fakeServer }),
|
|
180
|
-
}));
|
|
148
|
+
async function startSession(
|
|
149
|
+
events: OpenCodeEvent[],
|
|
150
|
+
cfg: ProviderSessionConfig = testConfig(),
|
|
151
|
+
opts: FakeHostOptions = {},
|
|
152
|
+
) {
|
|
153
|
+
const host = makeFakeHost(events, opts);
|
|
154
|
+
installHost(host);
|
|
155
|
+
const session = (await new OpencodeAdapter().createSession(cfg)) as OpencodeSession;
|
|
156
|
+
const emitted: ProviderEvent[] = [];
|
|
157
|
+
session.onEvent((e) => emitted.push(e));
|
|
158
|
+
return { host, session, emitted };
|
|
159
|
+
}
|
|
181
160
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
161
|
+
async function runSession(
|
|
162
|
+
events: OpenCodeEvent[],
|
|
163
|
+
cfg?: ProviderSessionConfig,
|
|
164
|
+
opts?: FakeHostOptions,
|
|
165
|
+
) {
|
|
166
|
+
const started = await startSession(events, cfg, opts);
|
|
167
|
+
const result = await started.session.waitForCompletion();
|
|
168
|
+
return { ...started, result };
|
|
169
|
+
}
|
|
188
170
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
await
|
|
171
|
+
async function waitFor(check: () => boolean, timeoutMs = 2_000) {
|
|
172
|
+
const deadline = Date.now() + timeoutMs;
|
|
173
|
+
while (!check()) {
|
|
174
|
+
if (Date.now() > deadline) throw new Error("waitFor timed out");
|
|
175
|
+
await Bun.sleep(5);
|
|
194
176
|
}
|
|
195
177
|
}
|
|
196
178
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
mock.restore();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("queue mode prompts asynchronously without aborting", async () => {
|
|
205
|
-
const abortCalls: unknown[] = [];
|
|
206
|
-
const promptAsyncCalls: unknown[] = [];
|
|
207
|
-
await inspectSteeringBeforeIdle(
|
|
208
|
-
{
|
|
209
|
-
abort: async (args) => {
|
|
210
|
-
abortCalls.push(args);
|
|
211
|
-
return {};
|
|
212
|
-
},
|
|
213
|
-
promptAsync: async (args) => {
|
|
214
|
-
promptAsyncCalls.push(args);
|
|
215
|
-
return {};
|
|
216
|
-
},
|
|
217
|
-
},
|
|
218
|
-
async (session) => {
|
|
219
|
-
await expect(
|
|
220
|
-
session.deliverSteering?.({ mode: "queue", text: "Use this extra context." }),
|
|
221
|
-
).resolves.toEqual({ delivered: true, mode: "queue" });
|
|
222
|
-
expect(abortCalls).toEqual([]);
|
|
223
|
-
expect(promptAsyncCalls).toEqual([
|
|
224
|
-
{
|
|
225
|
-
path: { id: "sess-steering-123" },
|
|
226
|
-
body: { parts: [{ type: "text", text: "Use this extra context." }] },
|
|
227
|
-
},
|
|
228
|
-
]);
|
|
229
|
-
},
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("a steer request never aborts and reports queue honestly", async () => {
|
|
234
|
-
const calls: Array<{ method: "abort" | "promptAsync"; args: unknown }> = [];
|
|
235
|
-
await inspectSteeringBeforeIdle(
|
|
236
|
-
{
|
|
237
|
-
abort: async (args) => {
|
|
238
|
-
calls.push({ method: "abort", args });
|
|
239
|
-
return {};
|
|
240
|
-
},
|
|
241
|
-
promptAsync: async (args) => {
|
|
242
|
-
calls.push({ method: "promptAsync", args });
|
|
243
|
-
return {};
|
|
244
|
-
},
|
|
245
|
-
},
|
|
246
|
-
async (session, serverCloseCalls) => {
|
|
247
|
-
// opencode advertises `["queue"]` only: E2E showed the re-prompt fails
|
|
248
|
-
// once the session has been aborted, losing the message to the
|
|
249
|
-
// undeliverable path. A steer request that reaches the adapter anyway
|
|
250
|
-
// is served as a queue and reported as such.
|
|
251
|
-
await expect(
|
|
252
|
-
session.deliverSteering?.({ mode: "steer", text: "Stop and revise." }),
|
|
253
|
-
).resolves.toEqual({ delivered: true, mode: "queue" });
|
|
254
|
-
expect(calls).toEqual([
|
|
255
|
-
{
|
|
256
|
-
method: "promptAsync",
|
|
257
|
-
args: {
|
|
258
|
-
path: { id: "sess-steering-123" },
|
|
259
|
-
body: { parts: [{ type: "text", text: "Stop and revise." }] },
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
]);
|
|
263
|
-
expect(serverCloseCalls()).toBe(0);
|
|
264
|
-
await expect(
|
|
265
|
-
Promise.race([
|
|
266
|
-
session.waitForCompletion().then(() => "completed"),
|
|
267
|
-
Promise.resolve("still-running"),
|
|
268
|
-
]),
|
|
269
|
-
).resolves.toBe("still-running");
|
|
270
|
-
},
|
|
271
|
-
);
|
|
272
|
-
});
|
|
179
|
+
function configOf(host: FakeHost): Record<string, any> {
|
|
180
|
+
const config = host.createOptions.config as { content: string };
|
|
181
|
+
return JSON.parse(config.content);
|
|
182
|
+
}
|
|
273
183
|
|
|
274
|
-
|
|
275
|
-
await inspectSteeringBeforeIdle(
|
|
276
|
-
{
|
|
277
|
-
abort: async () => ({}),
|
|
278
|
-
promptAsync: async () => {
|
|
279
|
-
throw new Error("opencode prompt rejected");
|
|
280
|
-
},
|
|
281
|
-
},
|
|
282
|
-
async (session) => {
|
|
283
|
-
await expect(
|
|
284
|
-
session.deliverSteering?.({ mode: "queue", text: "Try again later." }),
|
|
285
|
-
).resolves.toEqual({
|
|
286
|
-
delivered: false,
|
|
287
|
-
reason: "Error: opencode prompt rejected",
|
|
288
|
-
});
|
|
289
|
-
},
|
|
290
|
-
);
|
|
291
|
-
});
|
|
184
|
+
const succeeded = ev("session.execution.succeeded", { sessionID: SESSION_ID });
|
|
292
185
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
).resolves.toEqual({
|
|
311
|
-
delivered: false,
|
|
312
|
-
reason: "opencode session already completed",
|
|
313
|
-
});
|
|
314
|
-
expect(promptAsyncCalls).toEqual([]);
|
|
315
|
-
},
|
|
316
|
-
);
|
|
317
|
-
});
|
|
186
|
+
const prevEnv: Record<string, string | undefined> = {};
|
|
187
|
+
beforeAll(() => {
|
|
188
|
+
for (const key of ["SKIP_SESSION_SUMMARY", "CONTEXT_MODE_DISABLED", "OPENCODE_SDK_PATH"]) {
|
|
189
|
+
prevEnv[key] = process.env[key];
|
|
190
|
+
}
|
|
191
|
+
process.env.SKIP_SESSION_SUMMARY = "1";
|
|
192
|
+
delete process.env.CONTEXT_MODE_DISABLED;
|
|
193
|
+
});
|
|
194
|
+
afterAll(() => {
|
|
195
|
+
for (const [key, value] of Object.entries(prevEnv)) {
|
|
196
|
+
if (value === undefined) delete process.env[key];
|
|
197
|
+
else process.env[key] = value;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
afterEach(() => {
|
|
201
|
+
_setOpencodeSdkLoaderForTests(null);
|
|
202
|
+
_setOpencodeSessionHooksForTests(null);
|
|
318
203
|
});
|
|
319
204
|
|
|
320
|
-
describe("
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
205
|
+
describe("OpencodeAdapter — host and config wiring", () => {
|
|
206
|
+
test("passes the per-task config as content and the swarm plugin as an object", async () => {
|
|
207
|
+
const { host } = await runSession([succeeded], testConfig({ contextKey: "ctx-1" }));
|
|
208
|
+
const config = configOf(host);
|
|
209
|
+
|
|
210
|
+
expect(config.model).toBe("openrouter/google/gemini-3-flash-preview");
|
|
211
|
+
expect(config.permission).toMatchObject({ edit: "allow", bash: "allow" });
|
|
212
|
+
expect(config.agent).toEqual({ swarm: { mode: "primary", prompt: "be helpful" } });
|
|
213
|
+
expect(config.mcp.swarm).toMatchObject({
|
|
214
|
+
type: "remote",
|
|
215
|
+
url: "http://127.0.0.1:9/mcp",
|
|
216
|
+
headers: {
|
|
217
|
+
Authorization: "Bearer example-test-key",
|
|
218
|
+
"X-Agent-ID": "agent-1",
|
|
219
|
+
"X-Source-Task-Id": "task-1",
|
|
220
|
+
"X-Context-Key": "ctx-1",
|
|
331
221
|
},
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const sessionInit = emitted.find((e) => e.type === "session_init");
|
|
336
|
-
expect(sessionInit?.type).toBe("session_init");
|
|
337
|
-
if (sessionInit?.type !== "session_init") {
|
|
338
|
-
throw new Error("Expected opencode session_init event");
|
|
339
|
-
}
|
|
340
|
-
expect(sessionInit.provider).toBe("opencode");
|
|
341
|
-
expect(sessionInit.harnessVariant).toBe("stock");
|
|
342
|
-
expect(typeof sessionInit.harnessVariantMeta?.version).toBe("string");
|
|
343
|
-
expect((sessionInit.harnessVariantMeta?.version as string).length).toBeGreaterThan(0);
|
|
344
|
-
|
|
345
|
-
const resultEvent = emitted.find((e) => e.type === "result");
|
|
346
|
-
expect(resultEvent).toBeDefined();
|
|
347
|
-
if (resultEvent?.type === "result") {
|
|
348
|
-
expect(resultEvent.isError).toBe(false);
|
|
349
|
-
expect(resultEvent.cost).toBeDefined();
|
|
350
|
-
expect(resultEvent.cost.provider).toBe("opencode");
|
|
351
|
-
}
|
|
352
|
-
expect(result.isError).toBe(false);
|
|
353
|
-
expect(result.exitCode).toBe(0);
|
|
354
|
-
expect(result.sessionId).toBe("sess-abc-123");
|
|
355
|
-
expect(serverCloseCalls()).toBe(1);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
test("session.idle closes the server and drops later heartbeat events", async () => {
|
|
359
|
-
const events: OpencodeEvent[] = [
|
|
360
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
361
|
-
{ type: "server.heartbeat", properties: {} } as OpencodeEvent,
|
|
362
|
-
];
|
|
363
|
-
const { emitted, serverCloseCalls } = await driveSession(events);
|
|
222
|
+
});
|
|
223
|
+
expect(config.mcp["context-mode"]).toMatchObject({ type: "local", command: ["context-mode"] });
|
|
364
224
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
.map((e) => e.content);
|
|
369
|
-
expect(rawLogContents.some((content) => content.includes("server.heartbeat"))).toBe(false);
|
|
225
|
+
const plugins = host.createOptions.plugins as Array<{ id: string; setup: unknown }>;
|
|
226
|
+
expect(plugins.map((p) => p.id)).toEqual(["agent-swarm"]);
|
|
227
|
+
expect(typeof plugins[0]?.setup).toBe("function");
|
|
370
228
|
});
|
|
371
229
|
|
|
372
|
-
test("session
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const { emitted, result, serverCloseCalls } = await driveSession(events);
|
|
383
|
-
|
|
384
|
-
const errorEvent = emitted.find((e) => e.type === "error");
|
|
385
|
-
expect(errorEvent).toBeDefined();
|
|
386
|
-
if (errorEvent?.type === "error") {
|
|
387
|
-
expect(errorEvent.message).toContain("provider overloaded");
|
|
388
|
-
}
|
|
389
|
-
expect(result.isError).toBe(true);
|
|
390
|
-
expect(result.exitCode).toBe(1);
|
|
391
|
-
expect(result.failureReason).toContain("provider overloaded");
|
|
392
|
-
expect(serverCloseCalls()).toBe(1);
|
|
230
|
+
test("creates the session in the task cwd with the swarm agent and queues the prompt", async () => {
|
|
231
|
+
const { host, session } = await startSession([]);
|
|
232
|
+
await waitFor(() => host.prompts.length > 0);
|
|
233
|
+
expect(host.created).toEqual([
|
|
234
|
+
{ location: { directory: "/tmp/opencode-adapter-test" }, agent: "swarm" },
|
|
235
|
+
]);
|
|
236
|
+
expect(host.prompts).toEqual([
|
|
237
|
+
{ sessionID: SESSION_ID, text: "do something", delivery: "queue" },
|
|
238
|
+
]);
|
|
239
|
+
await session.abort();
|
|
393
240
|
});
|
|
394
241
|
|
|
395
|
-
test("
|
|
396
|
-
|
|
397
|
-
const refreshCalls: Array<{ model?: string; configFilePath: string; dataHomePath: string }> =
|
|
398
|
-
[];
|
|
399
|
-
const fakeSessionId = "sess-abc-123";
|
|
400
|
-
let promptCalls = 0;
|
|
401
|
-
let resolveSecondPrompt!: () => void;
|
|
402
|
-
const secondPromptSent = new Promise<void>((resolve) => {
|
|
403
|
-
resolveSecondPrompt = resolve;
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
const fakeClient = {
|
|
407
|
-
session: {
|
|
408
|
-
create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
|
|
409
|
-
prompt: async (args: unknown) => {
|
|
410
|
-
lastPromptArgs = args;
|
|
411
|
-
promptCalls += 1;
|
|
412
|
-
if (promptCalls === 1) {
|
|
413
|
-
throw new Error(
|
|
414
|
-
"Model not found: openrouter/x-ai/grok-4.3. Did you mean: x-ai/grok-4.3?",
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
resolveSecondPrompt();
|
|
418
|
-
return { data: {}, error: undefined };
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
event: {
|
|
422
|
-
subscribe: async () => ({
|
|
423
|
-
stream: (async function* (): AsyncGenerator<OpencodeEvent> {
|
|
424
|
-
await secondPromptSent;
|
|
425
|
-
yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
|
|
426
|
-
})(),
|
|
427
|
-
}),
|
|
428
|
-
},
|
|
429
|
-
};
|
|
430
|
-
const fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
|
|
431
|
-
|
|
432
|
-
mock.module("@opencode-ai/sdk", () => ({
|
|
433
|
-
createOpencode: async () => ({ client: fakeClient, server: fakeServer }),
|
|
434
|
-
}));
|
|
435
|
-
|
|
436
|
-
const { OpencodeAdapter, _setOpenRouterModelCacheRefreshForTests } = await import(
|
|
437
|
-
"../providers/opencode-adapter"
|
|
438
|
-
);
|
|
439
|
-
_setOpenRouterModelCacheRefreshForTests(
|
|
440
|
-
async (opencodeConfig, configFilePath, dataHomePath) => {
|
|
441
|
-
refreshCalls.push({ model: opencodeConfig.model, configFilePath, dataHomePath });
|
|
442
|
-
},
|
|
443
|
-
);
|
|
242
|
+
test("context-mode is not wired when CONTEXT_MODE_DISABLED is set", async () => {
|
|
243
|
+
process.env.CONTEXT_MODE_DISABLED = "true";
|
|
444
244
|
try {
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
testConfig({ model: "openrouter/x-ai/grok-4.3", taskId: "task-refresh" }),
|
|
448
|
-
);
|
|
449
|
-
session.onEvent((e) => emitted.push(e));
|
|
450
|
-
|
|
451
|
-
const result = await session.waitForCompletion();
|
|
452
|
-
|
|
453
|
-
expect(result.isError).toBe(false);
|
|
454
|
-
expect(promptCalls).toBe(2);
|
|
455
|
-
expect(refreshCalls).toEqual([
|
|
456
|
-
{
|
|
457
|
-
model: "openrouter/x-ai/grok-4.3",
|
|
458
|
-
configFilePath: "/tmp/opencode-task-refresh.json",
|
|
459
|
-
dataHomePath: "/tmp/opencode-data-task-refresh",
|
|
460
|
-
},
|
|
461
|
-
]);
|
|
462
|
-
expect(emitted.some((e) => e.type === "progress" && e.message.includes("refreshing"))).toBe(
|
|
463
|
-
true,
|
|
464
|
-
);
|
|
245
|
+
const { host } = await runSession([succeeded]);
|
|
246
|
+
expect(configOf(host).mcp["context-mode"]).toBeUndefined();
|
|
465
247
|
} finally {
|
|
466
|
-
|
|
248
|
+
delete process.env.CONTEXT_MODE_DISABLED;
|
|
467
249
|
}
|
|
468
250
|
});
|
|
469
251
|
|
|
470
|
-
test("
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
id: "perm-1",
|
|
476
|
-
type: "bash",
|
|
477
|
-
sessionID: "sess-abc-123",
|
|
478
|
-
messageID: "msg-1",
|
|
479
|
-
title: "Run shell command",
|
|
480
|
-
metadata: {},
|
|
481
|
-
time: { created: Date.now() },
|
|
482
|
-
},
|
|
483
|
-
},
|
|
484
|
-
];
|
|
485
|
-
const { emitted, result } = await driveSession(events);
|
|
486
|
-
|
|
487
|
-
const errorEvent = emitted.find((e) => e.type === "error");
|
|
488
|
-
expect(errorEvent).toBeDefined();
|
|
489
|
-
if (errorEvent?.type === "error") {
|
|
490
|
-
expect(errorEvent.message).toContain("Permission request");
|
|
491
|
-
}
|
|
492
|
-
expect(result.isError).toBe(true);
|
|
252
|
+
test("reasoningEffort on an openrouter model sets provider options", async () => {
|
|
253
|
+
const { host } = await runSession([succeeded], testConfig({ reasoningEffort: "low" }));
|
|
254
|
+
expect(
|
|
255
|
+
configOf(host).provider.openrouter.models["google/gemini-3-flash-preview"].options,
|
|
256
|
+
).toEqual({ reasoning: { effort: "low" } });
|
|
493
257
|
});
|
|
494
258
|
|
|
495
|
-
test("
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
role: "assistant",
|
|
504
|
-
cost: 999,
|
|
505
|
-
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
506
|
-
time: { created: Date.now() },
|
|
507
|
-
parentID: "",
|
|
508
|
-
modelID: "claude-opus",
|
|
509
|
-
providerID: "anthropic",
|
|
510
|
-
mode: "live",
|
|
511
|
-
path: { cwd: "/", root: "/" },
|
|
512
|
-
} as never,
|
|
513
|
-
},
|
|
514
|
-
},
|
|
515
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
516
|
-
];
|
|
517
|
-
const { result } = await driveSession(events);
|
|
518
|
-
// The other-session cost should NOT be accumulated
|
|
519
|
-
expect(result.cost?.totalCostUsd).toBe(0);
|
|
259
|
+
test("session_init carries the SDK version", async () => {
|
|
260
|
+
const { emitted } = await runSession([succeeded]);
|
|
261
|
+
expect(emitted[0]).toMatchObject({
|
|
262
|
+
type: "session_init",
|
|
263
|
+
sessionId: SESSION_ID,
|
|
264
|
+
provider: "opencode",
|
|
265
|
+
harnessVariantMeta: { version: "2.0.28" },
|
|
266
|
+
});
|
|
520
267
|
});
|
|
521
268
|
|
|
522
|
-
test("
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
expect(rawLogs.length).toBeGreaterThan(0);
|
|
269
|
+
test("OPENCODE_SDK_PATH overrides the SDK lookup", () => {
|
|
270
|
+
process.env.OPENCODE_SDK_PATH = "/opt/opencode/sdk/dist/index.js";
|
|
271
|
+
try {
|
|
272
|
+
expect(resolveOpencodeSdkSpecifier()).toBe("/opt/opencode/sdk/dist/index.js");
|
|
273
|
+
} finally {
|
|
274
|
+
delete process.env.OPENCODE_SDK_PATH;
|
|
275
|
+
}
|
|
530
276
|
});
|
|
531
277
|
});
|
|
532
278
|
|
|
533
|
-
describe("OpencodeSession —
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const now = Date.now();
|
|
550
|
-
return {
|
|
551
|
-
type: "message.updated",
|
|
552
|
-
properties: {
|
|
553
|
-
info: {
|
|
554
|
-
id,
|
|
555
|
-
sessionID: "sess-abc-123",
|
|
556
|
-
role: "assistant",
|
|
557
|
-
cost: values.cost,
|
|
558
|
-
tokens: {
|
|
559
|
-
input: values.input,
|
|
560
|
-
output: values.output,
|
|
561
|
-
reasoning: values.reasoning ?? 0,
|
|
562
|
-
cache: { read: values.cacheRead ?? 0, write: values.cacheWrite ?? 0 },
|
|
563
|
-
},
|
|
564
|
-
time: { created: now, completed: now + 1 },
|
|
565
|
-
parentID: "",
|
|
566
|
-
modelID: "claude-opus",
|
|
567
|
-
providerID: "anthropic",
|
|
568
|
-
mode: "live",
|
|
569
|
-
path: { cwd: "/", root: "/" },
|
|
570
|
-
} as never,
|
|
571
|
-
},
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
test("distinct finalized message.updated steps → totals are summed", async () => {
|
|
576
|
-
const stepCosts = [0.001, 0.002, 0.0015];
|
|
577
|
-
const stepEvents: OpencodeEvent[] = stepCosts.map((cost, i) => ({
|
|
578
|
-
type: "message.updated",
|
|
579
|
-
properties: {
|
|
580
|
-
info: {
|
|
581
|
-
id: `msg-${i}`,
|
|
582
|
-
sessionID: "sess-abc-123",
|
|
583
|
-
role: "assistant",
|
|
584
|
-
cost,
|
|
585
|
-
tokens: {
|
|
586
|
-
input: 100 + i * 10,
|
|
587
|
-
output: 50 + i * 5,
|
|
588
|
-
reasoning: 0,
|
|
589
|
-
cache: { read: i * 2, write: i },
|
|
590
|
-
},
|
|
591
|
-
// Phase 9 fix: accumulator gates on `time.completed` so simulated steps
|
|
592
|
-
// must look like finalized opencode messages.
|
|
593
|
-
time: { created: Date.now(), completed: Date.now() + 1 },
|
|
594
|
-
parentID: "",
|
|
595
|
-
modelID: "claude-opus",
|
|
596
|
-
providerID: "anthropic",
|
|
597
|
-
mode: "live",
|
|
598
|
-
path: { cwd: "/", root: "/" },
|
|
599
|
-
} as never,
|
|
600
|
-
},
|
|
601
|
-
}));
|
|
602
|
-
|
|
603
|
-
const events: OpencodeEvent[] = [
|
|
604
|
-
...stepEvents,
|
|
605
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
606
|
-
];
|
|
607
|
-
|
|
608
|
-
const { result } = await driveSession(events);
|
|
609
|
-
const expected = stepCosts.reduce((a, b) => a + b, 0);
|
|
610
|
-
expect(result.cost?.totalCostUsd).toBeCloseTo(expected, 10);
|
|
611
|
-
expect(result.cost?.numTurns).toBe(stepCosts.length);
|
|
612
|
-
expect(result.cost?.inputTokens).toBe(100 + 110 + 120);
|
|
613
|
-
expect(result.cost?.outputTokens).toBe(50 + 55 + 60);
|
|
614
|
-
expect(result.cost?.cacheReadTokens).toBe(0 + 2 + 4);
|
|
615
|
-
expect(result.cost?.cacheWriteTokens).toBe(0 + 1 + 2);
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
test("duplicate finalized snapshot with the same message id is counted once", async () => {
|
|
619
|
-
const message = finalizedMessage("msg-duplicate", {
|
|
620
|
-
cost: 0.004,
|
|
621
|
-
input: 100,
|
|
622
|
-
output: 50,
|
|
623
|
-
reasoning: 10,
|
|
624
|
-
cacheRead: 20,
|
|
625
|
-
cacheWrite: 5,
|
|
626
|
-
});
|
|
627
|
-
const { result } = await driveSession([
|
|
628
|
-
message,
|
|
629
|
-
message,
|
|
630
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
631
|
-
]);
|
|
632
|
-
|
|
633
|
-
expect(result.cost?.totalCostUsd).toBe(0.004);
|
|
634
|
-
expect(result.cost?.inputTokens).toBe(100);
|
|
635
|
-
expect(result.cost?.outputTokens).toBe(50);
|
|
636
|
-
expect(result.cost?.reasoningOutputTokens).toBe(10);
|
|
637
|
-
expect(result.cost?.cacheReadTokens).toBe(20);
|
|
638
|
-
expect(result.cost?.cacheWriteTokens).toBe(5);
|
|
639
|
-
expect(result.cost?.numTurns).toBe(1);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
test("latest finalized snapshot wins when a message id is updated", async () => {
|
|
643
|
-
const { result } = await driveSession([
|
|
644
|
-
finalizedMessage("msg-last-wins", {
|
|
645
|
-
cost: 0.004,
|
|
646
|
-
input: 100,
|
|
647
|
-
output: 50,
|
|
648
|
-
reasoning: 10,
|
|
649
|
-
cacheRead: 20,
|
|
650
|
-
cacheWrite: 5,
|
|
279
|
+
describe("OpencodeSession — event mapping", () => {
|
|
280
|
+
test("tool, text, step and usage events map to provider events and cost", async () => {
|
|
281
|
+
const { result, emitted, host } = await runSession([
|
|
282
|
+
ev("session.tool.input.started", { sessionID: SESSION_ID, id: "call_1", name: "shell" }),
|
|
283
|
+
ev("session.tool.called", { sessionID: SESSION_ID, id: "call_1", input: { command: "ls" } }),
|
|
284
|
+
ev("session.tool.success", {
|
|
285
|
+
sessionID: SESSION_ID,
|
|
286
|
+
id: "call_1",
|
|
287
|
+
content: [{ type: "text", text: "file.txt\n" }],
|
|
288
|
+
}),
|
|
289
|
+
ev("session.tool.input.started", { sessionID: SESSION_ID, id: "call_2", name: "read" }),
|
|
290
|
+
ev("session.tool.called", { sessionID: SESSION_ID, id: "call_2", input: {} }),
|
|
291
|
+
ev("session.tool.failed", {
|
|
292
|
+
sessionID: SESSION_ID,
|
|
293
|
+
id: "call_2",
|
|
294
|
+
error: { type: "unknown", message: "no such file" },
|
|
651
295
|
}),
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
cacheWrite: 8,
|
|
296
|
+
ev("session.step.ended", { sessionID: SESSION_ID, tokens: tokens(1000, 10, 200, 0) }),
|
|
297
|
+
ev("session.text.ended", {
|
|
298
|
+
sessionID: SESSION_ID,
|
|
299
|
+
assistantMessageID: "msg_2",
|
|
300
|
+
ordinal: 0,
|
|
301
|
+
text: "All done.",
|
|
659
302
|
}),
|
|
660
|
-
|
|
303
|
+
ev("session.step.ended", { sessionID: SESSION_ID, tokens: tokens(1200, 5) }),
|
|
304
|
+
ev("session.usage.updated", {
|
|
305
|
+
sessionID: SESSION_ID,
|
|
306
|
+
cost: 0.42,
|
|
307
|
+
tokens: tokens(2200, 15, 200, 0),
|
|
308
|
+
}),
|
|
309
|
+
succeeded,
|
|
661
310
|
]);
|
|
662
311
|
|
|
663
|
-
|
|
664
|
-
expect(
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
finalizedMessage("msg-reasoning-1", { cost: 0.001, input: 10, output: 5, reasoning: 7 }),
|
|
675
|
-
finalizedMessage("msg-reasoning-2", { cost: 0.002, input: 20, output: 8, reasoning: 11 }),
|
|
676
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
312
|
+
const visible = emitted.filter((e) => e.type !== "raw_log");
|
|
313
|
+
expect(visible.map((e) => e.type)).toEqual([
|
|
314
|
+
"session_init",
|
|
315
|
+
"tool_start",
|
|
316
|
+
"tool_end",
|
|
317
|
+
"tool_start",
|
|
318
|
+
"tool_end",
|
|
319
|
+
"context_usage",
|
|
320
|
+
"message",
|
|
321
|
+
"context_usage",
|
|
322
|
+
"result",
|
|
677
323
|
]);
|
|
324
|
+
expect(visible[1]).toEqual({
|
|
325
|
+
type: "tool_start",
|
|
326
|
+
toolCallId: "call_1",
|
|
327
|
+
toolName: "shell",
|
|
328
|
+
args: { command: "ls" },
|
|
329
|
+
});
|
|
330
|
+
expect(visible[2]).toMatchObject({ toolName: "shell", result: "file.txt\n", isError: false });
|
|
331
|
+
expect(visible[4]).toMatchObject({ toolName: "read", result: "no such file", isError: true });
|
|
332
|
+
expect(visible[5]).toMatchObject({ contextUsedTokens: 1210, outputTokens: 10 });
|
|
333
|
+
expect(visible[6]).toEqual({
|
|
334
|
+
type: "message",
|
|
335
|
+
role: "assistant",
|
|
336
|
+
content: "All done.",
|
|
337
|
+
messageId: "msg_2",
|
|
338
|
+
});
|
|
678
339
|
|
|
679
|
-
expect(result.
|
|
680
|
-
expect(result.
|
|
340
|
+
expect(result.exitCode).toBe(0);
|
|
341
|
+
expect(result.isError).toBe(false);
|
|
342
|
+
expect(result.cost).toMatchObject({
|
|
343
|
+
totalCostUsd: 0.42,
|
|
344
|
+
inputTokens: 2200,
|
|
345
|
+
outputTokens: 15,
|
|
346
|
+
cacheReadTokens: 200,
|
|
347
|
+
numTurns: 2,
|
|
348
|
+
provider: "opencode",
|
|
349
|
+
taskId: "task-1",
|
|
350
|
+
agentId: "agent-1",
|
|
351
|
+
});
|
|
352
|
+
expect(host.closeCalls).toBe(1);
|
|
681
353
|
});
|
|
682
354
|
|
|
683
|
-
test("
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
355
|
+
test("text parts of one message are joined in order", async () => {
|
|
356
|
+
const { emitted } = await runSession([
|
|
357
|
+
ev("session.text.ended", {
|
|
358
|
+
sessionID: SESSION_ID,
|
|
359
|
+
assistantMessageID: "m",
|
|
360
|
+
ordinal: 0,
|
|
361
|
+
text: "a ",
|
|
362
|
+
}),
|
|
363
|
+
ev("session.text.ended", {
|
|
364
|
+
sessionID: SESSION_ID,
|
|
365
|
+
assistantMessageID: "m",
|
|
366
|
+
ordinal: 1,
|
|
367
|
+
text: "b",
|
|
368
|
+
}),
|
|
369
|
+
succeeded,
|
|
695
370
|
]);
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
expect(result.cost?.totalCostUsd).toBeCloseTo(0.003, 10);
|
|
699
|
-
expect(result.cost?.numTurns).toBe(2);
|
|
371
|
+
const messages = emitted.filter((e) => e.type === "message");
|
|
372
|
+
expect(messages.map((m) => (m as { content: string }).content)).toEqual(["a", "a b"]);
|
|
700
373
|
});
|
|
701
374
|
|
|
702
|
-
test("
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
delete clone.properties.info.tokens;
|
|
708
|
-
const { result } = await driveSession([
|
|
709
|
-
clone as unknown as OpencodeEvent,
|
|
710
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
375
|
+
test("events from other sessions are ignored", async () => {
|
|
376
|
+
const { result, emitted } = await runSession([
|
|
377
|
+
ev("session.execution.failed", { sessionID: "ses_child", error: { message: "child" } }),
|
|
378
|
+
ev("session.usage.updated", { sessionID: "ses_child", cost: 9, tokens: tokens(1, 1) }),
|
|
379
|
+
succeeded,
|
|
711
380
|
]);
|
|
712
|
-
|
|
713
|
-
expect(result.cost?.totalCostUsd).
|
|
714
|
-
expect(
|
|
715
|
-
expect(result.cost?.outputTokens).toBe(0);
|
|
716
|
-
expect(result.cost?.numTurns).toBe(1);
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
test("cost data includes provider='opencode'", async () => {
|
|
720
|
-
const events: OpencodeEvent[] = [
|
|
721
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
722
|
-
];
|
|
723
|
-
const { result } = await driveSession(events);
|
|
724
|
-
expect(result.cost?.provider).toBe("opencode");
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
test("cost data includes taskId and agentId from config", async () => {
|
|
728
|
-
const cfg = testConfig({ taskId: "my-task", agentId: "my-agent" });
|
|
729
|
-
const events: OpencodeEvent[] = [
|
|
730
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
731
|
-
];
|
|
732
|
-
const { result } = await driveSession(events, cfg);
|
|
733
|
-
expect(result.cost?.taskId).toBe("my-task");
|
|
734
|
-
expect(result.cost?.agentId).toBe("my-agent");
|
|
735
|
-
});
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
describe("OpencodeSession — raw_log persistence", () => {
|
|
739
|
-
beforeEach(() => {
|
|
740
|
-
mock.restore();
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
test("every SSE event produces at least one raw_log row", async () => {
|
|
744
|
-
const events: OpencodeEvent[] = [
|
|
745
|
-
{
|
|
746
|
-
type: "message.updated",
|
|
747
|
-
properties: {
|
|
748
|
-
info: {
|
|
749
|
-
id: "msg-1",
|
|
750
|
-
sessionID: "sess-abc-123",
|
|
751
|
-
role: "assistant",
|
|
752
|
-
cost: 0.001,
|
|
753
|
-
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
754
|
-
time: { created: Date.now() },
|
|
755
|
-
parentID: "",
|
|
756
|
-
modelID: "claude-opus",
|
|
757
|
-
providerID: "anthropic",
|
|
758
|
-
mode: "live",
|
|
759
|
-
path: { cwd: "/", root: "/" },
|
|
760
|
-
} as never,
|
|
761
|
-
},
|
|
762
|
-
},
|
|
763
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
764
|
-
];
|
|
765
|
-
|
|
766
|
-
const { emitted } = await driveSession(events);
|
|
767
|
-
const rawLogs = emitted.filter((e) => e.type === "raw_log");
|
|
768
|
-
// At minimum: one per SSE event + one for the result event
|
|
769
|
-
expect(rawLogs.length).toBeGreaterThanOrEqual(events.length);
|
|
381
|
+
expect(result.isError).toBe(false);
|
|
382
|
+
expect(result.cost?.totalCostUsd).toBe(0);
|
|
383
|
+
expect(emitted.some((e) => e.type === "error")).toBe(false);
|
|
770
384
|
});
|
|
771
385
|
|
|
772
|
-
test("
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
const rawLogs = emitted.filter((e) => e.type === "raw_log");
|
|
778
|
-
for (const rl of rawLogs) {
|
|
779
|
-
if (rl.type === "raw_log") {
|
|
780
|
-
expect(() => JSON.parse(rl.content)).not.toThrow();
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
}, 30_000);
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
// ── Phase 9: context_usage emission ───────────────────────────────────────────
|
|
787
|
-
|
|
788
|
-
describe("OpencodeSession — context_usage emission (phase 9 fix)", () => {
|
|
789
|
-
beforeEach(() => {
|
|
790
|
-
mock.restore();
|
|
386
|
+
test("every handled event produces a scrubbed raw_log", async () => {
|
|
387
|
+
const { emitted } = await runSession([succeeded]);
|
|
388
|
+
const raw = emitted.filter((e) => e.type === "raw_log");
|
|
389
|
+
expect(raw.length).toBeGreaterThan(0);
|
|
390
|
+
for (const r of raw) expect(() => JSON.parse((r as { content: string }).content)).not.toThrow();
|
|
791
391
|
});
|
|
792
392
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
input?: number;
|
|
799
|
-
output?: number;
|
|
800
|
-
cacheRead?: number;
|
|
801
|
-
cacheWrite?: number;
|
|
802
|
-
cost?: number;
|
|
803
|
-
modelID?: string;
|
|
804
|
-
} = {},
|
|
805
|
-
): OpencodeEvent {
|
|
806
|
-
const now = Date.now();
|
|
807
|
-
return {
|
|
808
|
-
type: "message.updated",
|
|
809
|
-
properties: {
|
|
810
|
-
info: {
|
|
811
|
-
id: `msg-${now}`,
|
|
812
|
-
sessionID: overrides.sessionID ?? "sess-abc-123",
|
|
813
|
-
role: "assistant",
|
|
814
|
-
cost: overrides.cost ?? 0.001,
|
|
815
|
-
tokens: {
|
|
816
|
-
input: overrides.input ?? 0,
|
|
817
|
-
output: overrides.output ?? 0,
|
|
818
|
-
reasoning: 0,
|
|
819
|
-
cache: {
|
|
820
|
-
read: overrides.cacheRead ?? 0,
|
|
821
|
-
write: overrides.cacheWrite ?? 0,
|
|
822
|
-
},
|
|
823
|
-
},
|
|
824
|
-
time: overrides.completed ? { created: now, completed: now + 1 } : { created: now },
|
|
825
|
-
parentID: "",
|
|
826
|
-
modelID: overrides.modelID ?? "claude-sonnet-4-5",
|
|
827
|
-
providerID: "anthropic",
|
|
828
|
-
mode: "live",
|
|
829
|
-
path: { cwd: "/", root: "/" },
|
|
830
|
-
} as never,
|
|
831
|
-
},
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
test("finalized message with real tokens → emits context_usage matching the cost row", async () => {
|
|
836
|
-
// Mirrors the E2E evidence: opencode reports `in=12, cache.read=99970,
|
|
837
|
-
// cache.write=104606, out=288` on the FINAL message.updated for the turn.
|
|
838
|
-
const events: OpencodeEvent[] = [
|
|
839
|
-
makeMessageUpdated({
|
|
840
|
-
completed: true,
|
|
841
|
-
input: 12,
|
|
842
|
-
output: 288,
|
|
843
|
-
cacheRead: 99970,
|
|
844
|
-
cacheWrite: 104606,
|
|
393
|
+
test("session.execution.failed fails the task with the error message", async () => {
|
|
394
|
+
const { result, emitted } = await runSession([
|
|
395
|
+
ev("session.execution.failed", {
|
|
396
|
+
sessionID: SESSION_ID,
|
|
397
|
+
error: { type: "provider", message: "rate limited" },
|
|
845
398
|
}),
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
const { emitted, result } = await driveSession(events);
|
|
850
|
-
|
|
851
|
-
const contextEvents = emitted.filter((e) => e.type === "context_usage");
|
|
852
|
-
expect(contextEvents.length).toBe(1);
|
|
853
|
-
const ctx = contextEvents[0];
|
|
854
|
-
if (ctx?.type === "context_usage") {
|
|
855
|
-
// Unified formula: input + cache_read + cache_write + output
|
|
856
|
-
expect(ctx.contextUsedTokens).toBe(12 + 99970 + 104606 + 288);
|
|
857
|
-
expect(ctx.contextFormula).toBe("input-cache-output");
|
|
858
|
-
expect(ctx.outputTokens).toBe(288);
|
|
859
|
-
expect(ctx.contextTotalTokens).toBeGreaterThan(0);
|
|
860
|
-
expect(ctx.contextPercent).toBeGreaterThan(0);
|
|
861
|
-
}
|
|
862
|
-
// The cost row stays consistent — same tokens, single turn.
|
|
863
|
-
expect(result.cost?.inputTokens).toBe(12);
|
|
864
|
-
expect(result.cost?.cacheReadTokens).toBe(99970);
|
|
865
|
-
expect(result.cost?.cacheWriteTokens).toBe(104606);
|
|
866
|
-
expect(result.cost?.outputTokens).toBe(288);
|
|
867
|
-
expect(result.cost?.numTurns).toBe(1);
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
test("non-finalized message.updated (tokens all zero) → NO context_usage emission", async () => {
|
|
871
|
-
// Simulates opencode's intermediate streaming updates that arrive before
|
|
872
|
-
// the model returns usage counts. Pre-fix, these emitted a 0-token snapshot
|
|
873
|
-
// that the runner-side throttle pinned for the rest of the session.
|
|
874
|
-
const events: OpencodeEvent[] = [
|
|
875
|
-
makeMessageUpdated({ completed: false }),
|
|
876
|
-
makeMessageUpdated({ completed: false }),
|
|
877
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
878
|
-
];
|
|
879
|
-
|
|
880
|
-
const { emitted, result } = await driveSession(events);
|
|
881
|
-
|
|
882
|
-
const contextEvents = emitted.filter((e) => e.type === "context_usage");
|
|
883
|
-
expect(contextEvents.length).toBe(0);
|
|
884
|
-
// Cost accumulator also skipped non-finalized updates.
|
|
885
|
-
expect(result.cost?.numTurns).toBe(0);
|
|
886
|
-
expect(result.cost?.totalCostUsd).toBe(0);
|
|
399
|
+
]);
|
|
400
|
+
expect(result).toMatchObject({ exitCode: 1, isError: true, failureReason: "rate limited" });
|
|
401
|
+
expect(emitted).toContainEqual({ type: "error", message: "rate limited" });
|
|
887
402
|
});
|
|
888
403
|
|
|
889
|
-
test("
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
makeMessageUpdated({
|
|
897
|
-
completed: true,
|
|
898
|
-
input: 50,
|
|
899
|
-
output: 200,
|
|
900
|
-
cacheRead: 1000,
|
|
901
|
-
cacheWrite: 500,
|
|
404
|
+
test("permission.asked fails the session (headless cannot answer)", async () => {
|
|
405
|
+
const { result } = await runSession([
|
|
406
|
+
ev("permission.asked", {
|
|
407
|
+
sessionID: SESSION_ID,
|
|
408
|
+
id: "per_1",
|
|
409
|
+
action: "shell",
|
|
410
|
+
resources: [],
|
|
902
411
|
}),
|
|
903
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
904
|
-
];
|
|
905
|
-
|
|
906
|
-
const { emitted, result } = await driveSession(events);
|
|
907
|
-
|
|
908
|
-
const contextEvents = emitted.filter((e) => e.type === "context_usage");
|
|
909
|
-
expect(contextEvents.length).toBe(1);
|
|
910
|
-
if (contextEvents[0]?.type === "context_usage") {
|
|
911
|
-
expect(contextEvents[0].contextUsedTokens).toBe(50 + 1000 + 500 + 200);
|
|
912
|
-
}
|
|
913
|
-
expect(result.cost?.numTurns).toBe(1);
|
|
914
|
-
expect(result.cost?.inputTokens).toBe(50);
|
|
915
|
-
});
|
|
916
|
-
|
|
917
|
-
test("finalized message with all-zero tokens → still no emission (guards against pathological zero turns)", async () => {
|
|
918
|
-
const events: OpencodeEvent[] = [
|
|
919
|
-
makeMessageUpdated({ completed: true }), // all zero tokens
|
|
920
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
921
|
-
];
|
|
922
|
-
|
|
923
|
-
const { emitted } = await driveSession(events);
|
|
924
|
-
const contextEvents = emitted.filter((e) => e.type === "context_usage");
|
|
925
|
-
expect(contextEvents.length).toBe(0);
|
|
926
|
-
});
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
// ── DES-300: per-task isolation ────────────────────────────────────────────────
|
|
930
|
-
|
|
931
|
-
describe("OpencodeSession — assistant text for final-message validation", () => {
|
|
932
|
-
beforeEach(() => {
|
|
933
|
-
mock.restore();
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
const SESSION = "sess-abc-123";
|
|
937
|
-
|
|
938
|
-
function messageUpdated(
|
|
939
|
-
id: string,
|
|
940
|
-
role: "assistant" | "user",
|
|
941
|
-
completed: boolean,
|
|
942
|
-
): OpencodeEvent {
|
|
943
|
-
const now = Date.now();
|
|
944
|
-
return {
|
|
945
|
-
type: "message.updated",
|
|
946
|
-
properties: {
|
|
947
|
-
info: {
|
|
948
|
-
id,
|
|
949
|
-
sessionID: SESSION,
|
|
950
|
-
role,
|
|
951
|
-
cost: 0,
|
|
952
|
-
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
953
|
-
time: completed ? { created: now, completed: now + 1 } : { created: now },
|
|
954
|
-
parentID: "",
|
|
955
|
-
modelID: "m",
|
|
956
|
-
providerID: "p",
|
|
957
|
-
mode: "live",
|
|
958
|
-
path: { cwd: "/", root: "/" },
|
|
959
|
-
} as never,
|
|
960
|
-
},
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function textPart(
|
|
965
|
-
messageID: string,
|
|
966
|
-
id: string,
|
|
967
|
-
text: string,
|
|
968
|
-
extra: { synthetic?: boolean; ignored?: boolean } = {},
|
|
969
|
-
): OpencodeEvent {
|
|
970
|
-
return {
|
|
971
|
-
type: "message.part.updated",
|
|
972
|
-
properties: {
|
|
973
|
-
part: { id, sessionID: SESSION, messageID, type: "text", text, ...extra } as never,
|
|
974
|
-
},
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
const idle: OpencodeEvent = { type: "session.idle", properties: { sessionID: SESSION } };
|
|
979
|
-
|
|
980
|
-
function assistantTexts(emitted: ProviderEvent[]): string[] {
|
|
981
|
-
return emitted.flatMap((e) =>
|
|
982
|
-
e.type === "message" && e.role === "assistant" ? [e.content] : [],
|
|
983
|
-
);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
test("the runner's final-message buffer holds the last step's text, ready for outputSchema validation", async () => {
|
|
987
|
-
const final = JSON.stringify({ verdict: "pass", score: 3 });
|
|
988
|
-
const { emitted } = await driveSession([
|
|
989
|
-
messageUpdated("m1", "assistant", false),
|
|
990
|
-
textPart("m1", "p1", "Checking the page first."),
|
|
991
|
-
messageUpdated("m1", "assistant", true),
|
|
992
|
-
// Step 2 is tool-free and carries the answer.
|
|
993
|
-
messageUpdated("m2", "assistant", false),
|
|
994
|
-
textPart("m2", "p2", final),
|
|
995
|
-
messageUpdated("m2", "assistant", true),
|
|
996
|
-
idle,
|
|
997
412
|
]);
|
|
998
|
-
|
|
999
|
-
expect(
|
|
1000
|
-
|
|
1001
|
-
const holder: { value?: string } = {};
|
|
1002
|
-
for (const e of emitted) trackAssistantText(holder, e);
|
|
1003
|
-
expect(resolveProviderOutput({ output: undefined }, holder)).toBe(final);
|
|
413
|
+
expect(result.isError).toBe(true);
|
|
414
|
+
expect(result.failureReason).toContain("per_1");
|
|
1004
415
|
});
|
|
1005
416
|
|
|
1006
|
-
test("
|
|
1007
|
-
const {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
messageUpdated("m1", "assistant", false),
|
|
1011
|
-
textPart("m1", "p-synth", "injected reminder", { synthetic: true }),
|
|
1012
|
-
textPart("m1", "p-ign", "ignored text", { ignored: true }),
|
|
1013
|
-
textPart("m1", "p1", "real answer"),
|
|
1014
|
-
messageUpdated("m1", "assistant", true),
|
|
1015
|
-
idle,
|
|
1016
|
-
]);
|
|
1017
|
-
|
|
1018
|
-
expect(assistantTexts(emitted)).toEqual(["real answer"]);
|
|
417
|
+
test("a stream that ends without a terminal event settles the session as failed", async () => {
|
|
418
|
+
const { result } = await runSession([], testConfig(), { endStream: true });
|
|
419
|
+
expect(result.isError).toBe(true);
|
|
420
|
+
expect(result.failureReason).toBe("runner exited without result: opencode event stream ended");
|
|
1019
421
|
});
|
|
1020
422
|
|
|
1021
|
-
test("
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
textPart("m1", "p1", '{"a"'),
|
|
1025
|
-
textPart("m1", "p1", '{"a": 1}'),
|
|
1026
|
-
messageUpdated("m1", "assistant", true),
|
|
1027
|
-
messageUpdated("m1", "assistant", true),
|
|
1028
|
-
idle,
|
|
1029
|
-
]);
|
|
423
|
+
test("a step on a different model than requested fails the session", async () => {
|
|
424
|
+
const step = (providerID: string, id: string) =>
|
|
425
|
+
ev("session.step.started", { sessionID: SESSION_ID, model: { id, providerID } });
|
|
1030
426
|
|
|
1031
|
-
|
|
1032
|
-
|
|
427
|
+
const ok = await runSession([step("openrouter", "google/gemini-3-flash-preview"), succeeded]);
|
|
428
|
+
expect(ok.result.isError).toBe(false);
|
|
1033
429
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
textPart("m1", "p1", "first half. "),
|
|
1038
|
-
messageUpdated("m1", "assistant", true),
|
|
1039
|
-
textPart("m1", "p2", "second half."),
|
|
1040
|
-
idle,
|
|
430
|
+
const { result } = await runSession([
|
|
431
|
+
step("openrouter", "anthropic/claude-haiku-5.5"),
|
|
432
|
+
succeeded,
|
|
1041
433
|
]);
|
|
1042
|
-
|
|
1043
|
-
expect(
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
1048
|
-
let prevOpencodeSkillsDir: string | undefined;
|
|
1049
|
-
|
|
1050
|
-
beforeEach(() => {
|
|
1051
|
-
prevOpencodeSkillsDir = process.env.OPENCODE_SKILLS_DIR;
|
|
1052
|
-
lastPromptArgs = undefined;
|
|
1053
|
-
lastCreateOpencodeConfig = undefined;
|
|
1054
|
-
mock.restore();
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
afterEach(() => {
|
|
1058
|
-
if (prevOpencodeSkillsDir === undefined) delete process.env.OPENCODE_SKILLS_DIR;
|
|
1059
|
-
else process.env.OPENCODE_SKILLS_DIR = prevOpencodeSkillsDir;
|
|
1060
|
-
// Clean up any written files from tests
|
|
1061
|
-
Bun.$`rm -rf /tmp/opencode-task-1.json /tmp/opencode-data-task-1`.quiet().nothrow();
|
|
1062
|
-
Bun.$`rm -rf /tmp/test/.opencode`.quiet().nothrow();
|
|
1063
|
-
rmSync("/tmp/opencode-skills-test", { recursive: true, force: true });
|
|
1064
|
-
});
|
|
1065
|
-
|
|
1066
|
-
test("session.prompt receives agent=swarm-<taskId>", async () => {
|
|
1067
|
-
const events: OpencodeEvent[] = [
|
|
1068
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1069
|
-
];
|
|
1070
|
-
const cfg = testConfig({ taskId: "task-1" });
|
|
1071
|
-
await driveSession(events, cfg);
|
|
1072
|
-
|
|
1073
|
-
expect(lastPromptArgs).toBeDefined();
|
|
1074
|
-
const args = lastPromptArgs as { body?: { agent?: string } };
|
|
1075
|
-
expect(args.body?.agent).toBe("swarm-task-1");
|
|
434
|
+
expect(result.isError).toBe(true);
|
|
435
|
+
expect(result.failureReason).toBe(
|
|
436
|
+
"opencode ran openrouter/anthropic/claude-haiku-5.5 instead of the requested model openrouter/google/gemini-3-flash-preview; the requested model is not in OpenCode's catalog",
|
|
437
|
+
);
|
|
1076
438
|
});
|
|
1077
439
|
|
|
1078
|
-
test("
|
|
1079
|
-
const
|
|
1080
|
-
|
|
1081
|
-
writeFileSync(join(skillDir, "SKILL.md"), "Use the task worker procedure.");
|
|
1082
|
-
process.env.OPENCODE_SKILLS_DIR = "/tmp/opencode-skills-test";
|
|
1083
|
-
|
|
1084
|
-
const events: OpencodeEvent[] = [
|
|
1085
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1086
|
-
];
|
|
1087
|
-
const cfg = testConfig({
|
|
1088
|
-
taskId: "task-1",
|
|
1089
|
-
prompt: "/work-on-task task-123\n\nTask body.",
|
|
440
|
+
test("a rejected prompt fails the session", async () => {
|
|
441
|
+
const { result } = await runSession([], testConfig(), {
|
|
442
|
+
promptError: new Error("model not found: x/y"),
|
|
1090
443
|
});
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
const args = lastPromptArgs as { body?: { parts?: Array<{ type: string; text: string }> } };
|
|
1094
|
-
const text = args.body?.parts?.[0]?.text ?? "";
|
|
1095
|
-
expect(text).toStartWith("Use the task worker procedure.");
|
|
1096
|
-
expect(text).toContain("User request: task-123\n\nTask body.");
|
|
1097
|
-
expect(text).not.toContain("/work-on-task task-123");
|
|
444
|
+
expect(result).toMatchObject({ isError: true, failureReason: "model not found: x/y" });
|
|
1098
445
|
});
|
|
446
|
+
});
|
|
1099
447
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
448
|
+
describe("OpencodeSession — persisted rows render in the dashboard", () => {
|
|
449
|
+
test("text, tools and errors render once each, in order", async () => {
|
|
450
|
+
const text = (ordinal: number, value: string, messageID = "msg_1") => [
|
|
451
|
+
ev("session.text.started", { sessionID: SESSION_ID, assistantMessageID: messageID, ordinal }),
|
|
452
|
+
ev("session.text.delta", {
|
|
453
|
+
sessionID: SESSION_ID,
|
|
454
|
+
assistantMessageID: messageID,
|
|
455
|
+
ordinal,
|
|
456
|
+
delta: value,
|
|
457
|
+
}),
|
|
458
|
+
ev("session.text.ended", {
|
|
459
|
+
sessionID: SESSION_ID,
|
|
460
|
+
assistantMessageID: messageID,
|
|
461
|
+
ordinal,
|
|
462
|
+
text: value,
|
|
463
|
+
}),
|
|
1103
464
|
];
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
type: string;
|
|
1128
|
-
url: string;
|
|
1129
|
-
headers?: Record<string, string>;
|
|
1130
|
-
};
|
|
1131
|
-
expect(swarm.type).toBe("remote");
|
|
1132
|
-
expect(swarm.url).toContain("http://localhost:9999");
|
|
1133
|
-
expect(swarm.headers?.Authorization).toContain("mykey");
|
|
1134
|
-
expect(opts.config?.permission?.edit).toBe("allow");
|
|
1135
|
-
});
|
|
1136
|
-
|
|
1137
|
-
test("swarm MCP headers carry the per-boot runtime identity when set", async () => {
|
|
1138
|
-
const prev = process.env.SWARM_RUNTIME_INSTANCE_ID;
|
|
1139
|
-
process.env.SWARM_RUNTIME_INSTANCE_ID = "opencode-runtime-1";
|
|
1140
|
-
try {
|
|
1141
|
-
const events: OpencodeEvent[] = [
|
|
1142
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1143
|
-
];
|
|
1144
|
-
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
465
|
+
const { emitted } = await runSession([
|
|
466
|
+
...text(0, "Listing files."),
|
|
467
|
+
ev("session.tool.input.started", { sessionID: SESSION_ID, id: "call_1", name: "shell" }),
|
|
468
|
+
ev("session.tool.called", { sessionID: SESSION_ID, id: "call_1", input: { command: "ls" } }),
|
|
469
|
+
ev("session.tool.success", {
|
|
470
|
+
sessionID: SESSION_ID,
|
|
471
|
+
id: "call_1",
|
|
472
|
+
content: [{ type: "text", text: "file.txt\n" }],
|
|
473
|
+
}),
|
|
474
|
+
...text(1, " Found one."),
|
|
475
|
+
ev("session.tool.input.started", { sessionID: SESSION_ID, id: "call_2", name: "read" }),
|
|
476
|
+
ev("session.tool.called", { sessionID: SESSION_ID, id: "call_2", input: { path: "x" } }),
|
|
477
|
+
ev("session.tool.failed", {
|
|
478
|
+
sessionID: SESSION_ID,
|
|
479
|
+
id: "call_2",
|
|
480
|
+
error: { type: "unknown", message: "no such file" },
|
|
481
|
+
}),
|
|
482
|
+
ev("session.step.ended", { sessionID: SESSION_ID, tokens: tokens(1000, 10) }),
|
|
483
|
+
ev("session.execution.failed", {
|
|
484
|
+
sessionID: SESSION_ID,
|
|
485
|
+
error: { type: "unknown", message: "provider overloaded" },
|
|
486
|
+
}),
|
|
487
|
+
]);
|
|
1145
488
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
489
|
+
// The runner stores every raw_log line verbatim as a session log row.
|
|
490
|
+
const rows: SessionLogRecord[] = emitted
|
|
491
|
+
.filter((e) => e.type === "raw_log")
|
|
492
|
+
.map((e, index) => ({
|
|
493
|
+
id: `row-${index}`,
|
|
494
|
+
taskId: "task-1",
|
|
495
|
+
sessionId: "session-1",
|
|
496
|
+
iteration: 1,
|
|
497
|
+
cli: "opencode",
|
|
498
|
+
content: (e as { content: string }).content,
|
|
499
|
+
lineNumber: index + 1,
|
|
500
|
+
createdAt: "2026-10-13T10:00:00.000Z",
|
|
501
|
+
}));
|
|
502
|
+
const { items } = normalizeSessionLogs(rows);
|
|
503
|
+
|
|
504
|
+
const visible = items
|
|
505
|
+
.filter((item) => item.kind !== "lifecycle")
|
|
506
|
+
.map((item) =>
|
|
507
|
+
item.kind === "text"
|
|
508
|
+
? `${item.role} text: ${item.text}`
|
|
509
|
+
: item.kind === "tool_call"
|
|
510
|
+
? `tool_call ${item.tool?.name} ${JSON.stringify(item.tool?.input)}`
|
|
511
|
+
: item.kind === "tool_result"
|
|
512
|
+
? `tool_result ${item.result?.id} error=${item.result?.isError} ${item.result?.payload}`
|
|
513
|
+
: item.kind,
|
|
1151
514
|
);
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
await driveSession(
|
|
1163
|
-
events,
|
|
1164
|
-
testConfig({ taskId: "task-1", contextKey: "task:slack:C1:1700000000.000100" }),
|
|
1165
|
-
);
|
|
1166
|
-
|
|
1167
|
-
const opts = lastCreateOpencodeConfig as {
|
|
1168
|
-
config?: { mcp?: Record<string, { headers?: Record<string, string> }> };
|
|
1169
|
-
};
|
|
1170
|
-
const headers = opts.config?.mcp?.swarm?.headers;
|
|
1171
|
-
expect(headers?.["X-Source-Task-Id"]).toBe("task-1");
|
|
1172
|
-
expect(headers?.["X-Context-Key"]).toBe("task:slack:C1:1700000000.000100");
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
test("swarm MCP headers omit the context key when the task has none", async () => {
|
|
1176
|
-
const events: OpencodeEvent[] = [
|
|
1177
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1178
|
-
];
|
|
1179
|
-
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
1180
|
-
|
|
1181
|
-
const opts = lastCreateOpencodeConfig as {
|
|
1182
|
-
config?: { mcp?: Record<string, { headers?: Record<string, string> }> };
|
|
1183
|
-
};
|
|
1184
|
-
const headers = opts.config?.mcp?.swarm?.headers;
|
|
1185
|
-
expect(headers?.["X-Source-Task-Id"]).toBe("task-1");
|
|
1186
|
-
expect(headers?.["X-Context-Key"]).toBeUndefined();
|
|
1187
|
-
});
|
|
1188
|
-
|
|
1189
|
-
test("swarm MCP headers omit the runtime identity when unset", async () => {
|
|
1190
|
-
const prev = process.env.SWARM_RUNTIME_INSTANCE_ID;
|
|
1191
|
-
delete process.env.SWARM_RUNTIME_INSTANCE_ID;
|
|
1192
|
-
try {
|
|
1193
|
-
const events: OpencodeEvent[] = [
|
|
1194
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1195
|
-
];
|
|
1196
|
-
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
1197
|
-
|
|
1198
|
-
const opts = lastCreateOpencodeConfig as {
|
|
1199
|
-
config?: { mcp?: Record<string, { headers?: Record<string, string> }> };
|
|
1200
|
-
};
|
|
1201
|
-
expect(opts.config?.mcp?.swarm?.headers?.["X-Runtime-Instance-ID"]).toBeUndefined();
|
|
1202
|
-
} finally {
|
|
1203
|
-
if (prev === undefined) delete process.env.SWARM_RUNTIME_INSTANCE_ID;
|
|
1204
|
-
else process.env.SWARM_RUNTIME_INSTANCE_ID = prev;
|
|
1205
|
-
}
|
|
515
|
+
expect(visible).toEqual([
|
|
516
|
+
"assistant text: Listing files.",
|
|
517
|
+
'tool_call shell {"command":"ls"}',
|
|
518
|
+
"tool_result call_1 error=false file.txt\n",
|
|
519
|
+
"assistant text: Found one.",
|
|
520
|
+
'tool_call read {"path":"x"}',
|
|
521
|
+
"tool_result call_2 error=true no such file",
|
|
522
|
+
"system text: opencode error: provider overloaded",
|
|
523
|
+
]);
|
|
524
|
+
expect(items.some((item) => item.kind === "unknown")).toBe(false);
|
|
1206
525
|
});
|
|
526
|
+
});
|
|
1207
527
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
expect(content).toContain("be a coder");
|
|
1219
|
-
}
|
|
528
|
+
describe("OpencodeSession — session end is bounded", () => {
|
|
529
|
+
test("a profile sync that never answers cannot strand the host or completion", async () => {
|
|
530
|
+
let syncCalls = 0;
|
|
531
|
+
_setOpencodeSessionHooksForTests({
|
|
532
|
+
budgetMs: 50,
|
|
533
|
+
// A held profile PUT that ignores cancellation: the worst case.
|
|
534
|
+
syncIdentity: () => {
|
|
535
|
+
syncCalls++;
|
|
536
|
+
return new Promise<void>(() => {});
|
|
537
|
+
},
|
|
1220
538
|
});
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
};
|
|
1239
|
-
expect(parsed.mcp).toBeDefined();
|
|
1240
|
-
expect(parsed.permission).toBeDefined();
|
|
1241
|
-
// The per-task file must carry the task identity too: `opencode serve`
|
|
1242
|
-
// reads this file (via OPENCODE_CONFIG), not the in-process object.
|
|
1243
|
-
expect(parsed.mcp?.swarm?.headers?.["X-Source-Task-Id"]).toBe("task-cfg-json");
|
|
1244
|
-
expect(parsed.mcp?.swarm?.headers?.["X-Context-Key"]).toBe("task:api:ctx-1");
|
|
1245
|
-
}
|
|
539
|
+
const { result, host } = await runSession([succeeded]);
|
|
540
|
+
expect(syncCalls).toBe(1);
|
|
541
|
+
expect(host.closeCalls).toBe(1);
|
|
542
|
+
expect(result).toMatchObject({ exitCode: 0, isError: false });
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("abort while finishing cancels the sync, closes the host, and completes", async () => {
|
|
546
|
+
let syncSignal: AbortSignal | undefined;
|
|
547
|
+
_setOpencodeSessionHooksForTests({
|
|
548
|
+
budgetMs: 60_000,
|
|
549
|
+
// A deferred profile response that only ends when the request is cancelled.
|
|
550
|
+
syncIdentity: (_config, _source, signal) => {
|
|
551
|
+
syncSignal = signal;
|
|
552
|
+
return new Promise<void>((resolve) =>
|
|
553
|
+
signal?.addEventListener("abort", () => resolve(), { once: true }),
|
|
554
|
+
);
|
|
555
|
+
},
|
|
1246
556
|
});
|
|
557
|
+
const { session, host } = await startSession([succeeded]);
|
|
558
|
+
await waitFor(() => syncSignal !== undefined);
|
|
559
|
+
expect(session.isFinished).toBe(true);
|
|
560
|
+
expect(host.closeCalls).toBe(0);
|
|
1247
561
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
562
|
+
await session.abort();
|
|
563
|
+
const result = await session.waitForCompletion();
|
|
564
|
+
expect(syncSignal?.aborted).toBe(true);
|
|
565
|
+
expect(host.closeCalls).toBe(1);
|
|
566
|
+
expect(result).toMatchObject({ exitCode: 0, isError: false });
|
|
1251
567
|
});
|
|
1252
568
|
});
|
|
1253
569
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
lastCreateOpencodeConfig = undefined;
|
|
1259
|
-
mock.restore();
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
test("reasoningEffort: 'low' on an openrouter model sets provider.openrouter.models[id].options.reasoning.effort", async () => {
|
|
1263
|
-
const events: OpencodeEvent[] = [
|
|
1264
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1265
|
-
];
|
|
1266
|
-
const cfg = testConfig({
|
|
1267
|
-
model: "openrouter/google/gemini-3-flash-preview",
|
|
1268
|
-
reasoningEffort: "low",
|
|
570
|
+
describe("OpencodeAdapter — swarm MCP admission", () => {
|
|
571
|
+
test("waits for a pending server, then prompts once store-progress is registered", async () => {
|
|
572
|
+
const { host, session } = await startSession([], testConfig(), {
|
|
573
|
+
mcpStatuses: [null, { status: "pending" }, { status: "connected" }],
|
|
1269
574
|
});
|
|
1270
|
-
await
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
config?: { provider?: Record<string, { models?: Record<string, { options?: unknown }> }> };
|
|
1274
|
-
};
|
|
1275
|
-
const options =
|
|
1276
|
-
opts.config?.provider?.openrouter?.models?.["google/gemini-3-flash-preview"]?.options;
|
|
1277
|
-
expect(options).toEqual({ reasoning: { effort: "low" } });
|
|
575
|
+
await waitFor(() => host.prompts.length > 0);
|
|
576
|
+
expect(host.mcpListCalls).toBeGreaterThanOrEqual(3);
|
|
577
|
+
await session.abort();
|
|
1278
578
|
});
|
|
1279
579
|
|
|
1280
|
-
test("
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
reasoningEffort: "high",
|
|
580
|
+
test("a refused server fails the session before the first prompt and closes the host", async () => {
|
|
581
|
+
const { result, host } = await runSession([], testConfig(), {
|
|
582
|
+
mcpStatuses: [
|
|
583
|
+
{ status: "pending" },
|
|
584
|
+
{ status: "failed", error: "Unable to connect. (ConnectionRefused)" },
|
|
585
|
+
],
|
|
1287
586
|
});
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
expect(options).toEqual({ thinking: { type: "enabled", budgetTokens: 32768 } });
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
test("undefined reasoningEffort leaves model options unset", async () => {
|
|
1298
|
-
const events: OpencodeEvent[] = [
|
|
1299
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1300
|
-
];
|
|
1301
|
-
const cfg = testConfig({ model: "openrouter/google/gemini-3-flash-preview" });
|
|
1302
|
-
await driveSession(events, cfg);
|
|
1303
|
-
|
|
1304
|
-
const opts = lastCreateOpencodeConfig as {
|
|
1305
|
-
config?: { provider?: Record<string, { models?: unknown }> };
|
|
1306
|
-
};
|
|
1307
|
-
expect(opts.config?.provider?.openrouter?.models).toBeUndefined();
|
|
1308
|
-
});
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
// ── Phase 4: context-mode in-process plugin ────────────────────────────────────
|
|
1312
|
-
|
|
1313
|
-
describe("OpencodeAdapter — context-mode plugin wiring (phase 4)", () => {
|
|
1314
|
-
let prevContextModeDisabled: string | undefined;
|
|
1315
|
-
let prevContextModePluginPath: string | undefined;
|
|
1316
|
-
// The global npm install of context-mode is absent in the test env, so point
|
|
1317
|
-
// the override at a real temp file to make resolution succeed deterministically.
|
|
1318
|
-
const fakePluginPath = "/tmp/ctx-mode-opencode-plugin.test.js";
|
|
1319
|
-
|
|
1320
|
-
beforeEach(() => {
|
|
1321
|
-
prevContextModeDisabled = process.env.CONTEXT_MODE_DISABLED;
|
|
1322
|
-
prevContextModePluginPath = process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
|
|
1323
|
-
lastCreateOpencodeConfig = undefined;
|
|
1324
|
-
mock.restore();
|
|
1325
|
-
});
|
|
1326
|
-
|
|
1327
|
-
afterEach(() => {
|
|
1328
|
-
// Never leak the env mutations across tests.
|
|
1329
|
-
if (prevContextModeDisabled === undefined) delete process.env.CONTEXT_MODE_DISABLED;
|
|
1330
|
-
else process.env.CONTEXT_MODE_DISABLED = prevContextModeDisabled;
|
|
1331
|
-
if (prevContextModePluginPath === undefined)
|
|
1332
|
-
delete process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
|
|
1333
|
-
else process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = prevContextModePluginPath;
|
|
1334
|
-
Bun.$`rm -rf /tmp/opencode-task-1.json /tmp/opencode-data-task-1`.quiet().nothrow();
|
|
1335
|
-
Bun.$`rm -rf /tmp/test/.opencode`.quiet().nothrow();
|
|
1336
|
-
Bun.$`rm -f ${fakePluginPath}`.quiet().nothrow();
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
/** Pull the opencode config object passed to createOpencode. */
|
|
1340
|
-
function getBuiltConfig(): { plugin?: string[]; mcp?: Record<string, unknown> } {
|
|
1341
|
-
const opts = lastCreateOpencodeConfig as {
|
|
1342
|
-
config?: { plugin?: string[]; mcp?: Record<string, unknown> };
|
|
1343
|
-
};
|
|
1344
|
-
expect(opts.config).toBeDefined();
|
|
1345
|
-
return opts.config as { plugin?: string[]; mcp?: Record<string, unknown> };
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
test("resolveContextModePluginPath returns the override path when it exists", async () => {
|
|
1349
|
-
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
1350
|
-
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
1351
|
-
const { resolveContextModePluginPath } = await import("../providers/opencode-adapter");
|
|
1352
|
-
expect(resolveContextModePluginPath()).toBe(fakePluginPath);
|
|
1353
|
-
});
|
|
1354
|
-
|
|
1355
|
-
test("resolveContextModePluginPath returns null when the override path is missing", async () => {
|
|
1356
|
-
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = "/tmp/ctx-mode-does-not-exist.js";
|
|
1357
|
-
const { resolveContextModePluginPath } = await import("../providers/opencode-adapter");
|
|
1358
|
-
expect(resolveContextModePluginPath()).toBeNull();
|
|
1359
|
-
});
|
|
1360
|
-
|
|
1361
|
-
test("plugin array includes the resolved context-mode plugin path when available", async () => {
|
|
1362
|
-
delete process.env.CONTEXT_MODE_DISABLED;
|
|
1363
|
-
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
1364
|
-
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
1365
|
-
const events: OpencodeEvent[] = [
|
|
1366
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1367
|
-
];
|
|
1368
|
-
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
1369
|
-
|
|
1370
|
-
const built = getBuiltConfig();
|
|
1371
|
-
expect(built.plugin).toContain(fakePluginPath);
|
|
1372
|
-
// The bare package name must never be used — opencode can't resolve it offline.
|
|
1373
|
-
expect(built.plugin).not.toContain("context-mode");
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
test.each([
|
|
1377
|
-
"true",
|
|
1378
|
-
"1",
|
|
1379
|
-
"TRUE",
|
|
1380
|
-
])("plugin array excludes context-mode when CONTEXT_MODE_DISABLED=%s", async (value) => {
|
|
1381
|
-
process.env.CONTEXT_MODE_DISABLED = value;
|
|
1382
|
-
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
1383
|
-
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
1384
|
-
const events: OpencodeEvent[] = [
|
|
1385
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1386
|
-
];
|
|
1387
|
-
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
1388
|
-
|
|
1389
|
-
const built = getBuiltConfig();
|
|
1390
|
-
expect(built.plugin).not.toContain(fakePluginPath);
|
|
1391
|
-
expect(built.plugin).not.toContain("context-mode");
|
|
1392
|
-
});
|
|
1393
|
-
|
|
1394
|
-
test("context-mode does NOT appear in the mcp block", async () => {
|
|
1395
|
-
delete process.env.CONTEXT_MODE_DISABLED;
|
|
1396
|
-
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
1397
|
-
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
1398
|
-
const events: OpencodeEvent[] = [
|
|
1399
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
1400
|
-
];
|
|
1401
|
-
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
1402
|
-
|
|
1403
|
-
const built = getBuiltConfig();
|
|
1404
|
-
expect(built.mcp).toBeDefined();
|
|
1405
|
-
expect(built.mcp?.["context-mode"]).toBeUndefined();
|
|
1406
|
-
});
|
|
1407
|
-
});
|
|
1408
|
-
|
|
1409
|
-
describe("OpencodeAdapter: session create timeout", () => {
|
|
1410
|
-
let prevTimeout: string | undefined;
|
|
1411
|
-
|
|
1412
|
-
beforeEach(() => {
|
|
1413
|
-
prevTimeout = process.env.OPENCODE_SERVER_TIMEOUT_MS;
|
|
1414
|
-
mock.restore();
|
|
587
|
+
expect(host.prompts).toEqual([]);
|
|
588
|
+
expect(host.closeCalls).toBe(1);
|
|
589
|
+
expect(result).toMatchObject({ exitCode: 1, isError: true });
|
|
590
|
+
expect(result.failureReason).toBe(
|
|
591
|
+
"swarm MCP admission failed: swarm MCP server failed: Unable to connect. (ConnectionRefused)",
|
|
592
|
+
);
|
|
1415
593
|
});
|
|
1416
594
|
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
595
|
+
test("an unauthorized server fails with a scrubbed reason", async () => {
|
|
596
|
+
const leaked = `sk-or-v1-${"a1B2c3D4e5".repeat(5)}`;
|
|
597
|
+
const { result, host, emitted } = await runSession([], testConfig(), {
|
|
598
|
+
mcpStatuses: [{ status: "needs_auth", error: `401 for bearer ${leaked}` }],
|
|
599
|
+
});
|
|
600
|
+
expect(host.prompts).toEqual([]);
|
|
601
|
+
expect(host.closeCalls).toBe(1);
|
|
602
|
+
expect(result.failureReason).toContain(
|
|
603
|
+
"swarm MCP admission failed: swarm MCP server needs_auth",
|
|
604
|
+
);
|
|
605
|
+
expect(result.failureReason).not.toContain(leaked);
|
|
606
|
+
expect(JSON.stringify(emitted)).not.toContain(leaked);
|
|
1422
607
|
});
|
|
1423
608
|
|
|
1424
|
-
test("a
|
|
1425
|
-
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
event: { subscribe: async () => ({ stream: makeStream([]) }) },
|
|
1434
|
-
};
|
|
1435
|
-
mock.module("@opencode-ai/sdk", () => ({
|
|
1436
|
-
createOpencode: async () => ({ client: fakeClient, server: fakeServer }),
|
|
1437
|
-
}));
|
|
1438
|
-
|
|
1439
|
-
const { OpencodeAdapter } = await import("../providers/opencode-adapter");
|
|
1440
|
-
const adapter = new OpencodeAdapter();
|
|
1441
|
-
|
|
1442
|
-
await expect(adapter.createSession(testConfig({ taskId: "task-timeout" }))).rejects.toThrow(
|
|
1443
|
-
"opencode session create timed out after 50ms",
|
|
609
|
+
test("a connected server without store-progress fails once admission times out", async () => {
|
|
610
|
+
_setOpencodeSessionHooksForTests({ admissionTimeoutMs: 50 });
|
|
611
|
+
const { result, host } = await runSession([], testConfig(), {
|
|
612
|
+
tools: ["read", "swarm_poll-task"],
|
|
613
|
+
});
|
|
614
|
+
expect(host.prompts).toEqual([]);
|
|
615
|
+
expect(host.closeCalls).toBe(1);
|
|
616
|
+
expect(result.failureReason).toContain(
|
|
617
|
+
"swarm MCP server connected without required tools: store-progress",
|
|
1444
618
|
);
|
|
1445
|
-
expect(closeServer).toHaveBeenCalledTimes(1);
|
|
1446
619
|
});
|
|
1447
620
|
});
|
|
1448
621
|
|
|
1449
|
-
describe("
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
622
|
+
describe("OpencodeSession — abort and steering", () => {
|
|
623
|
+
test("abort interrupts the session, closes the host, and reports aborted", async () => {
|
|
624
|
+
const { session, host } = await startSession([]);
|
|
625
|
+
await session.abort();
|
|
626
|
+
const result = await session.waitForCompletion();
|
|
627
|
+
expect(host.interrupts).toEqual([{ sessionID: SESSION_ID }]);
|
|
628
|
+
expect(host.closeCalls).toBe(1);
|
|
629
|
+
expect(result).toMatchObject({ exitCode: 1, isError: true, failureReason: "aborted" });
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("steering queues a prompt, even when steer is requested", async () => {
|
|
633
|
+
const { session, host } = await startSession([]);
|
|
634
|
+
await Bun.sleep(0);
|
|
635
|
+
const delivered = await session.deliverSteering({ mode: "steer", text: "also do X" });
|
|
636
|
+
expect(delivered).toEqual({ delivered: true, mode: "queue" });
|
|
637
|
+
expect(host.prompts.at(-1)).toEqual({
|
|
638
|
+
sessionID: SESSION_ID,
|
|
639
|
+
text: "also do X",
|
|
640
|
+
delivery: "queue",
|
|
641
|
+
});
|
|
642
|
+
await session.abort();
|
|
1459
643
|
});
|
|
1460
644
|
|
|
1461
|
-
test("
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
prompt: async () => ({ data: {}, error: undefined }),
|
|
1468
|
-
},
|
|
1469
|
-
event: {
|
|
1470
|
-
subscribe: async (args: { sseMaxRetryAttempts?: number }) => {
|
|
1471
|
-
subscribeArgs = args;
|
|
1472
|
-
// The server died: the stream closes with no session.idle.
|
|
1473
|
-
return { stream: makeStream([]) };
|
|
1474
|
-
},
|
|
1475
|
-
},
|
|
1476
|
-
};
|
|
1477
|
-
mock.module("@opencode-ai/sdk", () => ({
|
|
1478
|
-
createOpencode: async () => ({
|
|
1479
|
-
client: fakeClient,
|
|
1480
|
-
server: { url: "http://127.0.0.1:12345", close: closeServer },
|
|
1481
|
-
}),
|
|
1482
|
-
}));
|
|
1483
|
-
|
|
1484
|
-
const { OpencodeAdapter, OPENCODE_SSE_MAX_RETRY_ATTEMPTS } = await import(
|
|
1485
|
-
"../providers/opencode-adapter"
|
|
1486
|
-
);
|
|
1487
|
-
const session = await new OpencodeAdapter().createSession(
|
|
1488
|
-
testConfig({ taskId: "task-stream-end" }),
|
|
1489
|
-
);
|
|
1490
|
-
session.onEvent(() => {});
|
|
1491
|
-
|
|
1492
|
-
const settled = await Promise.race([
|
|
1493
|
-
session.waitForCompletion(),
|
|
1494
|
-
Bun.sleep(2_000).then(() => "pending" as const),
|
|
1495
|
-
]);
|
|
1496
|
-
expect(settled).not.toBe("pending");
|
|
1497
|
-
const result = settled as ProviderResult;
|
|
1498
|
-
expect(result.exitCode).toBe(1);
|
|
1499
|
-
expect(result.isError).toBe(true);
|
|
1500
|
-
expect(result.failureReason).toContain("runner exited without result");
|
|
1501
|
-
expect(subscribeArgs?.sseMaxRetryAttempts).toBe(OPENCODE_SSE_MAX_RETRY_ATTEMPTS);
|
|
1502
|
-
expect(closeServer).toHaveBeenCalledTimes(1);
|
|
645
|
+
test("steering after completion is undeliverable", async () => {
|
|
646
|
+
const { session } = await runSession([succeeded]);
|
|
647
|
+
expect(await session.deliverSteering({ mode: "queue", text: "late" })).toEqual({
|
|
648
|
+
delivered: false,
|
|
649
|
+
reason: "opencode session already completed",
|
|
650
|
+
});
|
|
1503
651
|
});
|
|
1504
652
|
});
|
|
1505
653
|
|
|
1506
654
|
describe("OpenRouter attribution config", () => {
|
|
1507
|
-
test("overrides harness attribution while preserving custom headers and options",
|
|
1508
|
-
const { applyOpenRouterBaseUrlOverride } = await import("../providers/opencode-adapter");
|
|
655
|
+
test("overrides harness attribution while preserving custom headers and options", () => {
|
|
1509
656
|
const config = {
|
|
1510
657
|
provider: {
|
|
1511
658
|
openrouter: {
|
|
@@ -1532,8 +679,8 @@ describe("OpenRouter attribution config", () => {
|
|
|
1532
679
|
},
|
|
1533
680
|
});
|
|
1534
681
|
});
|
|
1535
|
-
|
|
1536
|
-
|
|
682
|
+
|
|
683
|
+
test("gateway override preserves options without injecting attribution", () => {
|
|
1537
684
|
const config = {
|
|
1538
685
|
provider: {
|
|
1539
686
|
openrouter: {
|
|
@@ -1556,8 +703,8 @@ describe("OpenRouter attribution config", () => {
|
|
|
1556
703
|
});
|
|
1557
704
|
expect(config.provider.anthropic).toEqual({ options: { apiKey: "test-other" } });
|
|
1558
705
|
});
|
|
1559
|
-
|
|
1560
|
-
|
|
706
|
+
|
|
707
|
+
test("task opt-out leaves default provider config untouched", () => {
|
|
1561
708
|
const config = {};
|
|
1562
709
|
applyOpenRouterBaseUrlOverride(config, { OPENROUTER_APP_ATTRIBUTION: "false" });
|
|
1563
710
|
expect(config).toEqual({});
|