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,72 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseUiPromptHandoff, type UiSessionMessages } from "../types.ts";
3
+
4
+ describe("UiSessionMessages", () => {
5
+ describe("type structure", () => {
6
+ it("can create empty session messages", () => {
7
+ const messages: UiSessionMessages = {
8
+ prompts: [],
9
+ notifications: [],
10
+ intents: [],
11
+ };
12
+
13
+ expect(messages.prompts).toHaveLength(0);
14
+ expect(messages.notifications).toHaveLength(0);
15
+ expect(messages.intents).toHaveLength(0);
16
+ });
17
+
18
+ it("can store prompts", () => {
19
+ const messages: UiSessionMessages = {
20
+ prompts: ["What is the weather?", "Tell me more"],
21
+ notifications: [],
22
+ intents: [],
23
+ };
24
+
25
+ expect(messages.prompts).toHaveLength(2);
26
+ expect(messages.prompts[0]).toBe("What is the weather?");
27
+ });
28
+
29
+ it("can store notifications", () => {
30
+ const messages: UiSessionMessages = {
31
+ prompts: [],
32
+ notifications: ["Task completed", "Error occurred"],
33
+ intents: [],
34
+ };
35
+
36
+ expect(messages.notifications).toHaveLength(2);
37
+ });
38
+
39
+ it("can store intents with params", () => {
40
+ const messages: UiSessionMessages = {
41
+ prompts: [],
42
+ notifications: [],
43
+ intents: [
44
+ { intent: "get_forecast", params: { days: 7, location: "NYC" } },
45
+ { intent: "refresh" },
46
+ ],
47
+ };
48
+
49
+ expect(messages.intents).toHaveLength(2);
50
+ expect(messages.intents[0].intent).toBe("get_forecast");
51
+ expect(messages.intents[0].params).toEqual({ days: 7, location: "NYC" });
52
+ expect(messages.intents[1].params).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("named handoff envelopes", () => {
57
+ it("parses canonical intent-newline-json payloads", () => {
58
+ expect(
59
+ parseUiPromptHandoff('visualization_annotations_submitted\n{"visualizationId":"flow","annotations":[]}')
60
+ ).toEqual({
61
+ intent: "visualization_annotations_submitted",
62
+ params: { visualizationId: "flow", annotations: [] },
63
+ raw: 'visualization_annotations_submitted\n{"visualizationId":"flow","annotations":[]}',
64
+ });
65
+ });
66
+
67
+ it("ignores free-form prompts", () => {
68
+ expect(parseUiPromptHandoff("Please analyze this chart")).toBeUndefined();
69
+ expect(parseUiPromptHandoff("visualization_annotations_submitted {}")).toBeUndefined();
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,543 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import http from "node:http";
3
+ import { McpServerManager } from "../server-manager.ts";
4
+ import { startUiServer, type UiServerOptions, type UiServerHandle } from "../ui-server.ts";
5
+ import type { ConsentManager } from "../consent-manager.ts";
6
+ import {
7
+ UI_STREAM_HOST_CONTEXT_KEY,
8
+ UI_STREAM_STRUCTURED_CONTENT_KEY,
9
+ SERVER_STREAM_RESULT_PATCH_METHOD,
10
+ getVisualizationStreamEnvelope,
11
+ getUiStreamHostContext,
12
+ serverStreamResultPatchNotificationSchema,
13
+ type UiResourceContent,
14
+ type VisualizationStreamEnvelope,
15
+ } from "../types.ts";
16
+
17
+ // Helper to connect to SSE and collect events
18
+ function connectSSE(
19
+ url: string,
20
+ onEvent: (name: string, data: unknown, eventId?: string) => void,
21
+ headers: Record<string, string> = {}
22
+ ): Promise<{ close: () => void }> {
23
+ return new Promise((resolve, reject) => {
24
+ const parsed = new URL(url);
25
+ const req = http.request(
26
+ {
27
+ hostname: parsed.hostname,
28
+ port: parsed.port,
29
+ path: parsed.pathname + parsed.search,
30
+ method: "GET",
31
+ headers: { Accept: "text/event-stream", ...headers },
32
+ },
33
+ (res) => {
34
+ if (res.statusCode !== 200) {
35
+ reject(new Error(`SSE connection failed: ${res.statusCode}`));
36
+ return;
37
+ }
38
+ let buffer = "";
39
+ let eventName = "message";
40
+ let eventId: string | undefined;
41
+ res.on("data", (chunk) => {
42
+ buffer += chunk.toString();
43
+ const lines = buffer.split("\n");
44
+ buffer = lines.pop() ?? "";
45
+ for (const line of lines) {
46
+ if (line.startsWith("id: ")) {
47
+ eventId = line.slice(4);
48
+ } else if (line.startsWith("event: ")) {
49
+ eventName = line.slice(7);
50
+ } else if (line.startsWith("data: ")) {
51
+ try {
52
+ onEvent(eventName, JSON.parse(line.slice(6)), eventId);
53
+ } catch {
54
+ onEvent(eventName, line.slice(6), eventId);
55
+ }
56
+ eventName = "message";
57
+ eventId = undefined;
58
+ }
59
+ }
60
+ });
61
+ resolve({ close: () => req.destroy() });
62
+ }
63
+ );
64
+ req.on("error", reject);
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ function createMockResource(): UiResourceContent {
70
+ return {
71
+ uri: "ui://test/widget",
72
+ html: "<h1>Test</h1>",
73
+ mimeType: "text/html",
74
+ meta: { permissions: {} },
75
+ };
76
+ }
77
+
78
+ function createServerOptions(overrides: Partial<UiServerOptions> = {}): UiServerOptions {
79
+ return {
80
+ serverName: "test-server",
81
+ toolName: "test_tool",
82
+ toolArgs: { key: "value" },
83
+ resource: createMockResource(),
84
+ manager: {} as McpServerManager,
85
+ consentManager: {} as ConsentManager,
86
+ ...overrides,
87
+ };
88
+ }
89
+
90
+ describe("UI Streaming", () => {
91
+ describe("getUiStreamHostContext", () => {
92
+ it("returns undefined for missing host context", () => {
93
+ expect(getUiStreamHostContext(undefined)).toBeUndefined();
94
+ expect(getUiStreamHostContext({})).toBeUndefined();
95
+ });
96
+
97
+ it("returns undefined for invalid stream context", () => {
98
+ expect(getUiStreamHostContext({ [UI_STREAM_HOST_CONTEXT_KEY]: "invalid" })).toBeUndefined();
99
+ expect(getUiStreamHostContext({ [UI_STREAM_HOST_CONTEXT_KEY]: { mode: "invalid" } })).toBeUndefined();
100
+ });
101
+
102
+ it("parses valid eager stream context", () => {
103
+ const context = getUiStreamHostContext({
104
+ [UI_STREAM_HOST_CONTEXT_KEY]: {
105
+ mode: "eager",
106
+ streamId: "abc-123",
107
+ intermediateResultPatches: false,
108
+ partialInput: false,
109
+ },
110
+ });
111
+ expect(context).toEqual({
112
+ mode: "eager",
113
+ streamId: "abc-123",
114
+ intermediateResultPatches: false,
115
+ partialInput: false,
116
+ });
117
+ });
118
+
119
+ it("parses valid stream-first context", () => {
120
+ const context = getUiStreamHostContext({
121
+ [UI_STREAM_HOST_CONTEXT_KEY]: {
122
+ mode: "stream-first",
123
+ streamId: "xyz-789",
124
+ intermediateResultPatches: true,
125
+ partialInput: false,
126
+ },
127
+ });
128
+ expect(context).toEqual({
129
+ mode: "stream-first",
130
+ streamId: "xyz-789",
131
+ intermediateResultPatches: true,
132
+ partialInput: false,
133
+ });
134
+ });
135
+ });
136
+
137
+ describe("getVisualizationStreamEnvelope", () => {
138
+ it("returns undefined for non-object input", () => {
139
+ expect(getVisualizationStreamEnvelope(undefined)).toBeUndefined();
140
+ expect(getVisualizationStreamEnvelope(null)).toBeUndefined();
141
+ expect(getVisualizationStreamEnvelope("string")).toBeUndefined();
142
+ expect(getVisualizationStreamEnvelope([])).toBeUndefined();
143
+ });
144
+
145
+ it("returns undefined when envelope key is missing", () => {
146
+ expect(getVisualizationStreamEnvelope({})).toBeUndefined();
147
+ expect(getVisualizationStreamEnvelope({ other: "data" })).toBeUndefined();
148
+ });
149
+
150
+ it("returns undefined for invalid envelope", () => {
151
+ expect(getVisualizationStreamEnvelope({
152
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: { streamId: "abc" }, // missing required fields
153
+ })).toBeUndefined();
154
+ });
155
+
156
+ it("parses valid envelope", () => {
157
+ const envelope = getVisualizationStreamEnvelope({
158
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
159
+ streamId: "stream-1",
160
+ sequence: 0,
161
+ frameType: "patch",
162
+ phase: "shell",
163
+ status: "ok",
164
+ },
165
+ });
166
+ expect(envelope).toEqual({
167
+ streamId: "stream-1",
168
+ sequence: 0,
169
+ frameType: "patch",
170
+ phase: "shell",
171
+ status: "ok",
172
+ });
173
+ });
174
+
175
+ it("parses envelope with optional fields", () => {
176
+ const envelope = getVisualizationStreamEnvelope({
177
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
178
+ streamId: "stream-2",
179
+ sequence: 5,
180
+ frameType: "checkpoint",
181
+ phase: "detail",
182
+ status: "ok",
183
+ message: "Checkpoint ready",
184
+ spec: { kind: "mermaid" },
185
+ checkpoint: { kind: "mermaid", code: "graph LR" },
186
+ },
187
+ });
188
+ expect(envelope?.message).toBe("Checkpoint ready");
189
+ expect(envelope?.spec).toEqual({ kind: "mermaid" });
190
+ expect(envelope?.checkpoint).toEqual({ kind: "mermaid", code: "graph LR" });
191
+ });
192
+ });
193
+
194
+ describe("McpServerManager stream listeners", () => {
195
+ function attachNotificationHandler(manager: McpServerManager, serverName = "test-server") {
196
+ const client = { setNotificationHandler: vi.fn() };
197
+ (manager as unknown as {
198
+ attachAdapterNotificationHandlers: (serverName: string, client: { setNotificationHandler: typeof client.setNotificationHandler }) => void;
199
+ }).attachAdapterNotificationHandlers(serverName, client);
200
+ expect(client.setNotificationHandler).toHaveBeenCalledOnce();
201
+ return client.setNotificationHandler.mock.calls[0][1] as (notification: {
202
+ method: string;
203
+ params: {
204
+ streamToken: string;
205
+ result: { content?: unknown[]; structuredContent?: Record<string, unknown> };
206
+ };
207
+ }) => void;
208
+ }
209
+
210
+ it("routes notifications to the matching listener", () => {
211
+ const manager = new McpServerManager();
212
+ const listener = vi.fn();
213
+ const handleNotification = attachNotificationHandler(manager, "server-a");
214
+
215
+ manager.registerUiStreamListener("token-123", listener);
216
+ const notification = {
217
+ method: SERVER_STREAM_RESULT_PATCH_METHOD,
218
+ params: {
219
+ streamToken: "token-123",
220
+ result: { content: [{ type: "text", text: "patch" }] },
221
+ },
222
+ };
223
+
224
+ handleNotification(notification);
225
+
226
+ expect(listener).toHaveBeenCalledTimes(1);
227
+ expect(listener).toHaveBeenCalledWith("server-a", notification.params);
228
+ });
229
+
230
+ it("does not call removed listeners", () => {
231
+ const manager = new McpServerManager();
232
+ const listener = vi.fn();
233
+ const handleNotification = attachNotificationHandler(manager);
234
+
235
+ manager.registerUiStreamListener("token-456", listener);
236
+ manager.removeUiStreamListener("token-456");
237
+
238
+ handleNotification({
239
+ method: SERVER_STREAM_RESULT_PATCH_METHOD,
240
+ params: {
241
+ streamToken: "token-456",
242
+ result: { content: [{ type: "text", text: "patch" }] },
243
+ },
244
+ });
245
+
246
+ expect(listener).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it("keeps multiple listeners isolated by stream token", () => {
250
+ const manager = new McpServerManager();
251
+ const listener1 = vi.fn();
252
+ const listener2 = vi.fn();
253
+ const handleNotification = attachNotificationHandler(manager, "server-b");
254
+
255
+ manager.registerUiStreamListener("token-1", listener1);
256
+ manager.registerUiStreamListener("token-2", listener2);
257
+
258
+ handleNotification({
259
+ method: SERVER_STREAM_RESULT_PATCH_METHOD,
260
+ params: {
261
+ streamToken: "token-2",
262
+ result: { content: [{ type: "text", text: "patch-2" }] },
263
+ },
264
+ });
265
+
266
+ expect(listener1).not.toHaveBeenCalled();
267
+ expect(listener2).toHaveBeenCalledTimes(1);
268
+ expect(listener2).toHaveBeenCalledWith("server-b", {
269
+ streamToken: "token-2",
270
+ result: { content: [{ type: "text", text: "patch-2" }] },
271
+ });
272
+ });
273
+ });
274
+
275
+ describe("UiServer SSE streaming", () => {
276
+ let handle: UiServerHandle | null = null;
277
+
278
+ afterEach(() => {
279
+ if (handle) {
280
+ handle.close("test-cleanup");
281
+ handle = null;
282
+ }
283
+ });
284
+
285
+ it("sends result-patch events with stream envelope", async () => {
286
+ handle = await startUiServer(createServerOptions({
287
+ hostContext: {
288
+ [UI_STREAM_HOST_CONTEXT_KEY]: {
289
+ mode: "stream-first",
290
+ streamId: "test-stream",
291
+ intermediateResultPatches: true,
292
+ partialInput: false,
293
+ },
294
+ },
295
+ }));
296
+
297
+ const events: Array<{ name: string; data: unknown; id?: string }> = [];
298
+ const sse = await connectSSE(
299
+ `http://localhost:${handle.port}/events?session=${handle.sessionToken}`,
300
+ (name, data, id) => events.push({ name, data, id })
301
+ );
302
+
303
+ // Send a result patch
304
+ handle.sendResultPatch({
305
+ content: [{ type: "text", text: "Streaming..." }],
306
+ structuredContent: {
307
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
308
+ streamId: "test-stream",
309
+ sequence: 1,
310
+ frameType: "patch",
311
+ phase: "shell",
312
+ status: "ok",
313
+ message: "Building shell",
314
+ },
315
+ },
316
+ });
317
+
318
+ // Wait for event propagation
319
+ await new Promise((r) => setTimeout(r, 50));
320
+ sse.close();
321
+
322
+ const patchEvents = events.filter((e) => e.name === "result-patch");
323
+ expect(patchEvents).toHaveLength(1);
324
+
325
+ const envelope = getVisualizationStreamEnvelope(
326
+ (patchEvents[0].data as { structuredContent?: unknown })?.structuredContent
327
+ );
328
+ expect(envelope?.frameType).toBe("patch");
329
+ expect(envelope?.phase).toBe("shell");
330
+ });
331
+
332
+ it("assigns sequential event IDs", async () => {
333
+ handle = await startUiServer(createServerOptions());
334
+
335
+ const eventIds: string[] = [];
336
+ const sse = await connectSSE(
337
+ `http://localhost:${handle.port}/events?session=${handle.sessionToken}`,
338
+ (_name, _data, id) => { if (id) eventIds.push(id); }
339
+ );
340
+
341
+ handle.sendResultPatch({ content: [] });
342
+ handle.sendResultPatch({ content: [] });
343
+ handle.sendResultPatch({ content: [] });
344
+
345
+ await new Promise((r) => setTimeout(r, 50));
346
+ sse.close();
347
+
348
+ const ids = eventIds.map(Number).filter((n) => !Number.isNaN(n));
349
+ expect(ids).toHaveLength(3);
350
+ for (let i = 1; i < ids.length; i++) {
351
+ expect(ids[i]).toBeGreaterThan(ids[i - 1]);
352
+ }
353
+ });
354
+
355
+ it("replays events from Last-Event-ID", async () => {
356
+ handle = await startUiServer(createServerOptions());
357
+
358
+ // First connection to send some events
359
+ const firstEvents: Array<{ name: string; id?: string }> = [];
360
+ const sse1 = await connectSSE(
361
+ `http://localhost:${handle.port}/events?session=${handle.sessionToken}`,
362
+ (name, _data, id) => firstEvents.push({ name, id })
363
+ );
364
+
365
+ handle.sendResultPatch({ content: [{ type: "text", text: "patch-1" }] });
366
+ handle.sendResultPatch({ content: [{ type: "text", text: "patch-2" }] });
367
+ handle.sendResultPatch({ content: [{ type: "text", text: "patch-3" }] });
368
+
369
+ await new Promise((r) => setTimeout(r, 50));
370
+ sse1.close();
371
+
372
+ // Get the ID of the first patch event
373
+ const patchEvents = firstEvents.filter((e) => e.name === "result-patch");
374
+ expect(patchEvents.length).toBe(3);
375
+ const firstPatchId = patchEvents[0].id;
376
+ expect(firstPatchId).toBeDefined();
377
+
378
+ // Second connection with Last-Event-ID should replay from that point
379
+ const replayedEvents: Array<{ name: string; id?: string }> = [];
380
+ const sse2 = await connectSSE(
381
+ `http://localhost:${handle.port}/events?session=${handle.sessionToken}`,
382
+ (name, _data, id) => replayedEvents.push({ name, id }),
383
+ { "Last-Event-ID": firstPatchId! }
384
+ );
385
+
386
+ await new Promise((r) => setTimeout(r, 50));
387
+ sse2.close();
388
+
389
+ // Should replay events AFTER the provided ID
390
+ const replayedPatches = replayedEvents.filter((e) => e.name === "result-patch");
391
+ expect(replayedPatches.length).toBe(2); // patch-2 and patch-3
392
+ });
393
+
394
+ it("replays from latest checkpoint for fresh connections", async () => {
395
+ handle = await startUiServer(createServerOptions());
396
+
397
+ // Send patches and a checkpoint
398
+ const sse1 = await connectSSE(
399
+ `http://localhost:${handle.port}/events?session=${handle.sessionToken}`,
400
+ () => {}
401
+ );
402
+
403
+ handle.sendResultPatch({
404
+ content: [],
405
+ structuredContent: {
406
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
407
+ streamId: "s1", sequence: 0, frameType: "patch", phase: "shell", status: "ok",
408
+ },
409
+ },
410
+ });
411
+ handle.sendResultPatch({
412
+ content: [],
413
+ structuredContent: {
414
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
415
+ streamId: "s1", sequence: 1, frameType: "checkpoint", phase: "detail", status: "ok",
416
+ },
417
+ },
418
+ });
419
+ handle.sendResultPatch({
420
+ content: [],
421
+ structuredContent: {
422
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
423
+ streamId: "s1", sequence: 2, frameType: "patch", phase: "detail", status: "ok",
424
+ },
425
+ },
426
+ });
427
+
428
+ await new Promise((r) => setTimeout(r, 50));
429
+ sse1.close();
430
+
431
+ // Fresh connection (no Last-Event-ID) should start from checkpoint
432
+ const freshEvents: Array<{ data: unknown }> = [];
433
+ const sse2 = await connectSSE(
434
+ `http://localhost:${handle.port}/events?session=${handle.sessionToken}`,
435
+ (_name, data) => freshEvents.push({ data })
436
+ );
437
+
438
+ await new Promise((r) => setTimeout(r, 50));
439
+ sse2.close();
440
+
441
+ // Should have replayed checkpoint and subsequent patches
442
+ const envelopes = freshEvents
443
+ .map((e) => getVisualizationStreamEnvelope((e.data as { structuredContent?: unknown })?.structuredContent))
444
+ .filter(Boolean) as VisualizationStreamEnvelope[];
445
+
446
+ expect(envelopes).toHaveLength(2);
447
+ expect(envelopes.map((envelope) => envelope.frameType)).toEqual(["checkpoint", "patch"]);
448
+ });
449
+
450
+ it("tracks stream summary", async () => {
451
+ handle = await startUiServer(createServerOptions({
452
+ hostContext: {
453
+ [UI_STREAM_HOST_CONTEXT_KEY]: {
454
+ mode: "stream-first",
455
+ streamId: "summary-test",
456
+ intermediateResultPatches: true,
457
+ partialInput: false,
458
+ },
459
+ },
460
+ }));
461
+
462
+ handle.sendResultPatch({
463
+ content: [],
464
+ structuredContent: {
465
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
466
+ streamId: "summary-test", sequence: 0, frameType: "patch", phase: "shell", status: "ok",
467
+ },
468
+ },
469
+ });
470
+ handle.sendResultPatch({
471
+ content: [],
472
+ structuredContent: {
473
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
474
+ streamId: "summary-test", sequence: 1, frameType: "patch", phase: "structure", status: "ok",
475
+ },
476
+ },
477
+ });
478
+ handle.sendToolResult({
479
+ content: [],
480
+ structuredContent: {
481
+ [UI_STREAM_STRUCTURED_CONTENT_KEY]: {
482
+ streamId: "summary-test", sequence: 2, frameType: "final", phase: "settled", status: "ok",
483
+ message: "Complete",
484
+ },
485
+ },
486
+ });
487
+
488
+ const summary = handle.getStreamSummary();
489
+ expect(summary).toBeDefined();
490
+ expect(summary?.streamId).toBe("summary-test");
491
+ expect(summary?.mode).toBe("stream-first");
492
+ expect(summary?.frames).toBe(3);
493
+ expect(summary?.phases).toContain("shell");
494
+ expect(summary?.phases).toContain("structure");
495
+ expect(summary?.phases).toContain("settled");
496
+ expect(summary?.finalStatus).toBe("ok");
497
+ expect(summary?.lastMessage).toBe("Complete");
498
+ });
499
+ });
500
+
501
+ describe("Server stream result patch notification schema", () => {
502
+ it("validates correct notification format", () => {
503
+ const notification = {
504
+ method: SERVER_STREAM_RESULT_PATCH_METHOD,
505
+ params: {
506
+ streamToken: "token-123",
507
+ result: {
508
+ content: [{ type: "text", text: "test" }],
509
+ structuredContent: { data: "value" },
510
+ },
511
+ },
512
+ };
513
+
514
+ const parsed = serverStreamResultPatchNotificationSchema.safeParse(notification);
515
+ expect(parsed.success).toBe(true);
516
+ });
517
+
518
+ it("rejects notification with wrong method", () => {
519
+ const notification = {
520
+ method: "wrong/method",
521
+ params: {
522
+ streamToken: "token-123",
523
+ result: { content: [] },
524
+ },
525
+ };
526
+
527
+ const parsed = serverStreamResultPatchNotificationSchema.safeParse(notification);
528
+ expect(parsed.success).toBe(false);
529
+ });
530
+
531
+ it("rejects notification with missing streamToken", () => {
532
+ const notification = {
533
+ method: SERVER_STREAM_RESULT_PATCH_METHOD,
534
+ params: {
535
+ result: { content: [] },
536
+ },
537
+ };
538
+
539
+ const parsed = serverStreamResultPatchNotificationSchema.safeParse(notification);
540
+ expect(parsed.success).toBe(false);
541
+ });
542
+ });
543
+ });
@@ -0,0 +1,20 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export function getAgentDir(): string {
5
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
6
+ if (!configured) {
7
+ return join(homedir(), ".pi", "agent");
8
+ }
9
+ if (configured === "~") {
10
+ return homedir();
11
+ }
12
+ if (configured.startsWith("~/")) {
13
+ return resolve(homedir(), configured.slice(2));
14
+ }
15
+ return resolve(configured);
16
+ }
17
+
18
+ export function getAgentPath(...segments: string[]): string {
19
+ return join(getAgentDir(), ...segments);
20
+ }