@sjawhar/opencode-legion-envoy 0.5.2 → 0.6.1
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/bin/dispatch-mcp-shim.js +13905 -0
- package/dist/src/server.js +14512 -0
- package/package.json +15 -6
- package/src/config/index.ts +2 -1
- package/src/dispatch-mcp.ts +15 -4
- package/src/dispatch-subscribe.ts +1 -1
- package/src/port.ts +3 -10
- package/src/server.ts +12 -9
- package/src/ss.ts +19 -0
- package/src/tui-port.ts +5 -13
- package/AGENTS.md +0 -34
- package/bin/dispatch-mcp-shim.ts +0 -75
- package/scripts/sync-host.sh +0 -73
- package/src/__tests__/clipboard.test.ts +0 -56
- package/src/__tests__/dispatch-mcp-bridge.test.ts +0 -339
- package/src/__tests__/dispatch-mcp.test.ts +0 -116
- package/src/__tests__/dispatch-subscribe.test.ts +0 -66
- package/src/__tests__/index.test.ts +0 -634
- package/src/__tests__/log.test.ts +0 -135
- package/src/__tests__/port.test.ts +0 -124
- package/src/__tests__/tui-port.test.ts +0 -85
- package/src/config/__tests__/index.test.ts +0 -93
- package/src/dispatch-mcp-bridge.ts +0 -310
- package/tsconfig.json +0 -20
|
@@ -1,339 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
dispatchSubscriptionTopic,
|
|
4
|
-
dispatchThreadTopic,
|
|
5
|
-
isDispatchTool,
|
|
6
|
-
} from "../dispatch-subscribe";
|
|
7
|
-
|
|
8
|
-
describe("isDispatchTool", () => {
|
|
9
|
-
it("matches the MCP-exposed name and common separators", () => {
|
|
10
|
-
expect(isDispatchTool("envoy_dispatch")).toBe(true);
|
|
11
|
-
expect(isDispatchTool("dispatch")).toBe(true);
|
|
12
|
-
expect(isDispatchTool("envoy.dispatch")).toBe(true);
|
|
13
|
-
expect(isDispatchTool("mcp__envoy__dispatch")).toBe(true);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("does not match unrelated tools", () => {
|
|
17
|
-
expect(isDispatchTool("envoy_subscribe")).toBe(false);
|
|
18
|
-
expect(isDispatchTool("bash")).toBe(false);
|
|
19
|
-
expect(isDispatchTool("dispatcher")).toBe(false);
|
|
20
|
-
expect(isDispatchTool("dispatch_thread")).toBe(false);
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe("dispatchThreadTopic", () => {
|
|
25
|
-
it("builds the wildcard thread topic", () => {
|
|
26
|
-
expect(dispatchThreadTopic("sjawhar", "legion", 123)).toBe(
|
|
27
|
-
"notifications.github.sjawhar.legion.issue.123.>"
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("dispatchSubscriptionTopic", () => {
|
|
33
|
-
it("derives the topic from a dispatch tool result JSON", () => {
|
|
34
|
-
const output = JSON.stringify({
|
|
35
|
-
thread: 742,
|
|
36
|
-
url: "https://github.com/sjawhar/legion/issues/742",
|
|
37
|
-
});
|
|
38
|
-
expect(dispatchSubscriptionTopic("envoy_dispatch", output)).toBe(
|
|
39
|
-
"notifications.github.sjawhar.legion.issue.742.>"
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("derives owner/repo/number purely from the issue URL", () => {
|
|
44
|
-
// Even if a stale/incorrect JSON `thread` were present, the URL is canonical.
|
|
45
|
-
const output = '{"thread":1,"url":"https://github.com/acme/Widgets/issues/55"}';
|
|
46
|
-
expect(dispatchSubscriptionTopic("envoy_dispatch", output)).toBe(
|
|
47
|
-
"notifications.github.acme.Widgets.issue.55.>"
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("returns null for non-dispatch tools even with a github URL present", () => {
|
|
52
|
-
const output = '{"url":"https://github.com/sjawhar/legion/issues/9"}';
|
|
53
|
-
expect(dispatchSubscriptionTopic("envoy_subscribe", output)).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("returns null when the output has no github issue URL", () => {
|
|
57
|
-
expect(dispatchSubscriptionTopic("envoy_dispatch", "created thread 5")).toBeNull();
|
|
58
|
-
expect(dispatchSubscriptionTopic("envoy_dispatch", "")).toBeNull();
|
|
59
|
-
expect(dispatchSubscriptionTopic("envoy_dispatch", "not json at all")).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("ignores pull-request URLs (only issue threads carry dispatch replies)", () => {
|
|
63
|
-
const output = '{"url":"https://github.com/sjawhar/legion/pull/100"}';
|
|
64
|
-
expect(dispatchSubscriptionTopic("envoy_dispatch", output)).toBeNull();
|
|
65
|
-
});
|
|
66
|
-
});
|