@sjawhar/opencode-legion-envoy 0.1.9 → 0.2.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/AGENTS.md CHANGED
@@ -12,10 +12,11 @@ It is the user-facing bridge between OpenCode sessions and Envoy transport.
12
12
 
13
13
  | Task | Location | Notes |
14
14
  | ------------------- | ---------------------- | ------------------------------------------------------------------ |
15
- | Tool definitions | `src/index.ts` | `envoy_subscribe`, `envoy_unsubscribe`, `envoy_list`, `envoy_send`, `envoy_publish`, `envoy_role_set`, `envoy_whoami`, `envoy_sessions` |
15
+ | Tool definitions | `src/server.ts` | `envoy_subscribe`, `envoy_unsubscribe`, `envoy_list`, `envoy_send`, `envoy_publish`, `envoy_role_set`, `envoy_whoami`, `envoy_sessions` |
16
16
  | Packaging metadata | `package.json` | npm identity, build scripts |
17
- | Distribution output | `dist/index.js` | built plugin consumed by OpenCode |
17
+ | Distribution output | `dist/server.js` | built plugin consumed by OpenCode |
18
18
  | Host rollout helper | `scripts/sync-host.sh` | sync dist + shim to remote host |
19
+ | Dispatch MCP + auto-subscribe | `src/dispatch-mcp.ts`, `src/dispatch-subscribe.ts` | injects the dispatch MCP server (shim); `tool.execute.after` auto-subscribes the caller to the new thread's topic so answers route back (Dispatch AC#4) |
19
20
 
20
21
  ## Critical conventions
21
22
 
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bun
2
+ // envoy-plugin local MCP shim.
3
+ //
4
+ // Spawned by OpenCode as a `type: "local"` MCP transport. Reads JSON-RPC
5
+ // messages from stdin (newline-delimited), forwards each to the remote
6
+ // dispatch server's Streamable HTTP /mcp endpoint with a fresh GitHub
7
+ // bearer minted via the user's `gh` shim, and writes responses to stdout.
8
+ //
9
+ // Token rotation is invisible to OpenCode — the shim handles 50-minute
10
+ // refresh cycles + immediate retry on 401. This avoids the "MCP dies
11
+ // after 1 hour" failure mode of static-header configurations.
12
+
13
+ import * as readline from "node:readline";
14
+ import {
15
+ createBridge,
16
+ defaultGhTokenGetter,
17
+ type JsonRpcRequest,
18
+ } from "../src/dispatch-mcp-bridge";
19
+
20
+ const remoteUrl = process.env.DISPATCH_MCP_URL;
21
+ if (!remoteUrl) {
22
+ process.stderr.write("envoy-dispatch shim: DISPATCH_MCP_URL is required\n");
23
+ process.exit(1);
24
+ }
25
+
26
+ const bridge = createBridge({
27
+ remoteUrl,
28
+ getToken: defaultGhTokenGetter,
29
+ });
30
+
31
+ const rl = readline.createInterface({ input: process.stdin });
32
+
33
+ let inflight = 0;
34
+ let closed = false;
35
+
36
+ // Serialize incoming requests. MCP requires the initialize handshake to
37
+ // complete before any tool calls are processed; running rl.on("line")
38
+ // callbacks in parallel would race tools/call against initialize and hit
39
+ // `invalid during session initialization` from the server. Even after
40
+ // init, sequencing keeps the wire ordering deterministic, which is what
41
+ // OpenCode expects for stdio MCP transports.
42
+ let chain: Promise<void> = Promise.resolve();
43
+
44
+ function maybeExit(): void {
45
+ if (closed && inflight === 0) process.exit(0);
46
+ }
47
+
48
+ rl.on("line", (line) => {
49
+ const trimmed = line.trim();
50
+ if (!trimmed) return;
51
+ inflight++;
52
+ chain = chain.then(async () => {
53
+ try {
54
+ const request = JSON.parse(trimmed) as JsonRpcRequest;
55
+ const response = await bridge.handle(request);
56
+ if (response !== null) {
57
+ process.stdout.write(`${JSON.stringify(response)}\n`);
58
+ }
59
+ } catch (err) {
60
+ const msg = err instanceof Error ? err.message : String(err);
61
+ process.stderr.write(`envoy-dispatch shim: ${msg}\n`);
62
+ } finally {
63
+ inflight--;
64
+ maybeExit();
65
+ }
66
+ });
67
+ });
68
+
69
+ rl.on("close", () => {
70
+ closed = true;
71
+ maybeExit();
72
+ });
73
+
74
+ process.on("SIGTERM", () => process.exit(0));
75
+ process.on("SIGINT", () => process.exit(0));
package/package.json CHANGED
@@ -1,25 +1,38 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "main": "dist/index.js",
5
+ "main": "src/server.ts",
6
+ "exports": {
7
+ "./server": {
8
+ "import": "./src/server.ts"
9
+ },
10
+ "./tui": {
11
+ "import": "./dist/tui.js",
12
+ "types": "./dist/tui.d.ts"
13
+ }
14
+ },
6
15
  "repository": {
7
16
  "type": "git",
8
17
  "url": "https://github.com/sjawhar/legion"
9
18
  },
