pi-soly 1.9.3 → 1.11.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.
Files changed (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
@@ -0,0 +1,967 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import http from "node:http";
3
+ import { startUiServer, type UiServerOptions, type UiServerHandle } from "../ui-server.ts";
4
+ import type { McpServerManager } from "../server-manager.ts";
5
+ import type { ConsentManager } from "../consent-manager.ts";
6
+ import type { UiResourceContent } from "../types.ts";
7
+
8
+ // Helper to make HTTP requests to the server
9
+ async function request(
10
+ url: string,
11
+ options: {
12
+ method?: string;
13
+ body?: unknown;
14
+ headers?: Record<string, string>;
15
+ } = {}
16
+ ): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
17
+ return new Promise((resolve, reject) => {
18
+ const parsed = new URL(url);
19
+ const req = http.request(
20
+ {
21
+ hostname: parsed.hostname,
22
+ port: parsed.port,
23
+ path: parsed.pathname + parsed.search,
24
+ method: options.method ?? "GET",
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ ...options.headers,
28
+ },
29
+ },
30
+ (res) => {
31
+ const chunks: Buffer[] = [];
32
+ res.on("data", (chunk) => chunks.push(chunk));
33
+ res.on("end", () => {
34
+ const text = Buffer.concat(chunks).toString("utf-8");
35
+ let body: unknown;
36
+ try {
37
+ body = JSON.parse(text);
38
+ } catch {
39
+ body = text;
40
+ }
41
+ resolve({ status: res.statusCode ?? 0, body, headers: res.headers });
42
+ });
43
+ }
44
+ );
45
+ req.on("error", reject);
46
+ if (options.body) {
47
+ req.write(JSON.stringify(options.body));
48
+ }
49
+ req.end();
50
+ });
51
+ }
52
+
53
+ // Helper to connect to SSE and collect events
54
+ function connectSSE(
55
+ url: string,
56
+ onEvent: (name: string, data: unknown, eventId?: string) => void,
57
+ headers: Record<string, string> = {},
58
+ ): Promise<{ close: () => void }> {
59
+ return new Promise((resolve, reject) => {
60
+ const parsed = new URL(url);
61
+ const req = http.request(
62
+ {
63
+ hostname: parsed.hostname,
64
+ port: parsed.port,
65
+ path: parsed.pathname + parsed.search,
66
+ method: "GET",
67
+ headers: { Accept: "text/event-stream", ...headers },
68
+ },
69
+ (res) => {
70
+ if (res.statusCode !== 200) {
71
+ reject(new Error(`SSE connection failed: ${res.statusCode}`));
72
+ return;
73
+ }
74
+ let buffer = "";
75
+ let eventName = "message";
76
+ let eventId: string | undefined;
77
+ res.on("data", (chunk) => {
78
+ buffer += chunk.toString();
79
+ const lines = buffer.split("\n");
80
+ buffer = lines.pop() ?? "";
81
+
82
+ for (const line of lines) {
83
+ if (line.startsWith("id: ")) {
84
+ eventId = line.slice(4);
85
+ } else if (line.startsWith("event: ")) {
86
+ eventName = line.slice(7);
87
+ } else if (line.startsWith("data: ")) {
88
+ try {
89
+ const data = JSON.parse(line.slice(6));
90
+ onEvent(eventName, data, eventId);
91
+ } catch {
92
+ onEvent(eventName, line.slice(6), eventId);
93
+ }
94
+ eventName = "message";
95
+ eventId = undefined;
96
+ }
97
+ }
98
+ });
99
+ resolve({
100
+ close: () => {
101
+ req.destroy();
102
+ },
103
+ });
104
+ }
105
+ );
106
+ req.on("error", reject);
107
+ req.end();
108
+ });
109
+ }
110
+
111
+ // Mock factories
112
+ function createMockManager(overrides: Partial<McpServerManager> = {}): McpServerManager {
113
+ return {
114
+ getConnection: vi.fn().mockReturnValue({
115
+ status: "connected",
116
+ client: {
117
+ callTool: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "result" }] }),
118
+ },
119
+ }),
120
+ touch: vi.fn(),
121
+ incrementInFlight: vi.fn(),
122
+ decrementInFlight: vi.fn(),
123
+ readResource: vi.fn(),
124
+ ...overrides,
125
+ } as unknown as McpServerManager;
126
+ }
127
+
128
+ function createMockConsentManager(overrides: Partial<ConsentManager> = {}): ConsentManager {
129
+ return {
130
+ requiresPrompt: vi.fn().mockReturnValue(false),
131
+ shouldCacheConsent: vi.fn().mockReturnValue(true),
132
+ ensureApproved: vi.fn(),
133
+ registerDecision: vi.fn(),
134
+ ...overrides,
135
+ } as unknown as ConsentManager;
136
+ }
137
+
138
+ function createMockResource(overrides: Partial<UiResourceContent> = {}): UiResourceContent {
139
+ return {
140
+ uri: "ui://test/widget",
141
+ html: "<h1>Test App</h1>",
142
+ mimeType: "text/html",
143
+ meta: {
144
+ permissions: [],
145
+ },
146
+ ...overrides,
147
+ };
148
+ }
149
+
150
+ function createServerOptions(overrides: Partial<UiServerOptions> = {}): UiServerOptions {
151
+ return {
152
+ serverName: "test-server",
153
+ toolName: "test_tool",
154
+ toolArgs: { key: "value" },
155
+ resource: createMockResource(),
156
+ manager: createMockManager(),
157
+ consentManager: createMockConsentManager(),
158
+ ...overrides,
159
+ };
160
+ }
161
+
162
+ describe("UiServer", () => {
163
+ let handle: UiServerHandle | null = null;
164
+
165
+ afterEach(() => {
166
+ if (handle) {
167
+ handle.close("test-cleanup");
168
+ handle = null;
169
+ }
170
+ });
171
+
172
+ describe("startUiServer", () => {
173
+ it("starts server on random port", async () => {
174
+ handle = await startUiServer(createServerOptions());
175
+
176
+ expect(handle.port).toBeGreaterThan(0);
177
+ expect(handle.url).toContain(`http://localhost:${handle.port}`);
178
+ expect(handle.sessionToken).toBeTruthy();
179
+ });
180
+
181
+ it("uses provided session token", async () => {
182
+ handle = await startUiServer(createServerOptions({ sessionToken: "custom-token-123" }));
183
+
184
+ expect(handle.sessionToken).toBe("custom-token-123");
185
+ expect(handle.url).toContain("session=custom-token-123");
186
+ });
187
+
188
+ it("uses provided port", async () => {
189
+ // Find a free port first
190
+ const tempServer = http.createServer();
191
+ await new Promise<void>((resolve) => tempServer.listen(0, "127.0.0.1", resolve));
192
+ const freePort = (tempServer.address() as { port: number }).port;
193
+ tempServer.close();
194
+
195
+ handle = await startUiServer(createServerOptions({ port: freePort }));
196
+
197
+ expect(handle.port).toBe(freePort);
198
+ });
199
+
200
+ it("includes server and tool name in handle", async () => {
201
+ handle = await startUiServer(createServerOptions({
202
+ serverName: "my-server",
203
+ toolName: "my_tool",
204
+ }));
205
+
206
+ expect(handle.serverName).toBe("my-server");
207
+ expect(handle.toolName).toBe("my_tool");
208
+ });
209
+ });
210
+
211
+ describe("GET /", () => {
212
+ it("returns HTML host page with valid token", async () => {
213
+ handle = await startUiServer(createServerOptions());
214
+ const url = `http://localhost:${handle.port}/?session=${handle.sessionToken}`;
215
+
216
+ const res = await request(url);
217
+
218
+ expect(res.status).toBe(200);
219
+ expect(res.headers["content-type"]).toContain("text/html");
220
+ expect((res.body as string).toLowerCase()).toContain("<!doctype html>");
221
+ expect(res.body).toContain("test-server");
222
+ expect(res.body).toContain("test_tool");
223
+ });
224
+
225
+ it("rejects invalid session token", async () => {
226
+ handle = await startUiServer(createServerOptions());
227
+ const url = `http://localhost:${handle.port}/?session=wrong-token`;
228
+
229
+ const res = await request(url);
230
+
231
+ expect(res.status).toBe(403);
232
+ expect(res.body).toEqual({ ok: false, error: "Invalid session" });
233
+ });
234
+
235
+ it("rejects missing session token", async () => {
236
+ handle = await startUiServer(createServerOptions());
237
+ const url = `http://localhost:${handle.port}/`;
238
+
239
+ const res = await request(url);
240
+
241
+ expect(res.status).toBe(403);
242
+ });
243
+ });
244
+
245
+ describe("GET /health", () => {
246
+ it("returns healthy status", async () => {
247
+ handle = await startUiServer(createServerOptions());
248
+ const url = `http://localhost:${handle.port}/health?session=${handle.sessionToken}`;
249
+
250
+ const res = await request(url);
251
+
252
+ expect(res.status).toBe(200);
253
+ expect(res.body).toEqual({ ok: true, result: { healthy: true } });
254
+ });
255
+ });
256
+
257
+ describe("GET /app-bridge.bundle.js", () => {
258
+ it("serves JavaScript bundle", async () => {
259
+ handle = await startUiServer(createServerOptions());
260
+ const url = `http://localhost:${handle.port}/app-bridge.bundle.js`;
261
+
262
+ const res = await request(url);
263
+
264
+ // May be 200 or 500 depending on whether bundle exists in test environment
265
+ if (res.status === 200) {
266
+ expect(res.headers["content-type"]).toContain("javascript");
267
+ expect(res.headers["cache-control"]).toContain("max-age");
268
+ }
269
+ });
270
+ });
271
+
272
+ describe("GET /events (SSE)", () => {
273
+ it("establishes SSE connection with valid token", async () => {
274
+ handle = await startUiServer(createServerOptions());
275
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
276
+
277
+ const events: Array<{ name: string; data: unknown }> = [];
278
+ const sse = await connectSSE(url, (name, data) => {
279
+ events.push({ name, data });
280
+ });
281
+
282
+ // Give it a moment to connect
283
+ await new Promise((r) => setTimeout(r, 50));
284
+ sse.close();
285
+
286
+ // Connection should succeed (no error thrown)
287
+ expect(true).toBe(true);
288
+ });
289
+
290
+ it("receives tool-result event when sent", async () => {
291
+ handle = await startUiServer(createServerOptions());
292
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
293
+
294
+ const events: Array<{ name: string; data: unknown }> = [];
295
+ const sse = await connectSSE(url, (name, data) => {
296
+ events.push({ name, data });
297
+ });
298
+
299
+ await new Promise((r) => setTimeout(r, 50));
300
+ handle.sendToolResult({ content: [{ type: "text", text: "hello" }] });
301
+ await new Promise((r) => setTimeout(r, 50));
302
+ sse.close();
303
+
304
+ expect(events.some((e) => e.name === "tool-result")).toBe(true);
305
+ });
306
+
307
+ it("receives result-patch events when sent", async () => {
308
+ handle = await startUiServer(createServerOptions());
309
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
310
+
311
+ const events: Array<{ name: string; data: unknown }> = [];
312
+ const sse = await connectSSE(url, (name, data) => {
313
+ events.push({ name, data });
314
+ });
315
+
316
+ await new Promise((r) => setTimeout(r, 50));
317
+ handle.sendResultPatch({
318
+ content: [{ type: "text", text: "phase" }],
319
+ structuredContent: {
320
+ "pi-mcp-adapter/stream": {
321
+ streamId: "stream-1",
322
+ sequence: 0,
323
+ frameType: "patch",
324
+ phase: "shell",
325
+ status: "ok",
326
+ spec: { kind: "mermaid", title: "Draft" },
327
+ },
328
+ },
329
+ });
330
+ await new Promise((r) => setTimeout(r, 50));
331
+ sse.close();
332
+
333
+ const patch = events.find((e) => e.name === "result-patch");
334
+ expect(patch).toBeTruthy();
335
+ expect(patch?.data).toMatchObject({
336
+ structuredContent: {
337
+ "pi-mcp-adapter/stream": {
338
+ streamId: "stream-1",
339
+ sequence: 0,
340
+ frameType: "patch",
341
+ phase: "shell",
342
+ },
343
+ },
344
+ });
345
+ });
346
+
347
+ it("replays events after Last-Event-ID using SSE ids", async () => {
348
+ handle = await startUiServer(createServerOptions());
349
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
350
+
351
+ const firstConnectionEvents: Array<{ name: string; data: unknown; id?: string }> = [];
352
+ const firstConnection = await connectSSE(url, (name, data, eventId) => {
353
+ firstConnectionEvents.push({ name, data, id: eventId });
354
+ });
355
+
356
+ await new Promise((r) => setTimeout(r, 50));
357
+ handle.sendResultPatch({
358
+ content: [{ type: "text", text: "shell" }],
359
+ structuredContent: {
360
+ "pi-mcp-adapter/stream": {
361
+ streamId: "stream-1",
362
+ sequence: 0,
363
+ frameType: "checkpoint",
364
+ phase: "shell",
365
+ status: "ok",
366
+ checkpoint: { kind: "mermaid", title: "Flow", code: "graph LR\nA-->B" },
367
+ },
368
+ },
369
+ });
370
+ handle.sendToolResult({ content: [{ type: "text", text: "final" }] });
371
+ await new Promise((r) => setTimeout(r, 50));
372
+ firstConnection.close();
373
+
374
+ const checkpointEvent = firstConnectionEvents.find((event) => event.name === "result-patch");
375
+ expect(checkpointEvent?.id).toBeTruthy();
376
+
377
+ const replayedEvents: Array<{ name: string; data: unknown; id?: string }> = [];
378
+ const replayConnection = await connectSSE(
379
+ url,
380
+ (name, data, eventId) => {
381
+ replayedEvents.push({ name, data, id: eventId });
382
+ },
383
+ { "Last-Event-ID": checkpointEvent?.id ?? "" },
384
+ );
385
+
386
+ await new Promise((r) => setTimeout(r, 50));
387
+ replayConnection.close();
388
+
389
+ expect(replayedEvents.map((event) => event.name)).toEqual(["tool-result"]);
390
+ });
391
+
392
+ it("receives tool-cancelled event when sent", async () => {
393
+ handle = await startUiServer(createServerOptions());
394
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
395
+
396
+ const events: Array<{ name: string; data: unknown }> = [];
397
+ const sse = await connectSSE(url, (name, data) => {
398
+ events.push({ name, data });
399
+ });
400
+
401
+ await new Promise((r) => setTimeout(r, 50));
402
+ handle.sendToolCancelled("user cancelled");
403
+ await new Promise((r) => setTimeout(r, 50));
404
+ sse.close();
405
+
406
+ const cancelled = events.find((e) => e.name === "tool-cancelled");
407
+ expect(cancelled).toBeTruthy();
408
+ expect(cancelled?.data).toEqual({ reason: "user cancelled" });
409
+ });
410
+
411
+ it("receives host-context event when sent", async () => {
412
+ handle = await startUiServer(createServerOptions());
413
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
414
+
415
+ const events: Array<{ name: string; data: unknown }> = [];
416
+ const sse = await connectSSE(url, (name, data) => {
417
+ events.push({ name, data });
418
+ });
419
+
420
+ await new Promise((r) => setTimeout(r, 50));
421
+ handle.sendHostContext({ displayMode: "fullscreen" });
422
+ await new Promise((r) => setTimeout(r, 50));
423
+ sse.close();
424
+
425
+ const ctx = events.find((e) => e.name === "host-context");
426
+ expect(ctx).toBeTruthy();
427
+ expect(ctx?.data).toEqual({ displayMode: "fullscreen" });
428
+ });
429
+ });
430
+
431
+ describe("POST /proxy/tools/call", () => {
432
+ it("proxies tool call to MCP server", async () => {
433
+ const mockClient = {
434
+ callTool: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "tool result" }] }),
435
+ };
436
+ const manager = createMockManager({
437
+ getConnection: vi.fn().mockReturnValue({ status: "connected", client: mockClient }),
438
+ });
439
+ handle = await startUiServer(createServerOptions({ manager }));
440
+
441
+ const res = await request(`http://localhost:${handle.port}/proxy/tools/call`, {
442
+ method: "POST",
443
+ body: {
444
+ token: handle.sessionToken,
445
+ params: { name: "some_tool", arguments: { arg1: "value1" } },
446
+ },
447
+ });
448
+
449
+ expect(res.status).toBe(200);
450
+ expect(res.body).toEqual({
451
+ ok: true,
452
+ result: { content: [{ type: "text", text: "tool result" }] },
453
+ });
454
+ expect(mockClient.callTool).toHaveBeenCalledWith({
455
+ name: "some_tool",
456
+ arguments: { arg1: "value1" },
457
+ });
458
+ });
459
+
460
+ it("checks consent before calling tool", async () => {
461
+ const consentManager = createMockConsentManager({
462
+ ensureApproved: vi.fn().mockImplementation(() => {
463
+ throw new Error("Consent denied");
464
+ }),
465
+ });
466
+ handle = await startUiServer(createServerOptions({ consentManager }));
467
+
468
+ const res = await request(`http://localhost:${handle.port}/proxy/tools/call`, {
469
+ method: "POST",
470
+ body: {
471
+ token: handle.sessionToken,
472
+ params: { name: "some_tool", arguments: {} },
473
+ },
474
+ });
475
+
476
+ expect(res.status).toBe(403);
477
+ expect(consentManager.ensureApproved).toHaveBeenCalledWith("test-server");
478
+ });
479
+
480
+ it("returns 503 when server not connected", async () => {
481
+ const manager = createMockManager({
482
+ getConnection: vi.fn().mockReturnValue(null),
483
+ });
484
+ handle = await startUiServer(createServerOptions({ manager }));
485
+
486
+ const res = await request(`http://localhost:${handle.port}/proxy/tools/call`, {
487
+ method: "POST",
488
+ body: {
489
+ token: handle.sessionToken,
490
+ params: { name: "some_tool", arguments: {} },
491
+ },
492
+ });
493
+
494
+ expect(res.status).toBe(503);
495
+ expect((res.body as { error: string }).error).toContain("not connected");
496
+ });
497
+
498
+ it("returns 400 for invalid params", async () => {
499
+ handle = await startUiServer(createServerOptions());
500
+
501
+ const res = await request(`http://localhost:${handle.port}/proxy/tools/call`, {
502
+ method: "POST",
503
+ body: {
504
+ token: handle.sessionToken,
505
+ params: { name: "" }, // Empty name
506
+ },
507
+ });
508
+
509
+ expect(res.status).toBe(400);
510
+ });
511
+
512
+ it("rejects invalid session token", async () => {
513
+ handle = await startUiServer(createServerOptions());
514
+
515
+ const res = await request(`http://localhost:${handle.port}/proxy/tools/call`, {
516
+ method: "POST",
517
+ body: {
518
+ token: "wrong-token",
519
+ params: { name: "some_tool", arguments: {} },
520
+ },
521
+ });
522
+
523
+ expect(res.status).toBe(403);
524
+ });
525
+
526
+ it("tracks in-flight requests", async () => {
527
+ const manager = createMockManager();
528
+ handle = await startUiServer(createServerOptions({ manager }));
529
+
530
+ await request(`http://localhost:${handle.port}/proxy/tools/call`, {
531
+ method: "POST",
532
+ body: {
533
+ token: handle.sessionToken,
534
+ params: { name: "some_tool", arguments: {} },
535
+ },
536
+ });
537
+
538
+ expect(manager.incrementInFlight).toHaveBeenCalledWith("test-server");
539
+ expect(manager.decrementInFlight).toHaveBeenCalledWith("test-server");
540
+ expect(manager.touch).toHaveBeenCalled();
541
+ });
542
+ });
543
+
544
+ describe("POST /proxy/ui/consent", () => {
545
+ it("registers approval", async () => {
546
+ const consentManager = createMockConsentManager();
547
+ handle = await startUiServer(createServerOptions({ consentManager }));
548
+
549
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/consent`, {
550
+ method: "POST",
551
+ body: {
552
+ token: handle.sessionToken,
553
+ params: { approved: true },
554
+ },
555
+ });
556
+
557
+ expect(res.status).toBe(200);
558
+ expect(res.body).toEqual({ ok: true, result: { approved: true } });
559
+ expect(consentManager.registerDecision).toHaveBeenCalledWith("test-server", true);
560
+ });
561
+
562
+ it("registers denial", async () => {
563
+ const consentManager = createMockConsentManager();
564
+ handle = await startUiServer(createServerOptions({ consentManager }));
565
+
566
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/consent`, {
567
+ method: "POST",
568
+ body: {
569
+ token: handle.sessionToken,
570
+ params: { approved: false },
571
+ },
572
+ });
573
+
574
+ expect(res.status).toBe(200);
575
+ expect(res.body).toEqual({ ok: true, result: { approved: false } });
576
+ expect(consentManager.registerDecision).toHaveBeenCalledWith("test-server", false);
577
+ });
578
+ });
579
+
580
+ describe("POST /proxy/ui/message", () => {
581
+ it("tracks prompt messages", async () => {
582
+ const onMessage = vi.fn();
583
+ handle = await startUiServer(createServerOptions({ onMessage }));
584
+
585
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
586
+ method: "POST",
587
+ body: {
588
+ token: handle.sessionToken,
589
+ params: { type: "prompt", prompt: "Hello agent" },
590
+ },
591
+ });
592
+
593
+ const messages = handle.getSessionMessages();
594
+ expect(messages.prompts).toContain("Hello agent");
595
+ expect(onMessage).toHaveBeenCalled();
596
+ });
597
+
598
+ it("tracks intent messages", async () => {
599
+ handle = await startUiServer(createServerOptions());
600
+
601
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
602
+ method: "POST",
603
+ body: {
604
+ token: handle.sessionToken,
605
+ params: { type: "intent", intent: "navigate", params: { to: "/home" } },
606
+ },
607
+ });
608
+
609
+ const messages = handle.getSessionMessages();
610
+ expect(messages.intents).toEqual([{ intent: "navigate", params: { to: "/home" } }]);
611
+ });
612
+
613
+ it("tracks notification messages", async () => {
614
+ handle = await startUiServer(createServerOptions());
615
+
616
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
617
+ method: "POST",
618
+ body: {
619
+ token: handle.sessionToken,
620
+ params: { type: "notify", message: "User clicked button" },
621
+ },
622
+ });
623
+
624
+ const messages = handle.getSessionMessages();
625
+ expect(messages.notifications).toContain("User clicked button");
626
+ });
627
+
628
+ it("handles legacy prompt format", async () => {
629
+ handle = await startUiServer(createServerOptions());
630
+
631
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
632
+ method: "POST",
633
+ body: {
634
+ token: handle.sessionToken,
635
+ params: { prompt: "Legacy prompt" }, // No type field
636
+ },
637
+ });
638
+
639
+ const messages = handle.getSessionMessages();
640
+ expect(messages.prompts).toContain("Legacy prompt");
641
+ });
642
+
643
+ it("handles native AppBridge user messages", async () => {
644
+ handle = await startUiServer(createServerOptions());
645
+
646
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
647
+ method: "POST",
648
+ body: {
649
+ token: handle.sessionToken,
650
+ params: {
651
+ role: "user",
652
+ content: [{ type: "text", text: "Native AppBridge prompt" }],
653
+ },
654
+ },
655
+ });
656
+
657
+ const messages = handle.getSessionMessages();
658
+ expect(messages.prompts).toContain("Native AppBridge prompt");
659
+ });
660
+
661
+ it("accumulates multiple messages", async () => {
662
+ handle = await startUiServer(createServerOptions());
663
+
664
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
665
+ method: "POST",
666
+ body: { token: handle.sessionToken, params: { type: "prompt", prompt: "First" } },
667
+ });
668
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
669
+ method: "POST",
670
+ body: { token: handle.sessionToken, params: { type: "prompt", prompt: "Second" } },
671
+ });
672
+ await request(`http://localhost:${handle.port}/proxy/ui/message`, {
673
+ method: "POST",
674
+ body: { token: handle.sessionToken, params: { type: "notify", message: "Info" } },
675
+ });
676
+
677
+ const messages = handle.getSessionMessages();
678
+ expect(messages.prompts).toEqual(["First", "Second"]);
679
+ expect(messages.notifications).toEqual(["Info"]);
680
+ });
681
+ });
682
+
683
+ describe("POST /proxy/ui/request-display-mode", () => {
684
+ it("changes display mode to fullscreen", async () => {
685
+ handle = await startUiServer(createServerOptions());
686
+
687
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/request-display-mode`, {
688
+ method: "POST",
689
+ body: {
690
+ token: handle.sessionToken,
691
+ params: { mode: "fullscreen" },
692
+ },
693
+ });
694
+
695
+ expect(res.status).toBe(200);
696
+ expect(res.body).toEqual({ ok: true, result: { mode: "fullscreen" } });
697
+ });
698
+
699
+ it("changes display mode to pip", async () => {
700
+ handle = await startUiServer(createServerOptions());
701
+
702
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/request-display-mode`, {
703
+ method: "POST",
704
+ body: {
705
+ token: handle.sessionToken,
706
+ params: { mode: "pip" },
707
+ },
708
+ });
709
+
710
+ expect(res.status).toBe(200);
711
+ expect(res.body).toEqual({ ok: true, result: { mode: "pip" } });
712
+ });
713
+
714
+ it("rejects unavailable mode", async () => {
715
+ handle = await startUiServer(createServerOptions({
716
+ hostContext: { displayMode: "inline", availableDisplayModes: ["inline"] },
717
+ }));
718
+
719
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/request-display-mode`, {
720
+ method: "POST",
721
+ body: {
722
+ token: handle.sessionToken,
723
+ params: { mode: "fullscreen" },
724
+ },
725
+ });
726
+
727
+ // Should return current mode, not requested
728
+ expect(res.status).toBe(200);
729
+ expect((res.body as { result: { mode: string } }).result.mode).toBe("inline");
730
+ });
731
+ });
732
+
733
+ describe("POST /proxy/ui/heartbeat", () => {
734
+ it("responds with success", async () => {
735
+ handle = await startUiServer(createServerOptions());
736
+
737
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/heartbeat`, {
738
+ method: "POST",
739
+ body: { token: handle.sessionToken, params: {} },
740
+ });
741
+
742
+ expect(res.status).toBe(200);
743
+ expect(res.body).toEqual({ ok: true, result: {} });
744
+ });
745
+ });
746
+
747
+ describe("POST /proxy/ui/complete", () => {
748
+ it("marks session complete with reason", async () => {
749
+ const onComplete = vi.fn();
750
+ handle = await startUiServer(createServerOptions({ onComplete }));
751
+
752
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/complete`, {
753
+ method: "POST",
754
+ body: {
755
+ token: handle.sessionToken,
756
+ params: { reason: "user-done" },
757
+ },
758
+ });
759
+
760
+ expect(res.status).toBe(200);
761
+ expect(onComplete).toHaveBeenCalledWith("user-done");
762
+ });
763
+
764
+ it("uses default reason when not provided", async () => {
765
+ const onComplete = vi.fn();
766
+ handle = await startUiServer(createServerOptions({ onComplete }));
767
+
768
+ await request(`http://localhost:${handle.port}/proxy/ui/complete`, {
769
+ method: "POST",
770
+ body: { token: handle.sessionToken, params: {} },
771
+ });
772
+
773
+ expect(onComplete).toHaveBeenCalledWith("done");
774
+ });
775
+ });
776
+
777
+ describe("POST /proxy/ui/open-link", () => {
778
+ it("validates URL", async () => {
779
+ handle = await startUiServer(createServerOptions());
780
+
781
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/open-link`, {
782
+ method: "POST",
783
+ body: {
784
+ token: handle.sessionToken,
785
+ params: { url: "https://example.com" },
786
+ },
787
+ });
788
+
789
+ expect(res.status).toBe(200);
790
+ expect(res.body).toEqual({ ok: true, result: {} });
791
+ });
792
+
793
+ it("returns error for invalid URL", async () => {
794
+ handle = await startUiServer(createServerOptions());
795
+
796
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/open-link`, {
797
+ method: "POST",
798
+ body: {
799
+ token: handle.sessionToken,
800
+ params: { url: "not-a-valid-url" },
801
+ },
802
+ });
803
+
804
+ expect(res.status).toBe(200);
805
+ expect(res.body).toEqual({ ok: true, result: { isError: true } });
806
+ });
807
+
808
+ it("returns 400 for missing URL", async () => {
809
+ handle = await startUiServer(createServerOptions());
810
+
811
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/open-link`, {
812
+ method: "POST",
813
+ body: { token: handle.sessionToken, params: {} },
814
+ });
815
+
816
+ expect(res.status).toBe(400);
817
+ });
818
+ });
819
+
820
+ describe("POST /proxy/ui/download-file", () => {
821
+ it("returns not supported", async () => {
822
+ handle = await startUiServer(createServerOptions());
823
+
824
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/download-file`, {
825
+ method: "POST",
826
+ body: { token: handle.sessionToken, params: {} },
827
+ });
828
+
829
+ expect(res.status).toBe(200);
830
+ expect(res.body).toEqual({ ok: true, result: { isError: true } });
831
+ });
832
+ });
833
+
834
+ describe("POST /proxy/ui/context", () => {
835
+ it("forwards context to callback", async () => {
836
+ const onContextUpdate = vi.fn();
837
+ handle = await startUiServer(createServerOptions({ onContextUpdate }));
838
+
839
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/context`, {
840
+ method: "POST",
841
+ body: {
842
+ token: handle.sessionToken,
843
+ params: { content: "some context data" },
844
+ },
845
+ });
846
+
847
+ expect(res.status).toBe(200);
848
+ expect(onContextUpdate).toHaveBeenCalledWith({ content: "some context data" });
849
+ });
850
+ });
851
+
852
+ describe("404 handling", () => {
853
+ it("returns 404 for unknown GET routes", async () => {
854
+ handle = await startUiServer(createServerOptions());
855
+
856
+ const res = await request(`http://localhost:${handle.port}/unknown?session=${handle.sessionToken}`);
857
+
858
+ expect(res.status).toBe(404);
859
+ });
860
+
861
+ it("returns 404 for unknown POST routes", async () => {
862
+ handle = await startUiServer(createServerOptions());
863
+
864
+ const res = await request(`http://localhost:${handle.port}/proxy/unknown`, {
865
+ method: "POST",
866
+ body: { token: handle.sessionToken, params: {} },
867
+ });
868
+
869
+ expect(res.status).toBe(404);
870
+ });
871
+ });
872
+
873
+ describe("handle.close()", () => {
874
+ it("marks session complete", async () => {
875
+ const onComplete = vi.fn();
876
+ handle = await startUiServer(createServerOptions({ onComplete }));
877
+
878
+ handle.close("manual-close");
879
+
880
+ expect(onComplete).toHaveBeenCalledWith("manual-close");
881
+ });
882
+
883
+ it("uses default reason when not provided", async () => {
884
+ const onComplete = vi.fn();
885
+ handle = await startUiServer(createServerOptions({ onComplete }));
886
+
887
+ handle.close();
888
+
889
+ expect(onComplete).toHaveBeenCalledWith("closed");
890
+ });
891
+ });
892
+
893
+ describe("initialResultPromise", () => {
894
+ it("pushes result when promise resolves", async () => {
895
+ const resultPromise = Promise.resolve({ data: "initial" });
896
+ handle = await startUiServer(createServerOptions({ initialResultPromise: resultPromise }));
897
+
898
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
899
+ const events: Array<{ name: string; data: unknown }> = [];
900
+ const sse = await connectSSE(url, (name, data) => {
901
+ events.push({ name, data });
902
+ });
903
+
904
+ await new Promise((r) => setTimeout(r, 100));
905
+ sse.close();
906
+
907
+ expect(events.some((e) => e.name === "tool-result")).toBe(true);
908
+ });
909
+
910
+ it("pushes cancelled when promise rejects", async () => {
911
+ const resultPromise = Promise.reject(new Error("Tool failed"));
912
+ handle = await startUiServer(createServerOptions({ initialResultPromise: resultPromise }));
913
+
914
+ const url = `http://localhost:${handle.port}/events?session=${handle.sessionToken}`;
915
+ const events: Array<{ name: string; data: unknown }> = [];
916
+ const sse = await connectSSE(url, (name, data) => {
917
+ events.push({ name, data });
918
+ });
919
+
920
+ await new Promise((r) => setTimeout(r, 100));
921
+ sse.close();
922
+
923
+ const cancelled = events.find((e) => e.name === "tool-cancelled");
924
+ expect(cancelled).toBeTruthy();
925
+ expect((cancelled?.data as { reason: string }).reason).toContain("Tool failed");
926
+ });
927
+ });
928
+
929
+ describe("body parsing", () => {
930
+ it("rejects non-JSON body", async () => {
931
+ handle = await startUiServer(createServerOptions());
932
+
933
+ const res = await request(`http://localhost:${handle.port}/proxy/ui/heartbeat`, {
934
+ method: "POST",
935
+ body: "not json" as unknown,
936
+ headers: { "Content-Type": "text/plain" },
937
+ });
938
+
939
+ expect(res.status).toBe(400);
940
+ });
941
+
942
+ it("rejects empty body", async () => {
943
+ handle = await startUiServer(createServerOptions());
944
+
945
+ const parsed = new URL(`http://localhost:${handle.port}/proxy/ui/heartbeat`);
946
+ const result = await new Promise<{ status: number }>((resolve, reject) => {
947
+ const req = http.request(
948
+ {
949
+ hostname: parsed.hostname,
950
+ port: parsed.port,
951
+ path: parsed.pathname,
952
+ method: "POST",
953
+ headers: { "Content-Type": "application/json" },
954
+ },
955
+ (res) => {
956
+ res.on("data", () => {});
957
+ res.on("end", () => resolve({ status: res.statusCode ?? 0 }));
958
+ }
959
+ );
960
+ req.on("error", reject);
961
+ req.end(); // No body
962
+ });
963
+
964
+ expect(result.status).toBe(400);
965
+ });
966
+ });
967
+ });