@runfusion/fusion 0.13.0 → 0.14.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 (69) hide show
  1. package/README.md +13 -0
  2. package/dist/bin.js +1250 -522
  3. package/dist/client/assets/AgentDetailView-CBFUveyO.js +18 -0
  4. package/dist/client/assets/{AgentsView-Dvf_xUkx.js → AgentsView-DPezXQ-U.js} +4 -4
  5. package/dist/client/assets/ChatView-5N4-EuhD.js +1 -0
  6. package/dist/client/assets/{DevServerView-C2qTJch7.js → DevServerView-Daft4YFc.js} +1 -1
  7. package/dist/client/assets/{DirectoryPicker-DRfhg9zz.js → DirectoryPicker-rew1y6qO.js} +1 -1
  8. package/dist/client/assets/{DocumentsView-j8ic1xUw.js → DocumentsView-i72qJzwd.js} +1 -1
  9. package/dist/client/assets/{InsightsView-CpAz3o0i.js → InsightsView-BL5eZJ0a.js} +1 -1
  10. package/dist/client/assets/{MemoryView-BcQsi_JK.js → MemoryView-pl8Cdg_p.js} +1 -1
  11. package/dist/client/assets/{NodesView-Bo_Yhr4N.js → NodesView-D6eJ15zc.js} +1 -1
  12. package/dist/client/assets/PiExtensionsManager-ExInwXWP.js +11 -0
  13. package/dist/client/assets/{PluginManager-BQhBHWrB.js → PluginManager-CYhtxHun.js} +1 -1
  14. package/dist/client/assets/{ResearchView-CLyyqAWE.js → ResearchView-B_QPUEjB.js} +1 -1
  15. package/dist/client/assets/{RoadmapsView-tG7IdOoc.js → RoadmapsView-DBNLaEsK.js} +1 -1
  16. package/dist/client/assets/SettingsModal-1ET586M3.js +31 -0
  17. package/dist/client/assets/{SettingsModal-CXUGeZ0_.js → SettingsModal-CL_gWmOj.js} +1 -1
  18. package/dist/client/assets/SettingsModal-D_AFkDJa.css +1 -0
  19. package/dist/client/assets/{SetupWizardModal-BMJL6eNR.js → SetupWizardModal-CLkY9HFL.js} +1 -1
  20. package/dist/client/assets/{SkillMultiselect-ILMft-Kz.js → SkillMultiselect-B0qi32SQ.js} +1 -1
  21. package/dist/client/assets/{SkillsView-x4_YwBz6.js → SkillsView-umVjRq6o.js} +1 -1
  22. package/dist/client/assets/TodoView-CFifSvrD.js +6 -0
  23. package/dist/client/assets/TodoView-SeO9o7km.css +1 -0
  24. package/dist/client/assets/{folder-open-DDdJt8aE.js → folder-open-nYPrL1W3.js} +1 -1
  25. package/dist/client/assets/index-Bc8nfKeH.js +661 -0
  26. package/dist/client/assets/index-C1prPuSl.css +1 -0
  27. package/dist/client/assets/{list-checks-DFxQ9biT.js → list-checks-sK8xJeH_.js} +1 -1
  28. package/dist/client/assets/{star-BKs1bgJN.js → star-BRtXbYkB.js} +1 -1
  29. package/dist/client/assets/{upload-Bb5Pidne.js → upload-BP60eBwN.js} +1 -1
  30. package/dist/client/assets/{users-BImNn91Q.js → users-qSGAX2Pf.js} +1 -1
  31. package/dist/client/index.html +2 -2
  32. package/dist/client/sw.js +6 -0
  33. package/dist/client/version.json +1 -1
  34. package/dist/droid-cli/index.ts +127 -0
  35. package/dist/droid-cli/package.json +37 -0
  36. package/dist/droid-cli/src/__tests__/control-handler.test.ts +164 -0
  37. package/dist/droid-cli/src/__tests__/event-bridge.test.ts +1318 -0
  38. package/dist/droid-cli/src/__tests__/mcp-config.test.ts +310 -0
  39. package/dist/droid-cli/src/__tests__/process-manager.test.ts +818 -0
  40. package/dist/droid-cli/src/__tests__/prompt-builder.test.ts +1206 -0
  41. package/dist/droid-cli/src/__tests__/provider.test.ts +1894 -0
  42. package/dist/droid-cli/src/__tests__/setup-test-isolation.test.ts +32 -0
  43. package/dist/droid-cli/src/__tests__/setup-test-isolation.ts +14 -0
  44. package/dist/droid-cli/src/__tests__/stream-parser.test.ts +188 -0
  45. package/dist/droid-cli/src/__tests__/thinking-config.test.ts +141 -0
  46. package/dist/droid-cli/src/__tests__/tool-mapping.test.ts +253 -0
  47. package/dist/droid-cli/src/control-handler.ts +82 -0
  48. package/dist/droid-cli/src/event-bridge.ts +397 -0
  49. package/dist/droid-cli/src/mcp-config.ts +144 -0
  50. package/dist/droid-cli/src/mcp-schema-server.cjs +49 -0
  51. package/dist/droid-cli/src/process-manager.ts +358 -0
  52. package/dist/droid-cli/src/prompt-builder.ts +629 -0
  53. package/dist/droid-cli/src/provider.ts +447 -0
  54. package/dist/droid-cli/src/stream-parser.ts +37 -0
  55. package/dist/droid-cli/src/thinking-config.ts +83 -0
  56. package/dist/droid-cli/src/tool-mapping.ts +147 -0
  57. package/dist/droid-cli/src/types.ts +87 -0
  58. package/dist/extension.js +473 -119
  59. package/dist/pi-claude-cli/package.json +1 -1
  60. package/package.json +2 -1
  61. package/dist/client/assets/AgentDetailView-B7j297GT.js +0 -18
  62. package/dist/client/assets/ChatView-BgUt38ty.js +0 -1
  63. package/dist/client/assets/PiExtensionsManager-DHt2zFg8.js +0 -11
  64. package/dist/client/assets/SettingsModal-9HS8MnmW.css +0 -1
  65. package/dist/client/assets/SettingsModal-UziTDnLh.js +0 -31
  66. package/dist/client/assets/TodoView-BBYcMbXE.js +0 -6
  67. package/dist/client/assets/TodoView-C1g65hJo.css +0 -1
  68. package/dist/client/assets/index-B15xwijw.css +0 -1
  69. package/dist/client/assets/index-DmSs2FGE.js +0 -661