10
- "types": "dist/index.d.ts",
19
+ "types": "src/server.ts",
11
20
  "scripts": {
12
- "build": "bun build src/index.ts --outdir dist --target bun --format esm",
21
+ "build": "bun -e 'import { createSolidTransformPlugin } from \"@opentui/solid/bun-plugin\"; const result = await Bun.build({ entrypoints: [\"src/tui.tsx\"], outdir: \"dist\", target: \"bun\", format: \"esm\", external: [\"@opencode-ai/*\", \"solid-js\", \"solid-js/*\", \"@opentui/*\"], plugins: [createSolidTransformPlugin()] }); if (!result.success) { console.error(result.logs); process.exit(1); }'",
13
22
  "typecheck": "bunx tsc --noEmit",
14
23
  "test": "bun test",
15
24
  "lint": "bunx biome check src/"
16
25
  },
17
26
  "dependencies": {
18
- "@opencode-ai/plugin": "^1.1.19"
27
+ "@opencode-ai/plugin": "~1.14.46"
19
28
  },
20
29
  "devDependencies": {
21
30
  "@biomejs/biome": "^2.3.14",
31
+ "@opentui/core": "^0.2.6",
32
+ "@opentui/keymap": "^0.2.6",
33
+ "@opentui/solid": "^0.2.6",
22
34
  "@types/bun": "latest",
35
+ "solid-js": "^1.9.0",
23
36
  "typescript": "^5.3.0"
24
37
  }
25
38
  }
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  host="${1:?usage: sync-host.sh user@host}"
5
5
 
6
6
  PLUGIN_DIR="legion/default/packages/envoy-plugin"
7
- PLUGIN_REF="file://{env:HOME}/${PLUGIN_DIR}/dist/index.js"
7
+ PLUGIN_REF="file://{env:HOME}/${PLUGIN_DIR}"
8
8
  REPO="sjawhar/legion"
9
9
 
10
10
  # Find latest envoy release tag
@@ -53,4 +53,21 @@ ssh "$host" "
53
53
  fi
54
54
  "
55
55
 
56
+ # Update tui.json: ensure file:// ref is present in the TUI plugin list.
57
+ # tui.json is a separate config file from opencode.json; opencode loads TUI
58
+ # plugins (slash commands, sidebar slots) only from here.
59
+ ssh "$host" "
60
+ TUI_CONFIG=\$(readlink -f \$HOME/.config/opencode/tui.json 2>/dev/null || echo \$HOME/.config/opencode/tui.json)
61
+ if [ -f \"\$TUI_CONFIG\" ]; then
62
+ jq --arg ref '$PLUGIN_REF' \\
63
+ '(.plugin // []) |= ((map(if (test(\"opencode-legion-envoy\") and (test(\"^file://\") | not)) then \$ref else . end)) | if any(. == \$ref) then . else . + [\$ref] end)' \\
64
+ \"\$TUI_CONFIG\" > /tmp/tui.json.tmp && mv /tmp/tui.json.tmp \"\$TUI_CONFIG\"
65
+ echo \"Updated tui.json: \$TUI_CONFIG\"
66
+ else
67
+ mkdir -p \$(dirname \"\$TUI_CONFIG\")
68
+ jq -n --arg ref '$PLUGIN_REF' '{\"\\\$schema\":\"https://opencode.ai/tui.json\",\"plugin\":[\$ref]}' > \"\$TUI_CONFIG\"
69
+ echo \"Created tui.json: \$TUI_CONFIG\"
70
+ fi
71
+ "
72
+
56
73
  echo "Done: $host envoy-plugin synced from release $tag"
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { copyNative, copyToClipboard } from "../clipboard";
3
+
4
+ describe("copyToClipboard", () => {
5
+ it("copies via the renderer OSC 52 writer and does not fall back when it succeeds", () => {
6
+ const copyToClipboardOSC52 = mock((_text: string) => true);
7
+ const ok = copyToClipboard("ses_abc", { copyToClipboardOSC52 });
8
+ expect(ok).toBe(true);
9
+ expect(copyToClipboardOSC52).toHaveBeenCalledWith("ses_abc");
10
+ });
11
+ });
12
+
13
+ describe("copyNative", () => {
14
+ it("uses wl-copy on Linux/Wayland when available", () => {
15
+ const run = mock((_cmd: string, _args: string[], _text: string) => true);
16
+ const ok = copyNative("ses_abc", {
17
+ os: "linux",
18
+ env: { WAYLAND_DISPLAY: "wayland-0" },
19
+ which: (cmd) => cmd === "wl-copy",
20
+ run,
21
+ });
22
+ expect(ok).toBe(true);
23
+ expect(run).toHaveBeenCalledWith("wl-copy", [], "ses_abc");
24
+ });
25
+
26
+ it("falls back to xclip on Linux/X11", () => {
27
+ const run = mock((_cmd: string, _args: string[], _text: string) => true);
28
+ const ok = copyNative("ses_abc", {
29
+ os: "linux",
30
+ env: {},
31
+ which: (cmd) => cmd === "xclip",
32
+ run,
33
+ });
34
+ expect(ok).toBe(true);
35
+ expect(run).toHaveBeenCalledWith("xclip", ["-selection", "clipboard"], "ses_abc");
36
+ });
37
+
38
+ it("falls back to xsel when xclip is missing", () => {
39
+ const run = mock((_cmd: string, _args: string[], _text: string) => true);
40
+ copyNative("x", { os: "linux", env: {}, which: (cmd) => cmd === "xsel", run });
41
+ expect(run).toHaveBeenCalledWith("xsel", ["--clipboard", "--input"], "x");
42
+ });
43
+
44
+ it("uses osascript on macOS with escaped quotes", () => {
45
+ const run = mock((_cmd: string, _args: string[], _text: string) => true);
46
+ copyNative('a"b\\c', { os: "darwin", which: () => true, run });
47
+ expect(run).toHaveBeenCalledWith("osascript", ["-e", 'set the clipboard to "a\\"b\\\\c"'], "");
48
+ });
49
+
50
+ it("returns false on Linux when no clipboard tool is installed", () => {
51
+ const run = mock(() => true);
52
+ const ok = copyNative("x", { os: "linux", env: {}, which: () => false, run });
53
+ expect(ok).toBe(false);
54
+ expect(run).not.toHaveBeenCalled();
55
+ });
56
+ });
@@ -0,0 +1,339 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { createBridge, type JsonRpcRequest } from "../dispatch-mcp-bridge";
3
+
4
+ interface MockResponse {
5
+ status: number;
6
+ statusText?: string;
7
+ headers?: Record<string, string>;
8
+ contentType?: string;
9
+ body: string;
10
+ }
11
+
12
+ function fakeFetch(responses: MockResponse[]) {
13
+ let idx = 0;
14
+ const calls: Array<{ url: string; init: RequestInit }> = [];
15
+ const impl = (url: string, init?: RequestInit) => {
16
+ calls.push({ url, init: init ?? {} });
17
+ const next = responses[idx++];
18
+ if (!next) throw new Error(`no mock response for call #${idx}`);
19
+ const headers = new Headers({
20
+ "content-type": next.contentType ?? "application/json",
21
+ ...(next.headers ?? {}),
22
+ });
23
+ return Promise.resolve(
24
+ new Response(next.body, {
25
+ status: next.status,
26
+ statusText: next.statusText ?? "",
27
+ headers,
28
+ })
29
+ );
30
+ };
31
+ return { impl: impl as unknown as typeof fetch, calls };
32
+ }
33
+
34
+ function sseEnvelope(payload: object): string {
35
+ return `event: message\ndata: ${JSON.stringify(payload)}\n\n`;
36
+ }
37
+
38
+ describe("dispatch-mcp-bridge", () => {
39
+ it("forwards a request with a fresh bearer and returns the parsed SSE response", async () => {
40
+ const f = fakeFetch([
41
+ {
42
+ status: 200,
43
+ contentType: "text/event-stream",
44
+ headers: { "mcp-session-id": "S1" },
45
+ body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: { ok: true } }),
46
+ },
47
+ ]);
48
+ const bridge = createBridge({
49
+ remoteUrl: "http://example/mcp",
50
+ getToken: async () => "tok-A",
51
+ fetchImpl: f.impl,
52
+ });
53
+
54
+ const req: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "tools/list" };
55
+ const res = await bridge.handle(req);
56
+
57
+ expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: { ok: true } });
58
+ expect(f.calls).toHaveLength(1);
59
+ expect((f.calls[0]?.init.headers as Record<string, string>).Authorization).toBe("Bearer tok-A");
60
+ });
61
+
62
+ it("reuses the cached token on a follow-up request and reuses the session id", async () => {
63
+ let tokenCalls = 0;
64
+ const f = fakeFetch([
65
+ {
66
+ status: 200,
67
+ contentType: "text/event-stream",
68
+ headers: { "mcp-session-id": "S2" },
69
+ body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: { phase: "init" } }),
70
+ },
71
+ {
72
+ status: 200,
73
+ contentType: "text/event-stream",
74
+ body: sseEnvelope({ jsonrpc: "2.0", id: 2, result: { phase: "list" } }),
75
+ },
76
+ ]);
77
+ const bridge = createBridge({
78
+ remoteUrl: "http://example/mcp",
79
+ getToken: async () => {
80
+ tokenCalls++;
81
+ return "tok-cached";
82
+ },
83
+ fetchImpl: f.impl,
84
+ });
85
+
86
+ await bridge.handle({ jsonrpc: "2.0", id: 1, method: "initialize" });
87
+ await bridge.handle({ jsonrpc: "2.0", id: 2, method: "tools/list" });
88
+
89
+ expect(tokenCalls).toBe(1);
90
+ expect((f.calls[1]?.init.headers as Record<string, string>)["Mcp-Session-Id"]).toBe("S2");
91
+ });
92
+
93
+ it("refreshes the token after the cache TTL elapses", async () => {
94
+ const responses: MockResponse[] = [
95
+ {
96
+ status: 200,
97
+ contentType: "text/event-stream",
98
+ body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: 1 }),
99
+ },
100
+ {
101
+ status: 200,
102
+ contentType: "text/event-stream",
103
+ body: sseEnvelope({ jsonrpc: "2.0", id: 2, result: 2 }),
104
+ },
105
+ ];
106
+ const f = fakeFetch(responses);
107
+ let issued = 0;
108
+ let clock = 1000;
109
+
110
+ const bridge = createBridge({
111
+ remoteUrl: "http://example/mcp",
112
+ getToken: async () => `tok-${++issued}`,
113
+ fetchImpl: f.impl,
114
+ tokenCacheTtlMs: 60_000,
115
+ now: () => clock,
116
+ });
117
+
118
+ await bridge.handle({ jsonrpc: "2.0", id: 1, method: "x" });
119
+ clock += 120_000; // beyond TTL
120
+ await bridge.handle({ jsonrpc: "2.0", id: 2, method: "x" });
121
+
122
+ expect(issued).toBe(2);
123
+ expect((f.calls[0]?.init.headers as Record<string, string>).Authorization).toBe("Bearer tok-1");
124
+ expect((f.calls[1]?.init.headers as Record<string, string>).Authorization).toBe("Bearer tok-2");
125
+ });
126
+
127
+ it("retries once on 401 with a forced token refresh", async () => {
128
+ const f = fakeFetch([
129
+ { status: 401, body: "unauthorized" },
130
+ {
131
+ status: 200,
132
+ contentType: "text/event-stream",
133
+ body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: "after-refresh" }),
134
+ },
135
+ ]);
136
+ let issued = 0;
137
+ const bridge = createBridge({
138
+ remoteUrl: "http://example/mcp",
139
+ getToken: async () => `tok-${++issued}`,
140
+ fetchImpl: f.impl,
141
+ logError: () => {},
142
+ });
143
+
144
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" });
145
+ expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: "after-refresh" });
146
+ expect(issued).toBe(2); // first attempt cached tok-1, retry forced tok-2
147
+ expect(f.calls).toHaveLength(2);
148
+ });
149
+
150
+ it("retries when remote returns HTTP 200 but tool result reports upstream 401", async () => {
151
+ const f = fakeFetch([
152
+ {
153
+ status: 200,
154
+ contentType: "application/json",
155
+ body: JSON.stringify({
156
+ jsonrpc: "2.0",
157
+ id: 1,
158
+ result: {
159
+ isError: true,
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: "search issues: GET https://api.github.com/search/issues?q=x: 401 Bad credentials []",
164
+ },
165
+ ],
166
+ },
167
+ }),
168
+ },
169
+ {
170
+ status: 200,
171
+ contentType: "text/event-stream",
172
+ body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: "after-refresh" }),
173
+ },
174
+ ]);
175
+ let issued = 0;
176
+ const bridge = createBridge({
177
+ remoteUrl: "http://example/mcp",
178
+ getToken: async () => `tok-${++issued}`,
179
+ fetchImpl: f.impl,
180
+ logError: () => {},
181
+ });
182
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/call" });
183
+ expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: "after-refresh" });
184
+ expect(issued).toBe(2);
185
+ expect(f.calls).toHaveLength(2);
186
+ });
187
+
188
+ it("retries when remote returns a JSON-RPC error whose message reports upstream 401", async () => {
189
+ const f = fakeFetch([
190
+ {
191
+ status: 200,
192
+ contentType: "application/json",
193
+ body: JSON.stringify({
194
+ jsonrpc: "2.0",
195
+ id: 1,
196
+ error: { code: -32603, message: "search issues: 401 Bad credentials" },
197
+ }),
198
+ },
199
+ {
200
+ status: 200,
201
+ contentType: "text/event-stream",
202
+ body: sseEnvelope({ jsonrpc: "2.0", id: 1, result: "after-refresh" }),
203
+ },
204
+ ]);
205
+ let issued = 0;
206
+ const bridge = createBridge({
207
+ remoteUrl: "http://example/mcp",
208
+ getToken: async () => `tok-${++issued}`,
209
+ fetchImpl: f.impl,
210
+ logError: () => {},
211
+ });
212
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/call" });
213
+ expect(res).toEqual({ jsonrpc: "2.0", id: 1, result: "after-refresh" });
214
+ expect(issued).toBe(2);
215
+ });
216
+
217
+ it("returns a JSON-RPC error when the token getter yields null", async () => {
218
+ const f = fakeFetch([]);
219
+ const bridge = createBridge({
220
+ remoteUrl: "http://example/mcp",
221
+ getToken: async () => null,
222
+ fetchImpl: f.impl,
223
+ });
224
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 7, method: "tools/call" });
225
+ expect(res?.error?.code).toBe(-32000);
226
+ expect(res?.error?.message).toContain("gh auth token");
227
+ expect(f.calls).toHaveLength(0);
228
+ });
229
+
230
+ it("returns null for notifications (no id) and still forwards them", async () => {
231
+ const f = fakeFetch([{ status: 200, contentType: "application/json", body: "{}" }]);
232
+ const bridge = createBridge({
233
+ remoteUrl: "http://example/mcp",
234
+ getToken: async () => "tok",
235
+ fetchImpl: f.impl,
236
+ });
237
+ const res = await bridge.handle({ jsonrpc: "2.0", method: "notifications/initialized" });
238
+ expect(res).toBeNull();
239
+ expect(f.calls).toHaveLength(1);
240
+ });
241
+
242
+ it("returns a JSON-RPC error on remote non-200 status", async () => {
243
+ const f = fakeFetch([{ status: 503, statusText: "Service Unavailable", body: "down" }]);
244
+ const bridge = createBridge({
245
+ remoteUrl: "http://example/mcp",
246
+ getToken: async () => "tok",
247
+ fetchImpl: f.impl,
248
+ });
249
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "x" });
250
+ expect(res?.error?.code).toBe(-32603);
251
+ expect(res?.error?.message).toContain("503");
252
+ });
253
+
254
+ it("returns a JSON-RPC error when fetch throws", async () => {
255
+ const erroringFetch = mock(async () => {
256
+ throw new Error("ECONNREFUSED");
257
+ });
258
+ const bridge = createBridge({
259
+ remoteUrl: "http://example/mcp",
260
+ getToken: async () => "tok",
261
+ fetchImpl: erroringFetch as unknown as typeof fetch,
262
+ });
263
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "x" });
264
+ expect(res?.error?.code).toBe(-32603);
265
+ expect(res?.error?.message).toContain("ECONNREFUSED");
266
+ });
267
+
268
+ it("normalizes union-type-null arrays in tools/list inputSchema so Gemini accepts them", async () => {
269
+ // Mirrors the real EnvoyDispatch schema the remote server emits: nullable arrays
270
+ // expressed as JSON-Schema union types { type: ["null", "array"] }, which Gemini rejects
271
+ // (array branch lacks items / orphaned items). The bridge must collapse these in transit.
272
+ const toolsList = {
273
+ jsonrpc: "2.0",
274
+ id: 1,
275
+ result: {
276
+ tools: [
277
+ {
278
+ name: "envoy_dispatch",
279
+ description: "Create a Dispatch thread",
280
+ inputSchema: {
281
+ type: "object",
282
+ required: ["parent", "subject", "body"],
283
+ properties: {
284
+ parent: { type: "string" },
285
+ ask: {
286
+ type: ["null", "array"],
287
+ items: {
288
+ type: "object",
289
+ required: ["question", "options"],
290
+ properties: {
291
+ question: { type: "string" },
292
+ custom: { type: ["null", "boolean"] },
293
+ options: {
294
+ type: ["null", "array"],
295
+ items: {
296
+ type: "object",
297
+ required: ["label"],
298
+ properties: { label: { type: "string" } },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ },
304
+ },
305
+ },
306
+ },
307
+ ],
308
+ },
309
+ };
310
+ const f = fakeFetch([
311
+ { status: 200, contentType: "application/json", body: JSON.stringify(toolsList) },
312
+ ]);
313
+ const bridge = createBridge({
314
+ remoteUrl: "http://example/mcp",
315
+ getToken: async () => "tok",
316
+ fetchImpl: f.impl,
317
+ });
318
+
319
+ const res = await bridge.handle({ jsonrpc: "2.0", id: 1, method: "tools/list" });
320
+ type SchemaNode = {
321
+ type?: unknown;
322
+ items?: SchemaNode;
323
+ properties?: Record<string, SchemaNode>;
324
+ };
325
+ const result = res?.result as { tools: Array<{ inputSchema: SchemaNode }> };
326
+ const ask = result.tools[0]?.inputSchema.properties?.ask;
327
+
328
+ // Nullable array collapses to a single-type array with items preserved.
329
+ expect(ask?.type).toBe("array");
330
+ expect(ask?.items).toBeDefined();
331
+ // Nested nullable array (options) collapses too.
332
+ expect(ask?.items?.properties?.options.type).toBe("array");
333
+ expect(ask?.items?.properties?.options.items).toBeDefined();
334
+ // Nullable boolean collapses to a single-type boolean.
335
+ expect(ask?.items?.properties?.custom.type).toBe("boolean");
336
+ // Nothing in the schema still uses a union type array (the shape Gemini rejects).
337
+ expect(JSON.stringify(res)).not.toContain('["null"');
338
+ });
339
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildDispatchMcpEntry, injectEnvoyMcp } from "../dispatch-mcp";
3
+
4
+ describe("buildDispatchMcpEntry", () => {
5
+ it("returns null when dispatch is undefined", () => {
6
+ const result = buildDispatchMcpEntry({ dispatch: undefined });
7
+ expect(result).toBeNull();
8
+ });
9
+
10
+ it("returns null when dispatch.enabled is false", () => {
11
+ const result = buildDispatchMcpEntry({
12
+ dispatch: { enabled: false, serverUrl: "http://example:8766" },
13
+ });
14
+ expect(result).toBeNull();
15
+ });
16
+
17
+ it("builds a local MCP entry pointing at the shim with serverUrl in env", () => {
18
+ const result = buildDispatchMcpEntry({
19
+ dispatch: {
20
+ enabled: true,
21
+ serverUrl: "http://sami-agents-mx:8766",
22
+ },
23
+ shimPath: "/path/to/shim.ts",
24
+ runtime: "bun",
25
+ });
26
+ expect(result).toEqual({
27
+ type: "local",
28
+ command: ["bun", "/path/to/shim.ts"],
29
+ environment: {
30
+ DISPATCH_MCP_URL: "http://sami-agents-mx:8766/mcp",
31
+ },
32
+ enabled: true,
33
+ });
34
+ });
35
+
36
+ it("falls back to localhost:8766 when serverUrl is omitted", () => {
37
+ const result = buildDispatchMcpEntry({
38
+ dispatch: { enabled: true },
39
+ shimPath: "/shim.ts",
40
+ });
41
+ expect(result?.environment.DISPATCH_MCP_URL).toBe("http://localhost:8766/mcp");
42
+ });
43
+
44
+ it("strips trailing slashes from serverUrl before appending /mcp", () => {
45
+ const result = buildDispatchMcpEntry({
46
+ dispatch: { enabled: true, serverUrl: "http://example:8766//" },
47
+ shimPath: "/shim.ts",
48
+ });
49
+ expect(result?.environment.DISPATCH_MCP_URL).toBe("http://example:8766/mcp");
50
+ });
51
+
52
+ it("uses bun as the default runtime", () => {
53
+ const result = buildDispatchMcpEntry({
54
+ dispatch: { enabled: true },
55
+ shimPath: "/shim.ts",
56
+ });
57
+ expect(result?.command[0]).toBe("bun");
58
+ });
59
+
60
+ it("uses the provided runtime override", () => {
61
+ const result = buildDispatchMcpEntry({
62
+ dispatch: { enabled: true },
63
+ shimPath: "/shim.ts",
64
+ runtime: "node",
65
+ });
66
+ expect(result?.command[0]).toBe("node");
67
+ });
68
+
69
+ it("default shim path resolves to bin/dispatch-mcp-shim.ts in the package root", () => {
70
+ const result = buildDispatchMcpEntry({
71
+ dispatch: { enabled: true },
72
+ });
73
+ expect(result?.command[1]).toContain("bin/dispatch-mcp-shim.ts");
74
+ });
75
+ });
76
+
77
+ describe("injectEnvoyMcp", () => {
78
+ const entry = {
79
+ type: "local" as const,
80
+ command: ["bun", "/shim.ts"],
81
+ environment: { DISPATCH_MCP_URL: "http://test:8766/mcp" },
82
+ enabled: true as const,
83
+ };
84
+
85
+ it("adds the entry to a cfg that has no mcp block yet", () => {
86
+ const cfg: { mcp?: Record<string, unknown> } = {};
87
+ const result = injectEnvoyMcp(cfg, entry);
88
+ expect(result.warning).toBeUndefined();
89
+ expect(cfg.mcp?.envoy).toEqual(entry);
90
+ });
91
+
92
+ it("is idempotent on its own re-write — second call does not warn", () => {
93
+ const cfg: { mcp?: Record<string, unknown> } = {};
94
+ injectEnvoyMcp(cfg, entry);
95
+ const second = injectEnvoyMcp(cfg, entry);
96
+ // No warning when the existing entry already equals what we'd inject.
97
+ // This is the common case after InstanceState invalidation re-runs the
98
+ // config hook against a Config-service cfg that still has our prior
99
+ // mutation — silent no-op, not a TUI stderr alarm.
100
+ expect(second.warning).toBeUndefined();
101
+ expect(cfg.mcp?.envoy).toEqual(entry);
102
+ });
103
+
104
+ it("warns and preserves the existing entry when it differs from ours", () => {
105
+ const userOverride = {
106
+ type: "local" as const,
107
+ command: ["node", "/custom-shim.js"],
108
+ environment: { DISPATCH_MCP_URL: "http://other:9999/mcp" },
109
+ enabled: true as const,
110
+ };
111
+ const cfg: { mcp?: Record<string, unknown> } = { mcp: { envoy: userOverride } };
112
+ const result = injectEnvoyMcp(cfg, entry);
113
+ expect(result.warning).toContain("already present");
114
+ expect(cfg.mcp?.envoy).toEqual(userOverride);
115
+ });
116
+ });