@@ -0,0 +1,1206 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { buildPrompt, buildResumePrompt } from "../prompt-builder";
3
+
4
+ describe("buildPrompt", () => {
5
+ it("returns empty string for empty messages array", () => {
6
+ const context = { messages: [] } as unknown as any;
7
+ expect(buildPrompt(context)).toBe("");
8
+ });
9
+
10
+ it("produces 'USER:\\n{text}' for a single user text message", () => {
11
+ const context = {
12
+ messages: [{ role: "user", content: "Hello world" }],
13
+ } as unknown as any;
14
+ expect(buildPrompt(context)).toBe("USER:\nHello world");
15
+ });
16
+
17
+ it("produces 'ASSISTANT:\\n{text}' for a single assistant text message", () => {
18
+ const context = {
19
+ messages: [{ role: "assistant", content: "Hi there" }],
20
+ } as unknown as any;
21
+ expect(buildPrompt(context)).toBe("ASSISTANT:\nHi there");
22
+ });
23
+
24
+ it("produces 'TOOL RESULT ({claudeName}):\\n{content}' for a tool result message", () => {
25
+ const context = {
26
+ messages: [
27
+ {
28
+ role: "toolResult",
29
+ content: "file contents here",
30
+ toolName: "read",
31
+ },
32
+ ],
33
+ } as unknown as any;
34
+ // Pi tool name "read" should be mapped to Claude name "Read" in the label
35
+ expect(buildPrompt(context)).toBe(
36
+ "TOOL RESULT (Read):\nfile contents here",
37
+ );
38
+ });
39
+
40
+ it("produces correctly ordered labeled blocks for mixed conversation", () => {
41
+ const context = {
42
+ messages: [
43
+ { role: "user", content: "What is in file.ts?" },
44
+ { role: "assistant", content: "Let me read that file." },
45
+ {
46
+ role: "toolResult",
47
+ content: "export const x = 1;",
48
+ toolName: "read",
49
+ },
50
+ { role: "user", content: "Now explain it." },
51
+ ],
52
+ } as unknown as any;
53
+
54
+ const result = buildPrompt(context);
55
+ const expected = [
56
+ "USER:",
57
+ "What is in file.ts?",
58
+ "ASSISTANT:",
59
+ "Let me read that file.",
60
+ "TOOL RESULT (Read):",
61
+ "export const x = 1;",
62
+ "USER:",
63
+ "Now explain it.",
64
+ ].join("\n");
65
+
66
+ expect(result).toBe(expected);
67
+ });
68
+
69
+ it("extracts text from array content blocks in user messages", () => {
70
+ const context = {
71
+ messages: [
72
+ {
73
+ role: "user",
74
+ content: [
75
+ { type: "text", text: "First block" },
76
+ { type: "text", text: "Second block" },
77
+ ],
78
+ },
79
+ ],
80
+ } as unknown as any;
81
+
82
+ const result = buildPrompt(context);
83
+ expect(result).toBe("USER:\nFirst block\nSecond block");
84
+ });
85
+
86
+ it("serializes assistant mixed content (text + thinking + toolCall) with Claude name mapping", () => {
87
+ const context = {
88
+ messages: [
89
+ {
90
+ role: "assistant",
91
+ content: [
92
+ { type: "text", text: "I will help you." },
93
+ { type: "thinking", thinking: "Let me think about this..." },
94
+ {
95
+ type: "toolCall",
96
+ name: "read",
97
+ arguments: { path: "/file.ts" },
98
+ },
99
+ ],
100
+ },
101
+ ],
102
+ } as unknown as any;
103
+
104
+ const result = buildPrompt(context);
105
+ expect(result).toContain("ASSISTANT:");
106
+ expect(result).toContain("I will help you.");
107
+ // Thinking content is skipped in prompt replay (internal reasoning)
108
+ expect(result).not.toContain("Let me think about this...");
109
+ // Tool name should be mapped from pi "read" to Claude "Read"
110
+ // Arg "path" should be mapped from pi format to Claude "file_path"
111
+ expect(result).toContain(
112
+ '[Prior tool call — already executed; result follows in TOOL RESULT (Read):] args={"file_path":"/file.ts"}',
113
+ );
114
+ });
115
+
116
+ it("inserts placeholder text for image blocks in non-final user messages", () => {
117
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
118
+ const context = {
119
+ messages: [
120
+ {
121
+ role: "user",
122
+ content: [
123
+ { type: "text", text: "Look at this image" },
124
+ { type: "image", data: "abc", mimeType: "image/png" },
125
+ ],
126
+ },
127
+ { role: "assistant", content: "I see." },
128
+ { role: "user", content: "Now explain it." },
129
+ ],
130
+ } as unknown as any;
131
+
132
+ const result = buildPrompt(context);
133
+ // Non-final user message should have placeholder
134
+ expect(result).toContain(
135
+ "[An image was shared here but could not be included]",
136
+ );
137
+ // Console.warn should be called once with image count
138
+ expect(warnSpy).toHaveBeenCalledTimes(1);
139
+ expect(warnSpy.mock.calls[0][0]).toContain("1 image(s)");
140
+ warnSpy.mockRestore();
141
+ });
142
+
143
+ it("handles toolCall with no arguments (maps pi name to Claude name)", () => {
144
+ const context = {
145
+ messages: [
146
+ {
147
+ role: "assistant",
148
+ content: [
149
+ {
150
+ type: "toolCall",
151
+ name: "bash",
152
+ },
153
+ ],
154
+ },
155
+ ],
156
+ } as unknown as any;
157
+
158
+ const result = buildPrompt(context);
159
+ // Pi "bash" maps to Claude "Bash"
160
+ expect(result).toContain(
161
+ "[Prior tool call — already executed; result follows in TOOL RESULT (Bash):] args={}",
162
+ );
163
+ });
164
+
165
+ it("handles tool result with array content blocks", () => {
166
+ const context = {
167
+ messages: [
168
+ {
169
+ role: "toolResult",
170
+ content: [
171
+ { type: "text", text: "line 1" },
172
+ { type: "text", text: "line 2" },
173
+ ],
174
+ toolName: "bash",
175
+ },
176
+ ],
177
+ } as unknown as any;
178
+
179
+ const result = buildPrompt(context);
180
+ expect(result).toBe("TOOL RESULT (Bash):\nline 1\nline 2");
181
+ });
182
+
183
+ describe("tool name and argument reverse mapping", () => {
184
+ it("maps pi tool name to Claude name in toolCall serialization", () => {
185
+ const context = {
186
+ messages: [
187
+ {
188
+ role: "assistant",
189
+ content: [
190
+ {
191
+ type: "toolCall",
192
+ name: "read",
193
+ arguments: { path: "/foo" },
194
+ },
195
+ ],
196
+ },
197
+ ],
198
+ } as unknown as any;
199
+
200
+ const result = buildPrompt(context);
201
+ expect(result).toContain("Read");
202
+ expect(result).toContain('"file_path":"/foo"');
203
+ });
204
+
205
+ it("translates pi arguments to Claude format for edit tool", () => {
206
+ const context = {
207
+ messages: [
208
+ {
209
+ role: "assistant",
210
+ content: [
211
+ {
212
+ type: "toolCall",
213
+ name: "edit",
214
+ arguments: { path: "/foo.ts", oldText: "old", newText: "new" },
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ } as unknown as any;
220
+
221
+ const result = buildPrompt(context);
222
+ expect(result).toContain("Edit");
223
+ expect(result).toContain('"file_path":"/foo.ts"');
224
+ expect(result).toContain('"old_string":"old"');
225
+ expect(result).toContain('"new_string":"new"');
226
+ });
227
+
228
+ it("maps pi tool name to Claude name in tool result label", () => {
229
+ const context = {
230
+ messages: [
231
+ {
232
+ role: "toolResult",
233
+ content: "result text",
234
+ toolName: "read",
235
+ },
236
+ ],
237
+ } as unknown as any;
238
+
239
+ const result = buildPrompt(context);
240
+ expect(result).toContain("TOOL RESULT (Read):");
241
+ });
242
+
243
+ it("prefixes custom (non-built-in) tool names with MCP prefix", () => {
244
+ const context = {
245
+ messages: [
246
+ {
247
+ role: "assistant",
248
+ content: [
249
+ {
250
+ type: "toolCall",
251
+ name: "custom_tool",
252
+ arguments: { key: "value" },
253
+ },
254
+ ],
255
+ },
256
+ ],
257
+ } as unknown as any;
258
+
259
+ const result = buildPrompt(context);
260
+ // Custom tool uses plain name format (not MCP-prefixed to avoid Claude re-calling)
261
+ expect(result).toContain("[Used custom_tool tool with args:");
262
+ expect(result).not.toContain("mcp__custom-tools__");
263
+ expect(result).toContain('"key":"value"');
264
+ });
265
+
266
+ it("handles toolCall with string arguments (raw unparsed)", () => {
267
+ const context = {
268
+ messages: [
269
+ {
270
+ role: "assistant",
271
+ content: [
272
+ {
273
+ type: "toolCall",
274
+ name: "read",
275
+ arguments: "raw string args",
276
+ },
277
+ ],
278
+ },
279
+ ],
280
+ } as unknown as any;
281
+
282
+ const result = buildPrompt(context);
283
+ // String arguments should be serialized as JSON string
284
+ expect(result).toContain('TOOL RESULT (Read):');
285
+ expect(result).toContain('args="raw string args"');
286
+ });
287
+ });
288
+ });
289
+
290
+ describe("image passthrough (HIST-02)", () => {
291
+ afterEach(() => {
292
+ vi.restoreAllMocks();
293
+ });
294
+
295
+ it("single user message with text and image returns ContentBlock[]", () => {
296
+ const context = {
297
+ messages: [
298
+ {
299
+ role: "user",
300
+ content: [
301
+ { type: "text", text: "Look at this" },
302
+ { type: "image", data: "base64data", mimeType: "image/png" },
303
+ ],
304
+ },
305
+ ],
306
+ } as unknown as any;
307
+
308
+ const result = buildPrompt(context);
309
+ // Should return an array (not string) with text + image blocks
310
+ expect(Array.isArray(result)).toBe(true);
311
+ const arr = result as any[];
312
+ expect(arr).toContainEqual({ type: "text", text: "Look at this" });
313
+ expect(arr).toContainEqual({
314
+ type: "image",
315
+ source: { type: "base64", media_type: "image/png", data: "base64data" },
316
+ });
317
+ });
318
+
319
+ it("multi-turn with images only in final user message returns ContentBlock[]", () => {
320
+ const context = {
321
+ messages: [
322
+ { role: "user", content: "Hello" },
323
+ { role: "assistant", content: "Hi there" },
324
+ {
325
+ role: "user",
326
+ content: [
327
+ { type: "text", text: "Check this" },
328
+ { type: "image", data: "imgdata", mimeType: "image/jpeg" },
329
+ ],
330
+ },
331
+ ],
332
+ } as unknown as any;
333
+
334
+ const result = buildPrompt(context);
335
+ expect(Array.isArray(result)).toBe(true);
336
+ const arr = result as any[];
337
+ // First elements should be text blocks for history
338
+ const textBlocks = arr.filter((b: any) => b.type === "text");
339
+ expect(textBlocks.length).toBeGreaterThanOrEqual(2);
340
+ // History text should contain the prior messages
341
+ const historyText = textBlocks[0].text;
342
+ expect(historyText).toContain("USER:");
343
+ expect(historyText).toContain("Hello");
344
+ expect(historyText).toContain("ASSISTANT:");
345
+ expect(historyText).toContain("Hi there");
346
+ // Final user message text block
347
+ expect(arr).toContainEqual({ type: "text", text: "Check this" });
348
+ // Image block in Anthropic format
349
+ expect(arr).toContainEqual({
350
+ type: "image",
351
+ source: { type: "base64", media_type: "image/jpeg", data: "imgdata" },
352
+ });
353
+ });
354
+
355
+ it("multi-turn with images in non-final user message uses placeholder and returns string", () => {
356
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
357
+ const context = {
358
+ messages: [
359
+ {
360
+ role: "user",
361
+ content: [
362
+ { type: "text", text: "See this" },
363
+ { type: "image", data: "imgdata", mimeType: "image/png" },
364
+ ],
365
+ },
366
+ { role: "assistant", content: "Noted." },
367
+ { role: "user", content: "What do you think?" },
368
+ ],
369
+ } as unknown as any;
370
+
371
+ const result = buildPrompt(context);
372
+ // No images in final message -> returns string
373
+ expect(typeof result).toBe("string");
374
+ // Non-final user message has placeholder
375
+ expect(result).toContain(
376
+ "[An image was shared here but could not be included]",
377
+ );
378
+ // Console.warn called once with image count
379
+ expect(warnSpy).toHaveBeenCalledTimes(1);
380
+ expect(warnSpy.mock.calls[0][0]).toContain("1 image(s)");
381
+ warnSpy.mockRestore();
382
+ });
383
+
384
+ it("multi-turn with images in both non-final and final user messages", () => {
385
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
386
+ const context = {
387
+ messages: [
388
+ {
389
+ role: "user",
390
+ content: [
391
+ { type: "text", text: "First image" },
392
+ { type: "image", data: "img1", mimeType: "image/png" },
393
+ ],
394
+ },
395
+ { role: "assistant", content: "Got it." },
396
+ {
397
+ role: "user",
398
+ content: [
399
+ { type: "text", text: "Second image" },
400
+ { type: "image", data: "img2", mimeType: "image/jpeg" },
401
+ ],
402
+ },
403
+ ],
404
+ } as unknown as any;
405
+
406
+ const result = buildPrompt(context);
407
+ // Final user message has images -> returns ContentBlock[]
408
+ expect(Array.isArray(result)).toBe(true);
409
+ const arr = result as any[];
410
+ // Non-final user message should have placeholder in the history text
411
+ const textBlocks = arr.filter((b: any) => b.type === "text");
412
+ const historyText = textBlocks[0].text;
413
+ expect(historyText).toContain(
414
+ "[An image was shared here but could not be included]",
415
+ );
416
+ // Final user message image translated to Anthropic format
417
+ expect(arr).toContainEqual({
418
+ type: "image",
419
+ source: { type: "base64", media_type: "image/jpeg", data: "img2" },
420
+ });
421
+ // Console.warn called with count of placeholder images (1 placeholder)
422
+ expect(warnSpy).toHaveBeenCalledTimes(1);
423
+ expect(warnSpy.mock.calls[0][0]).toContain("1 image(s)");
424
+ warnSpy.mockRestore();
425
+ });
426
+
427
+ it("no images returns string (backward compatible)", () => {
428
+ const context = {
429
+ messages: [
430
+ { role: "user", content: "Hello" },
431
+ { role: "assistant", content: "Hi" },
432
+ { role: "user", content: "How are you?" },
433
+ ],
434
+ } as unknown as any;
435
+
436
+ const result = buildPrompt(context);
437
+ expect(typeof result).toBe("string");
438
+ expect(result).toContain("USER:");
439
+ expect(result).toContain("Hello");
440
+ });
441
+
442
+ it("image block without data/mimeType uses placeholder", () => {
443
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
444
+ const context = {
445
+ messages: [
446
+ {
447
+ role: "user",
448
+ content: [
449
+ { type: "text", text: "Broken image" },
450
+ { type: "image" }, // Missing data and mimeType
451
+ ],
452
+ },
453
+ ],
454
+ } as unknown as any;
455
+
456
+ const result = buildPrompt(context);
457
+ // Invalid image in final message should fall back to placeholder -> string
458
+ // The single user message IS the final message, but image is invalid
459
+ // so it falls back to placeholder text block
460
+ if (Array.isArray(result)) {
461
+ // If still returns array, image should be a text placeholder
462
+ const hasImageBlock = result.some((b: any) => b.type === "image");
463
+ expect(hasImageBlock).toBe(false);
464
+ } else {
465
+ expect(result).toContain(
466
+ "[An image was shared here but could not be included]",
467
+ );
468
+ }
469
+ expect(warnSpy).toHaveBeenCalled();
470
+ warnSpy.mockRestore();
471
+ });
472
+
473
+ it("buildPrompt with custom tool result prompt (images in earlier messages) returns string", () => {
474
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
475
+ const context = {
476
+ messages: [
477
+ {
478
+ role: "user",
479
+ content: [
480
+ { type: "text", text: "Run this" },
481
+ { type: "image", data: "imgdata", mimeType: "image/png" },
482
+ ],
483
+ },
484
+ {
485
+ role: "assistant",
486
+ content: [
487
+ {
488
+ type: "toolCall",
489
+ name: "deploy",
490
+ arguments: { target: "prod" },
491
+ },
492
+ ],
493
+ },
494
+ {
495
+ role: "toolResult",
496
+ content: "Deploy succeeded",
497
+ toolName: "deploy",
498
+ },
499
+ ],
500
+ } as unknown as any;
501
+
502
+ const result = buildPrompt(context);
503
+ // Custom tool result prompt always returns string
504
+ expect(typeof result).toBe("string");
505
+ warnSpy.mockRestore();
506
+ });
507
+ });
508
+
509
+ describe("tool result image handling", () => {
510
+ afterEach(() => {
511
+ vi.restoreAllMocks();
512
+ });
513
+
514
+ it("tool result with image block triggers ContentBlock[] path with image passthrough", () => {
515
+ const context = {
516
+ messages: [
517
+ {
518
+ role: "user",
519
+ content: "explain this image C:\\temp\\screenshot.png",
520
+ },
521
+ {
522
+ role: "assistant",
523
+ content: [
524
+ {
525
+ type: "toolCall",
526
+ name: "read",
527
+ arguments: { path: "C:\\temp\\screenshot.png" },
528
+ },
529
+ ],
530
+ },
531
+ {
532
+ role: "toolResult",
533
+ content: [
534
+ { type: "text", text: "Read image file [image/png]" },
535
+ { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
536
+ ],
537
+ toolName: "read",
538
+ },
539
+ { role: "user", content: "what does that code do?" },
540
+ ],
541
+ } as unknown as any;
542
+
543
+ const result = buildPrompt(context);
544
+ // Tool result has image -> should return ContentBlock[] so Claude sees the image
545
+ expect(Array.isArray(result)).toBe(true);
546
+ const arr = result as any[];
547
+ // Should contain the translated image block from tool result
548
+ expect(arr).toContainEqual({
549
+ type: "image",
550
+ source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" },
551
+ });
552
+ });
553
+
554
+ it("tool result image in string path gets placeholder text", () => {
555
+ const _warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
556
+ const context = {
557
+ messages: [
558
+ { role: "user", content: "read that file" },
559
+ {
560
+ role: "assistant",
561
+ content: [
562
+ {
563
+ type: "toolCall",
564
+ name: "read",
565
+ arguments: { path: "photo.jpg" },
566
+ },
567
+ ],
568
+ },
569
+ {
570
+ role: "toolResult",
571
+ content: [
572
+ { type: "text", text: "Read image file" },
573
+ { type: "image", data: "base64jpg", mimeType: "image/jpeg" },
574
+ ],
575
+ toolName: "read",
576
+ },
577
+ ],
578
+ } as unknown as any;
579
+
580
+ const result = buildPrompt(context);
581
+ // No images in final user message, no images in user content at all
582
+ // But tool result has image -> should still handle it
583
+ // At minimum: placeholder text so Claude knows image existed
584
+ if (typeof result === "string") {
585
+ expect(result).toContain(
586
+ "[An image was shared here but could not be included]",
587
+ );
588
+ } else {
589
+ // Or ContentBlock[] with actual image
590
+ expect(result).toContainEqual(expect.objectContaining({ type: "image" }));
591
+ }
592
+ });
593
+
594
+ it("tool result with only text blocks works as before", () => {
595
+ const context = {
596
+ messages: [
597
+ { role: "user", content: "read the file" },
598
+ {
599
+ role: "assistant",
600
+ content: [
601
+ { type: "toolCall", name: "read", arguments: { path: "test.txt" } },
602
+ ],
603
+ },
604
+ {
605
+ role: "toolResult",
606
+ content: [{ type: "text", text: "file contents here" }],
607
+ toolName: "read",
608
+ },
609
+ { role: "user", content: "summarize it" },
610
+ ],
611
+ } as unknown as any;
612
+
613
+ const result = buildPrompt(context);
614
+ // No images anywhere -> should return string
615
+ expect(typeof result).toBe("string");
616
+ expect(result).toContain("file contents here");
617
+ });
618
+ });
619
+
620
+ describe("custom tool history replay", () => {
621
+ it("toolCall with custom tool name 'deploy' uses plain format (no MCP prefix)", () => {
622
+ const context = {
623
+ messages: [
624
+ {
625
+ role: "assistant",
626
+ content: [
627
+ {
628
+ type: "toolCall",
629
+ name: "deploy",
630
+ arguments: { target: "prod" },
631
+ },
632
+ ],
633
+ },
634
+ ],
635
+ } as unknown as any;
636
+
637
+ const result = buildPrompt(context);
638
+ expect(result).toContain("[Used deploy tool with args:");
639
+ expect(result).toContain('"target":"prod"');
640
+ expect(result).not.toContain("mcp__custom-tools__");
641
+ });
642
+
643
+ it("toolCall with built-in name 'read' still produces 'Read' (not MCP-prefixed)", () => {
644
+ const context = {
645
+ messages: [
646
+ {
647
+ role: "assistant",
648
+ content: [
649
+ {
650
+ type: "toolCall",
651
+ name: "read",
652
+ arguments: { path: "/foo" },
653
+ },
654
+ ],
655
+ },
656
+ ],
657
+ } as unknown as any;
658
+
659
+ const result = buildPrompt(context);
660
+ expect(result).toContain("Read");
661
+ expect(result).not.toContain("mcp__custom-tools__read");
662
+ });
663
+
664
+ it("toolResult for custom tool 'deploy' uses plain name (no MCP prefix)", () => {
665
+ const context = {
666
+ messages: [
667
+ {
668
+ role: "toolResult",
669
+ content: "deployment succeeded",
670
+ toolName: "deploy",
671
+ },
672
+ ],
673
+ } as unknown as any;
674
+
675
+ const result = buildPrompt(context);
676
+ expect(result).toContain("TOOL RESULT (deploy):");
677
+ expect(result).not.toContain("mcp__custom-tools__");
678
+ });
679
+
680
+ it("toolResult for built-in 'read' still produces TOOL RESULT with Claude name", () => {
681
+ const context = {
682
+ messages: [
683
+ {
684
+ role: "toolResult",
685
+ content: "file contents",
686
+ toolName: "read",
687
+ },
688
+ ],
689
+ } as unknown as any;
690
+
691
+ const result = buildPrompt(context);
692
+ expect(result).toContain("TOOL RESULT (Read):");
693
+ expect(result).not.toContain("mcp__custom-tools__");
694
+ });
695
+
696
+ it("custom tool arguments pass through without translation", () => {
697
+ const context = {
698
+ messages: [
699
+ {
700
+ role: "assistant",
701
+ content: [
702
+ {
703
+ type: "toolCall",
704
+ name: "deploy",
705
+ arguments: { target: "prod", force: true },
706
+ },
707
+ ],
708
+ },
709
+ ],
710
+ } as unknown as any;
711
+
712
+ const result = buildPrompt(context);
713
+ // Custom tool args should pass through unchanged (no renames)
714
+ expect(result).toContain('"target":"prod"');
715
+ expect(result).toContain('"force":true');
716
+ });
717
+ });
718
+
719
+ describe("buildSystemPrompt", () => {
720
+ beforeEach(() => {
721
+ vi.resetModules();
722
+ });
723
+
724
+ afterEach(() => {
725
+ vi.restoreAllMocks();
726
+ });
727
+
728
+ it("returns context systemPrompt when no AGENTS.md found", async () => {
729
+ // Mock fs to not find any AGENTS.md
730
+ vi.doMock("node:fs", () => ({
731
+ existsSync: () => false,
732
+ readFileSync: () => "",
733
+ }));
734
+
735
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
736
+ const context = {
737
+ systemPrompt: "You are a helpful assistant.",
738
+ messages: [],
739
+ } as unknown as any;
740
+ const result = bsp(context, "/some/project/path");
741
+ expect(result).toContain("You are a helpful assistant.");
742
+ });
743
+
744
+ it("appends AGENTS.md content when found", async () => {
745
+ vi.doMock("node:fs", () => ({
746
+ existsSync: (path: string) => path.endsWith("AGENTS.md"),
747
+ readFileSync: () => "# Agent Instructions\nDo things carefully.",
748
+ }));
749
+
750
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
751
+ const context = {
752
+ systemPrompt: "Base prompt.",
753
+ messages: [],
754
+ } as unknown as any;
755
+ const result = bsp(context, "/some/project");
756
+ expect(result).toContain("Base prompt.");
757
+ expect(result).toContain("Agent Instructions");
758
+ expect(result).toContain("Do things carefully.");
759
+ });
760
+
761
+ it("sanitizes .pi references to .claude in AGENTS.md content", async () => {
762
+ vi.doMock("node:fs", () => ({
763
+ existsSync: (path: string) => path.endsWith("AGENTS.md"),
764
+ readFileSync: () => "Check ~/.pi/config and .pi/settings for details.",
765
+ }));
766
+
767
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
768
+ const context = { systemPrompt: "Base.", messages: [] } as unknown as any;
769
+ const result = bsp(context, "/some/project");
770
+ expect(result).toContain("~/.claude");
771
+ expect(result).toContain(".claude/settings");
772
+ expect(result).not.toContain(".pi/");
773
+ });
774
+
775
+ it("returns empty string when no systemPrompt and no AGENTS.md", async () => {
776
+ vi.doMock("node:fs", () => ({
777
+ existsSync: () => false,
778
+ readFileSync: () => "",
779
+ }));
780
+
781
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
782
+ const context = { messages: [] } as unknown as any;
783
+ const result = bsp(context, "/some/project");
784
+ expect(result).toBe("");
785
+ });
786
+
787
+ it("walks up directories to find AGENTS.md in parent", async () => {
788
+ // Only the parent directory's AGENTS.md exists, not the cwd
789
+ vi.doMock("node:fs", () => ({
790
+ existsSync: (path: string) => {
791
+ // Only parent path has AGENTS.md
792
+ if (path.includes("parent") && path.endsWith("AGENTS.md")) return true;
793
+ return false;
794
+ },
795
+ readFileSync: () => "# Parent AGENTS.md\nInstructions from parent.",
796
+ }));
797
+ vi.doMock("node:path", async () => {
798
+ const actual =
799
+ await vi.importActual<typeof import("node:path")>("node:path");
800
+ return {
801
+ ...actual,
802
+ resolve: (p: string) => p,
803
+ join: (...args: string[]) => args.join("/"),
804
+ dirname: (p: string) => {
805
+ // Simulate walking up: /a/b/parent/child -> /a/b/parent -> /a/b -> etc.
806
+ const parts = p.split("/").filter(Boolean);
807
+ if (parts.length <= 1) return p; // root
808
+ return "/" + parts.slice(0, -1).join("/");
809
+ },
810
+ };
811
+ });
812
+
813
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
814
+ const context = {
815
+ systemPrompt: "Base.",
816
+ messages: [],
817
+ } as unknown as any;
818
+ const result = bsp(context, "/a/b/parent/child");
819
+ expect(result).toContain("Parent AGENTS.md");
820
+ expect(result).toContain("Instructions from parent.");
821
+ });
822
+
823
+ it("sanitizes empty AGENTS.md content gracefully", async () => {
824
+ vi.doMock("node:fs", () => ({
825
+ existsSync: (path: string) => path.endsWith("AGENTS.md"),
826
+ readFileSync: () => "",
827
+ }));
828
+
829
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
830
+ const context = {
831
+ systemPrompt: "Base prompt.",
832
+ messages: [],
833
+ } as unknown as any;
834
+ const result = bsp(context, "/some/project");
835
+ // Empty AGENTS.md content should just produce the base prompt
836
+ expect(result).toContain("Base prompt.");
837
+ });
838
+
839
+ it("sanitizes AGENTS.md with only whitespace content", async () => {
840
+ vi.doMock("node:fs", () => ({
841
+ existsSync: (path: string) => path.endsWith("AGENTS.md"),
842
+ readFileSync: () => " \n\n \t \n",
843
+ }));
844
+
845
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
846
+ const context = {
847
+ systemPrompt: "Base.",
848
+ messages: [],
849
+ } as unknown as any;
850
+ const result = bsp(context, "/some/project");
851
+ expect(result).toContain("Base.");
852
+ });
853
+
854
+ it("sanitizes AGENTS.md with special regex characters", async () => {
855
+ vi.doMock("node:fs", () => ({
856
+ existsSync: (path: string) => path.endsWith("AGENTS.md"),
857
+ readFileSync: () =>
858
+ "Config at ~/.pi/settings.json and .pi/rules/*.md files.",
859
+ }));
860
+
861
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
862
+ const context = {
863
+ systemPrompt: "",
864
+ messages: [],
865
+ } as unknown as any;
866
+ const result = bsp(context, "/some/project");
867
+ expect(result).toContain("~/.claude/settings.json");
868
+ expect(result).toContain(".claude/rules/*.md");
869
+ expect(result).not.toContain(".pi/");
870
+ });
871
+
872
+ it("handles readFileSync error gracefully (skip silently)", async () => {
873
+ vi.doMock("node:fs", () => ({
874
+ existsSync: (path: string) => path.endsWith("AGENTS.md"),
875
+ readFileSync: () => {
876
+ throw new Error("EACCES: permission denied");
877
+ },
878
+ }));
879
+
880
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
881
+ const context = {
882
+ systemPrompt: "Base prompt.",
883
+ messages: [],
884
+ } as unknown as any;
885
+ const result = bsp(context, "/some/project");
886
+ // Should still return the base prompt despite read error
887
+ expect(result).toContain("Base prompt.");
888
+ // Should not include any AGENTS.md content
889
+ expect(result).not.toContain("EACCES");
890
+ });
891
+
892
+ it("appends tool result instruction when messages contain toolResult", async () => {
893
+ vi.doMock("node:fs", () => ({
894
+ existsSync: () => false,
895
+ readFileSync: () => "",
896
+ }));
897
+
898
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
899
+ const context = {
900
+ systemPrompt: "Base prompt.",
901
+ messages: [
902
+ { role: "user", content: "read the file" },
903
+ {
904
+ role: "toolResult",
905
+ content: "file contents here",
906
+ toolName: "read",
907
+ },
908
+ ],
909
+ } as unknown as any;
910
+ const result = bsp(context, "/some/project");
911
+ expect(result).toContain("IMPORTANT:");
912
+ expect(result).toContain("tool results");
913
+ });
914
+
915
+ it("rewrites bare custom tool references to MCP-prefixed names", async () => {
916
+ vi.resetModules();
917
+ vi.doMock("node:fs", () => ({
918
+ existsSync: () => false,
919
+ readFileSync: () => "",
920
+ }));
921
+
922
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
923
+ const context = {
924
+ systemPrompt:
925
+ "Write the PROMPT.md, then call `fn_review_spec()` for review. " +
926
+ "If REVISE, call fn_review_spec again. Do not call mcp__custom-tools__fn_review_spec twice manually.",
927
+ messages: [],
928
+ tools: [
929
+ { name: "fn_review_spec", description: "review", parameters: {} },
930
+ { name: "read", description: "builtin", parameters: {} },
931
+ ],
932
+ } as unknown as any;
933
+ const result = bsp(context, "/some/project");
934
+
935
+ // Bare name occurrences are rewritten
936
+ expect(result).toContain(
937
+ "call `mcp__custom-tools__fn_review_spec()` for review",
938
+ );
939
+ expect(result).toContain(
940
+ "call mcp__custom-tools__fn_review_spec again",
941
+ );
942
+ // Already-prefixed occurrence is not double-prefixed
943
+ expect(result).not.toContain(
944
+ "mcp__custom-tools__mcp__custom-tools__fn_review_spec",
945
+ );
946
+ // Built-in pi tool names are not rewritten
947
+ expect(result).not.toContain("mcp__custom-tools__read");
948
+ // The addendum still lists the custom tool with its full mapping
949
+ expect(result).toContain("mcp__custom-tools__fn_review_spec");
950
+ });
951
+
952
+ it("treats ls as custom and rewrites to mcp__custom-tools__ls", async () => {
953
+ vi.resetModules();
954
+ vi.doMock("node:fs", () => ({
955
+ existsSync: () => false,
956
+ readFileSync: () => "",
957
+ }));
958
+
959
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
960
+ const context = {
961
+ systemPrompt: "List files with ls, then continue.",
962
+ messages: [],
963
+ tools: [
964
+ { name: "ls", description: "list directory", parameters: {} },
965
+ { name: "read", description: "builtin", parameters: {} },
966
+ ],
967
+ } as unknown as any;
968
+
969
+ const result = bsp(context, "/some/project");
970
+ expect(result).toContain("mcp__custom-tools__ls");
971
+ expect(result).not.toContain("mcp__custom-tools__read");
972
+ });
973
+
974
+ it("custom tools addendum instructs direct MCP calls without ToolSearch prerequisite", async () => {
975
+ vi.resetModules();
976
+ vi.doMock("node:fs", () => ({
977
+ existsSync: () => false,
978
+ readFileSync: () => "",
979
+ }));
980
+
981
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
982
+ const context = {
983
+ systemPrompt: "Use tools as needed.",
984
+ messages: [],
985
+ tools: [{ name: "fn_task_list", description: "list", parameters: {} }],
986
+ } as unknown as any;
987
+
988
+ const result = bsp(context, "/some/project");
989
+ expect(result).toContain("mcp__custom-tools__fn_task_list");
990
+ expect(result).not.toContain("ToolSearch");
991
+ });
992
+
993
+ it("does not rewrite identifier substrings that happen to overlap a tool name", async () => {
994
+ vi.resetModules();
995
+ vi.doMock("node:fs", () => ({
996
+ existsSync: () => false,
997
+ readFileSync: () => "",
998
+ }));
999
+
1000
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
1001
+ const context = {
1002
+ systemPrompt: "fn_review_specifier and fn_reviews are not the tool.",
1003
+ messages: [],
1004
+ tools: [
1005
+ { name: "fn_review", description: "x", parameters: {} },
1006
+ { name: "fn_review_spec", description: "y", parameters: {} },
1007
+ ],
1008
+ } as unknown as any;
1009
+ const result = bsp(context, "/some/project");
1010
+ // Neither substring should be rewritten — they're different identifiers.
1011
+ expect(result).toContain("fn_review_specifier");
1012
+ expect(result).toContain("fn_reviews");
1013
+ expect(result).not.toContain("mcp__custom-tools__fn_review_specifier");
1014
+ expect(result).not.toContain("mcp__custom-tools__fn_reviews");
1015
+ });
1016
+ });
1017
+
1018
+ describe("buildResumePrompt", () => {
1019
+ it("returns empty string for empty messages array", () => {
1020
+ expect(buildResumePrompt({ messages: [] })).toBe("");
1021
+ });
1022
+
1023
+ it("returns just the user message text for a single user message", () => {
1024
+ const context = {
1025
+ messages: [{ role: "user", content: "Hello world" }],
1026
+ };
1027
+ expect(buildResumePrompt(context)).toBe("Hello world");
1028
+ });
1029
+
1030
+ it("extracts only the last user message from a multi-turn conversation", () => {
1031
+ const context = {
1032
+ messages: [
1033
+ { role: "user", content: "First question" },
1034
+ { role: "assistant", content: "First answer" },
1035
+ { role: "user", content: "Follow-up question" },
1036
+ ],
1037
+ };
1038
+ expect(buildResumePrompt(context)).toBe("Follow-up question");
1039
+ });
1040
+
1041
+ it("includes tool results preceding the final user message", () => {
1042
+ const context = {
1043
+ messages: [
1044
+ { role: "user", content: "Read a file" },
1045
+ {
1046
+ role: "assistant",
1047
+ content: [
1048
+ {
1049
+ type: "toolCall",
1050
+ name: "read",
1051
+ arguments: { path: "/foo.ts" },
1052
+ },
1053
+ ],
1054
+ },
1055
+ {
1056
+ role: "toolResult",
1057
+ toolName: "read",
1058
+ content: "file contents here",
1059
+ },
1060
+ { role: "user", content: "Now explain it" },
1061
+ ],
1062
+ };
1063
+ const result = buildResumePrompt(context) as string;
1064
+ expect(result).toContain("TOOL RESULT (Read):");
1065
+ expect(result).toContain("file contents here");
1066
+ expect(result).toContain("Now explain it");
1067
+ });
1068
+
1069
+ it("includes multiple tool results preceding the final user message", () => {
1070
+ const context = {
1071
+ messages: [
1072
+ { role: "user", content: "Read two files" },
1073
+ {
1074
+ role: "assistant",
1075
+ content: [
1076
+ {
1077
+ type: "toolCall",
1078
+ name: "read",
1079
+ arguments: { path: "/a.ts" },
1080
+ },
1081
+ {
1082
+ type: "toolCall",
1083
+ name: "read",
1084
+ arguments: { path: "/b.ts" },
1085
+ },
1086
+ ],
1087
+ },
1088
+ {
1089
+ role: "toolResult",
1090
+ toolName: "read",
1091
+ content: "contents of a",
1092
+ },
1093
+ {
1094
+ role: "toolResult",
1095
+ toolName: "read",
1096
+ content: "contents of b",
1097
+ },
1098
+ { role: "user", content: "Compare them" },
1099
+ ],
1100
+ };
1101
+ const result = buildResumePrompt(context) as string;
1102
+ expect(result).toContain("contents of a");
1103
+ expect(result).toContain("contents of b");
1104
+ expect(result).toContain("Compare them");
1105
+ });
1106
+
1107
+ it("handles custom tool results with plain name format", () => {
1108
+ const context = {
1109
+ messages: [
1110
+ { role: "user", content: "Deploy" },
1111
+ {
1112
+ role: "assistant",
1113
+ content: [{ type: "toolCall", name: "deploy", arguments: {} }],
1114
+ },
1115
+ {
1116
+ role: "toolResult",
1117
+ toolName: "deploy",
1118
+ content: "Deployed successfully",
1119
+ },
1120
+ { role: "user", content: "Check status" },
1121
+ ],
1122
+ };
1123
+ const result = buildResumePrompt(context) as string;
1124
+ expect(result).toContain("TOOL RESULT (deploy):");
1125
+ expect(result).toContain("Deployed successfully");
1126
+ expect(result).toContain("Check status");
1127
+ });
1128
+
1129
+ it("returns empty string when no user message found", () => {
1130
+ const context = {
1131
+ messages: [{ role: "assistant", content: "Hello" }],
1132
+ };
1133
+ expect(buildResumePrompt(context)).toBe("");
1134
+ });
1135
+
1136
+ it("handles content blocks array for user message", () => {
1137
+ const context = {
1138
+ messages: [
1139
+ {
1140
+ role: "user",
1141
+ content: [{ type: "text", text: "Hello from blocks" }],
1142
+ },
1143
+ ],
1144
+ };
1145
+ expect(buildResumePrompt(context)).toBe("Hello from blocks");
1146
+ });
1147
+
1148
+ // Regression: multi-iteration tool loops re-anchor on the LAST assistant
1149
+ // turn, not the (only) user message at index 0. Previously this dumped the
1150
+ // entire transcript into a "user" prompt every iteration, ballooning the
1151
+ // resumed session.
1152
+ it("returns ONLY the trailing tool result during a multi-iteration tool loop", () => {
1153
+ const context = {
1154
+ messages: [
1155
+ { role: "user", content: "Find foo" },
1156
+ {
1157
+ role: "assistant",
1158
+ content: [{ type: "toolCall", name: "find", arguments: { pattern: "foo" } }],
1159
+ },
1160
+ { role: "toolResult", toolName: "find", content: "no matches (turn 1)" },
1161
+ {
1162
+ role: "assistant",
1163
+ content: [{ type: "toolCall", name: "find", arguments: { pattern: "foo" } }],
1164
+ },
1165
+ { role: "toolResult", toolName: "find", content: "no matches (turn 2)" },
1166
+ ],
1167
+ };
1168
+ const result = buildResumePrompt(context) as string;
1169
+ expect(result).toContain("no matches (turn 2)");
1170
+ expect(result).not.toContain("no matches (turn 1)");
1171
+ expect(result).not.toContain("Find foo");
1172
+ });
1173
+
1174
+ it("returns empty string mid-loop when only an assistant turn exists since the last delta", () => {
1175
+ const context = {
1176
+ messages: [
1177
+ { role: "user", content: "Hi" },
1178
+ { role: "assistant", content: "Working..." },
1179
+ ],
1180
+ };
1181
+ expect(buildResumePrompt(context)).toBe("");
1182
+ });
1183
+
1184
+ it("handles images in the final user message by returning ContentBlock[]", () => {
1185
+ const context = {
1186
+ messages: [
1187
+ {
1188
+ role: "user",
1189
+ content: [
1190
+ { type: "text", text: "Look at this" },
1191
+ {
1192
+ type: "image",
1193
+ data: "abc123",
1194
+ mimeType: "image/png",
1195
+ },
1196
+ ],
1197
+ },
1198
+ ],
1199
+ };
1200
+ const result = buildResumePrompt(context);
1201
+ expect(Array.isArray(result)).toBe(true);
1202
+ expect((result as any[]).length).toBe(2);
1203
+ expect((result as any[])[0].type).toBe("text");
1204
+ expect((result as any[])[1].type).toBe("image");
1205
+ });
1206
+ });