@nmzpy/pi-ember-stack 0.1.6 → 0.2.2

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 (36) hide show
  1. package/README.md +83 -83
  2. package/package.json +62 -48
  3. package/plugins/devin-auth/extensions/index.ts +68 -7
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/models.ts +42 -196
  11. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  12. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  13. package/plugins/devin-auth/src/stream.ts +1 -1
  14. package/plugins/index.ts +29 -6
  15. package/plugins/pi-compact-tools/index.ts +35 -192
  16. package/plugins/pi-compact-tools/renderer.ts +589 -0
  17. package/plugins/pi-custom-agents/index.ts +727 -373
  18. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  19. package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
  20. package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
  21. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  22. package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
  23. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  24. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  25. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
  26. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  27. package/plugins/pi-ember-fff/index.ts +975 -0
  28. package/plugins/pi-ember-fff/query.ts +247 -0
  29. package/plugins/pi-ember-fff/test/query.test.ts +222 -0
  30. package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
  31. package/plugins/pi-ember-tps/index.ts +144 -0
  32. package/plugins/pi-ember-ui/ember.json +99 -0
  33. package/plugins/pi-ember-ui/index.ts +805 -0
  34. package/plugins/pi-ember-ui/mode-colors.ts +196 -0
  35. package/tsconfig.json +2 -1
  36. package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
@@ -0,0 +1,727 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { CompactRenderer, formatCallBody, DISCOVERY_TOOLS } from "../../pi-compact-tools/renderer.ts";
3
+
4
+ function makeTheme() {
5
+ const fg = mock((tag: string, text: string) => `[${tag}:${text}]`);
6
+ return {
7
+ fg,
8
+ bold: mock((s: string) => `*${s}*`),
9
+ };
10
+ }
11
+
12
+ function makeContext(id: string, state: Record<string, any> = {}) {
13
+ return {
14
+ args: {},
15
+ toolCallId: id,
16
+ invalidate: mock(() => {}),
17
+ state,
18
+ };
19
+ }
20
+
21
+ describe("CompactRenderer", () => {
22
+ test("beginTurn alone does not reset grouping — thinking-only turns stay grouped", () => {
23
+ const r = new CompactRenderer();
24
+ const theme = makeTheme() as any;
25
+ const ctx1 = makeContext("a");
26
+ r.renderCall("read", { file_path: "foo.ts" }, theme, ctx1 as any);
27
+ r.beginTurn();
28
+ // No visible text in this turn — discovery calls should join the previous group
29
+ const ctx2 = makeContext("b");
30
+ r.renderCall("read", { file_path: "bar.ts" }, theme, ctx2 as any);
31
+ const record = (r as any).calls.get("b");
32
+ expect(record.group).toBeDefined();
33
+ expect(record.group).toBe((r as any).calls.get("a").group);
34
+ });
35
+
36
+ test("visible text before discovery calls resets grouping across turns", () => {
37
+ const r = new CompactRenderer();
38
+ const theme = makeTheme() as any;
39
+ const ctx1 = makeContext("a");
40
+ r.renderCall("read", { file_path: "foo.ts" }, theme, ctx1 as any);
41
+ r.beginTurn();
42
+ // Simulate visible text output in the new turn
43
+ r.noteVisibleText();
44
+ const ctx2 = makeContext("b");
45
+ r.renderCall("read", { file_path: "bar.ts" }, theme, ctx2 as any);
46
+ const record = (r as any).calls.get("b");
47
+ expect(record.group).toBeUndefined();
48
+ });
49
+
50
+ test("discovery group within a turn persists after an intervening non-discovery tool", () => {
51
+ const r = new CompactRenderer();
52
+ const theme = makeTheme() as any;
53
+ const ownerContext = makeContext("a");
54
+ const memberContext = makeContext("b");
55
+
56
+ r.renderCall("read", { file_path: "a.ts" }, theme, ownerContext as any);
57
+ r.renderResult(
58
+ "read",
59
+ { file_path: "a.ts" },
60
+ { content: [{ type: "text", text: "ok" }] },
61
+ { isPartial: false },
62
+ theme,
63
+ { ...ownerContext, isError: false } as any,
64
+ );
65
+
66
+ // Same turn: an edit intervenes, then another discovery call joins the same group
67
+ r.renderCall("edit", { file_path: "x.ts", oldText: "a", newText: "b" }, theme, makeContext("edit") as any);
68
+ r.renderCall("grep", { pattern: "foo", path: "src/" }, theme, memberContext as any);
69
+
70
+ const owner = r.renderCall("read", { file_path: "a.ts" }, theme, ownerContext as any) as any;
71
+ expect(owner.text).toContain("Exploring");
72
+ expect(owner.text).toContain("a.ts");
73
+ expect(owner.text).toContain("foo");
74
+ expect(owner.text.match(/•/g)?.length).toBe(1);
75
+
76
+ r.renderResult(
77
+ "grep",
78
+ { pattern: "foo", path: "src/" },
79
+ { content: [{ type: "text", text: "match" }], details: { totalMatched: 1 } },
80
+ { isPartial: false },
81
+ theme,
82
+ { ...memberContext, isError: false } as any,
83
+ );
84
+ const settledOwner = r.renderCall("read", { file_path: "a.ts" }, theme, ownerContext as any) as any;
85
+ expect(settledOwner.text).toContain("Explored");
86
+ });
87
+
88
+ test("consecutive discovery tools group together", () => {
89
+ const r = new CompactRenderer();
90
+ const theme = makeTheme() as any;
91
+ const ctx1 = makeContext("a");
92
+ const ctx2 = makeContext("b");
93
+ r.renderCall("read", { file_path: "a.ts" }, theme, ctx1 as any);
94
+ r.renderCall("grep", { pattern: "foo" }, theme, ctx2 as any);
95
+ const recA = (r as any).calls.get("a");
96
+ const recB = (r as any).calls.get("b");
97
+ expect(recA.group).toBeDefined();
98
+ expect(recB.group).toBe(recA.group);
99
+ expect(recA.group.records.length).toBe(2);
100
+ });
101
+
102
+ test("grouped Search stays visible without child bullets", () => {
103
+ const r = new CompactRenderer();
104
+ const theme = makeTheme() as any;
105
+ const readContext = makeContext("a");
106
+ const searchContext = makeContext("b");
107
+ r.renderCall("read", { file_path: "a.ts" }, theme, readContext as any);
108
+ r.renderCall("grep", { pattern: "foo", path: "src/" }, theme, searchContext as any);
109
+ // The owner (A, the first call) renders the full group
110
+ const groupCall = r.renderCall("read", { file_path: "a.ts" }, theme, readContext as any) as any;
111
+
112
+ expect(groupCall.text).toContain("Exploring");
113
+ expect(groupCall.text).toContain("Search");
114
+ expect(groupCall.text).toContain("Read");
115
+ expect(groupCall.text.match(/•/g)?.length).toBe(1);
116
+ });
117
+
118
+ test("preflight grouping does not hide an already-rendered Search call", () => {
119
+ const r = new CompactRenderer();
120
+ const theme = makeTheme() as any;
121
+ const searchContext = makeContext("a");
122
+ r.renderCall("grep", { pattern: "foo" }, theme, searchContext as any);
123
+ r.registerCall("read", "b", { file_path: "a.ts" });
124
+
125
+ const searchRecord = (r as any).calls.get("a");
126
+ expect(searchRecord.group.renderOwner).toBe(searchRecord);
127
+ // Re-render the owner (A) — it renders the group including Search
128
+ const groupCall = r.renderCall(
129
+ "grep",
130
+ { pattern: "foo" },
131
+ theme,
132
+ searchContext as any,
133
+ ) as any;
134
+ expect(groupCall.text).toContain("Search");
135
+ });
136
+
137
+ test("non-discovery tools do not group", () => {
138
+ const r = new CompactRenderer();
139
+ const theme = makeTheme() as any;
140
+ r.renderCall("bash", { command: "ls" }, theme, makeContext("a") as any);
141
+ r.renderCall("edit", { file_path: "x.ts" }, theme, makeContext("b") as any);
142
+ const recA = (r as any).calls.get("a");
143
+ const recB = (r as any).calls.get("b");
144
+ expect(recA.group).toBeUndefined();
145
+ expect(recB.group).toBeUndefined();
146
+ });
147
+
148
+ test("renderResult returns empty for bash on success", () => {
149
+ const r = new CompactRenderer();
150
+ const theme = makeTheme() as any;
151
+ r.renderCall("bash", { command: "echo hi" }, theme, makeContext("a") as any);
152
+ const comp = r.renderResult(
153
+ "bash",
154
+ { command: "echo hi" },
155
+ { content: [{ type: "text", text: "hi" }] },
156
+ { isPartial: false },
157
+ theme,
158
+ { ...makeContext("a"), isError: false } as any,
159
+ );
160
+ // Compact: the result component itself is empty, but the call row is updated.
161
+ expect((comp as any).text).toBe("");
162
+ });
163
+
164
+ test("bash success renders last output line on the call row", () => {
165
+ const r = new CompactRenderer();
166
+ const theme = makeTheme() as any;
167
+ const state: Record<string, any> = {};
168
+ const call = r.renderCall("bash", { command: "echo hi" }, theme, makeContext("a", state) as any);
169
+ r.renderResult(
170
+ "bash",
171
+ { command: "echo hi" },
172
+ { content: [{ type: "text", text: "first\nsecond\nOK" }] },
173
+ { isPartial: false },
174
+ theme,
175
+ { ...makeContext("a", state), isError: false } as any,
176
+ );
177
+ expect((call as any).text).toContain("Run");
178
+ expect((call as any).text).toContain("OK");
179
+ expect((call as any).text).not.toContain("first");
180
+ expect((call as any).text).not.toContain("second");
181
+ });
182
+
183
+ test("bash output skips trailing blank lines", () => {
184
+ const r = new CompactRenderer();
185
+ const theme = makeTheme() as any;
186
+ const state: Record<string, any> = {};
187
+ const call = r.renderCall("bash", { command: "echo hi" }, theme, makeContext("a", state) as any);
188
+ r.renderResult(
189
+ "bash",
190
+ { command: "echo hi" },
191
+ { content: [{ type: "text", text: "OK\n\n\n" }] },
192
+ { isPartial: false },
193
+ theme,
194
+ { ...makeContext("a", state), isError: false } as any,
195
+ );
196
+ expect((call as any).text).toContain("OK");
197
+ expect((call as any).text).not.toContain("\n\n");
198
+ });
199
+
200
+ test("bash empty output does not add a result line", () => {
201
+ const r = new CompactRenderer();
202
+ const theme = makeTheme() as any;
203
+ const state: Record<string, any> = {};
204
+ const call = r.renderCall("bash", { command: ":" }, theme, makeContext("a", state) as any);
205
+ r.renderResult(
206
+ "bash",
207
+ { command: ":" },
208
+ { content: [{ type: "text", text: "" }] },
209
+ { isPartial: false },
210
+ theme,
211
+ { ...makeContext("a", state), isError: false } as any,
212
+ );
213
+ expect((call as any).text).toContain("Run");
214
+ expect((call as any).text).not.toContain(" ");
215
+ });
216
+
217
+ test("bash partial result does not render output yet", () => {
218
+ const r = new CompactRenderer();
219
+ const theme = makeTheme() as any;
220
+ const state: Record<string, any> = {};
221
+ const call = r.renderCall("bash", { command: "sleep 1" }, theme, makeContext("a", state) as any);
222
+ r.renderResult(
223
+ "bash",
224
+ { command: "sleep 1" },
225
+ { content: [{ type: "text", text: "intermediate" }] },
226
+ { isPartial: true },
227
+ theme,
228
+ { ...makeContext("a", state), isError: false } as any,
229
+ );
230
+ expect((call as any).text).toContain("Run");
231
+ expect((call as any).text).not.toContain("intermediate");
232
+ });
233
+
234
+ test("renderResult shows error text on failure", () => {
235
+ const r = new CompactRenderer();
236
+ const theme = makeTheme() as any;
237
+ r.renderCall("read", { file_path: "bad.ts" }, theme, makeContext("a") as any);
238
+ const comp = r.renderResult(
239
+ "read",
240
+ { file_path: "bad.ts" },
241
+ { content: [{ type: "text", text: "Error: not found" }] },
242
+ { isPartial: false },
243
+ theme,
244
+ { ...makeContext("a"), isError: true } as any,
245
+ );
246
+ expect((comp as any).text).toContain("Error: not found");
247
+ });
248
+
249
+ test("read never dumps file content on success", () => {
250
+ const r = new CompactRenderer();
251
+ const theme = makeTheme() as any;
252
+ r.renderCall("read", { file_path: "big.ts" }, theme, makeContext("a") as any);
253
+ const fileContent = "line1\nline2\nline3\n".repeat(100);
254
+ const comp = r.renderResult(
255
+ "read",
256
+ { file_path: "big.ts" },
257
+ { content: [{ type: "text", text: fileContent }] },
258
+ { isPartial: false },
259
+ theme,
260
+ { ...makeContext("a"), isError: false } as any,
261
+ );
262
+ // Result must be empty — the call row shows the compact summary.
263
+ expect((comp as any).text).toBe("");
264
+ expect((comp as any).text).not.toContain("line1");
265
+ });
266
+
267
+ test("bash calls with same cd dir group within a turn", () => {
268
+ const r = new CompactRenderer();
269
+ const theme = makeTheme() as any;
270
+ r.renderCall("bash", { command: "cd src && ls" }, theme, makeContext("a") as any);
271
+ r.renderCall("bash", { command: "cd src && pwd" }, theme, makeContext("b") as any);
272
+ const recA = (r as any).calls.get("a");
273
+ const recB = (r as any).calls.get("b");
274
+ expect(recA.group).toBeDefined();
275
+ expect(recB.group).toBe(recA.group);
276
+ });
277
+
278
+ test("bash calls with same cd dir do NOT group across turns after visible text", () => {
279
+ const r = new CompactRenderer();
280
+ const theme = makeTheme() as any;
281
+ r.renderCall("bash", { command: "cd src && ls" }, theme, makeContext("a") as any);
282
+ r.beginTurn();
283
+ r.noteVisibleText();
284
+ r.renderCall("bash", { command: "cd src && pwd" }, theme, makeContext("b") as any);
285
+ const recB = (r as any).calls.get("b");
286
+ expect(recB.group).toBeUndefined();
287
+ });
288
+
289
+ test("DISCOVERY_TOOLS includes grep and find", () => {
290
+ expect(DISCOVERY_TOOLS.has("grep")).toBe(true);
291
+ expect(DISCOVERY_TOOLS.has("find")).toBe(true);
292
+ expect(DISCOVERY_TOOLS.has("read")).toBe(true);
293
+ expect(DISCOVERY_TOOLS.has("ls")).toBe(true);
294
+ });
295
+
296
+ test("formatCallBody handles grep", () => {
297
+ const theme = makeTheme() as any;
298
+ const result = formatCallBody("grep", { pattern: "MyClass", path: "src/" }, theme);
299
+ expect(result).toContain("Search");
300
+ expect(result).toContain("MyClass");
301
+ expect(result).toContain("src/");
302
+ });
303
+
304
+ test("formatCallBody handles find", () => {
305
+ const theme = makeTheme() as any;
306
+ const result = formatCallBody("find", { pattern: "main", path: "." }, theme);
307
+ expect(result).toContain("Find");
308
+ expect(result).toContain("main");
309
+ });
310
+
311
+ test("renderOwner stays on first call", () => {
312
+ const r = new CompactRenderer();
313
+ const theme = makeTheme() as any;
314
+ const ctx1 = makeContext("a");
315
+ const ctx2 = makeContext("b");
316
+ r.renderCall("read", { file_path: "a.ts" }, theme, ctx1 as any);
317
+ r.renderCall("read", { file_path: "b.ts" }, theme, ctx2 as any);
318
+ const recA = (r as any).calls.get("a");
319
+ const recB = (r as any).calls.get("b");
320
+ // First member A stays the renderOwner; B joining does NOT migrate ownership
321
+ expect(recA.group.renderOwner).toBe(recA);
322
+ expect(recA.group.renderOwner).not.toBe(recB);
323
+ });
324
+
325
+ test("non-owner grouped call renders empty; owner renders group", () => {
326
+ const r = new CompactRenderer();
327
+ const theme = makeTheme() as any;
328
+ const ctx1 = makeContext("a");
329
+ const ctx2 = makeContext("b");
330
+ r.renderCall("read", { file_path: "a.ts" }, theme, ctx1 as any);
331
+ r.renderCall("read", { file_path: "b.ts" }, theme, ctx2 as any);
332
+ // Re-rendering the non-owner B returns empty
333
+ const compB2 = r.renderCall("read", { file_path: "b.ts" }, theme, ctx2 as any);
334
+ expect((compB2 as any).text).toBe("");
335
+ // Re-rendering the owner A renders the full group with both children
336
+ const compA2 = r.renderCall("read", { file_path: "a.ts" }, theme, ctx1 as any);
337
+ expect((compA2 as any).text).toContain("Exploring");
338
+ expect((compA2 as any).text).toContain("a.ts");
339
+ expect((compA2 as any).text).toContain("b.ts");
340
+ });
341
+
342
+ test("expanded bash shows full output", () => {
343
+ const r = new CompactRenderer();
344
+ const theme = makeTheme() as any;
345
+ const state: Record<string, any> = {};
346
+ r.renderCall("bash", { command: "echo hi" }, theme, makeContext("a", state) as any);
347
+ const comp = r.renderResult(
348
+ "bash",
349
+ { command: "echo hi" },
350
+ { content: [{ type: "text", text: "line1\nline2\nline3" }] },
351
+ { isPartial: false, expanded: true },
352
+ theme,
353
+ { ...makeContext("a", state), isError: false } as any,
354
+ );
355
+ expect((comp as any).text).toContain("line1");
356
+ expect((comp as any).text).toContain("line2");
357
+ expect((comp as any).text).toContain("line3");
358
+ });
359
+
360
+ test("collapsed bash shows only last line", () => {
361
+ const r = new CompactRenderer();
362
+ const theme = makeTheme() as any;
363
+ const state: Record<string, any> = {};
364
+ const call = r.renderCall("bash", { command: "echo hi" }, theme, makeContext("a", state) as any);
365
+ r.renderResult(
366
+ "bash",
367
+ { command: "echo hi" },
368
+ { content: [{ type: "text", text: "line1\nline2\nline3" }] },
369
+ { isPartial: false, expanded: false },
370
+ theme,
371
+ { ...makeContext("a", state), isError: false } as any,
372
+ );
373
+ expect((call as any).text).toContain("line3");
374
+ expect((call as any).text).not.toContain("line1");
375
+ expect((call as any).text).not.toContain("line2");
376
+ });
377
+
378
+ test("expanded read shows full file content", () => {
379
+ const r = new CompactRenderer();
380
+ const theme = makeTheme() as any;
381
+ r.renderCall("read", { file_path: "big.ts" }, theme, makeContext("a") as any);
382
+ const fileContent = "line1\nline2\nline3\nline4\nline5";
383
+ const comp = r.renderResult(
384
+ "read",
385
+ { file_path: "big.ts" },
386
+ { content: [{ type: "text", text: fileContent }] },
387
+ { isPartial: false, expanded: true },
388
+ theme,
389
+ { ...makeContext("a"), isError: false } as any,
390
+ );
391
+ expect((comp as any).text).toContain("line1");
392
+ expect((comp as any).text).toContain("line2");
393
+ expect((comp as any).text).toContain("line5");
394
+ });
395
+
396
+ test("collapsed read hides file content", () => {
397
+ const r = new CompactRenderer();
398
+ const theme = makeTheme() as any;
399
+ r.renderCall("read", { file_path: "big.ts" }, theme, makeContext("a") as any);
400
+ const fileContent = "line1\nline2\nline3\nline4\nline5";
401
+ const comp = r.renderResult(
402
+ "read",
403
+ { file_path: "big.ts" },
404
+ { content: [{ type: "text", text: fileContent }] },
405
+ { isPartial: false, expanded: false },
406
+ theme,
407
+ { ...makeContext("a"), isError: false } as any,
408
+ );
409
+ expect((comp as any).text).toBe("");
410
+ });
411
+
412
+ test("expanded grep shows all match lines", () => {
413
+ const r = new CompactRenderer();
414
+ const theme = makeTheme() as any;
415
+ const state: Record<string, any> = {};
416
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("a", state) as any);
417
+ const comp = r.renderResult(
418
+ "grep",
419
+ { pattern: "foo" },
420
+ { content: [{ type: "text", text: "a.ts:1:foo\nb.ts:2:foo\nc.ts:3:foo" }], details: { totalMatched: 3 } },
421
+ { isPartial: false, expanded: true },
422
+ theme,
423
+ { ...makeContext("a", state), isError: false } as any,
424
+ );
425
+ expect((comp as any).text).toContain("a.ts:1:foo");
426
+ expect((comp as any).text).toContain("b.ts:2:foo");
427
+ expect((comp as any).text).toContain("c.ts:3:foo");
428
+ });
429
+
430
+ test("expanded grouped call shows full output for owner", () => {
431
+ const r = new CompactRenderer();
432
+ const theme = makeTheme() as any;
433
+ const stateA: Record<string, any> = {};
434
+ const stateB: Record<string, any> = {};
435
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
436
+ r.renderCall("read", { file_path: "b.ts" }, theme, makeContext("b", stateB) as any);
437
+ // A is the owner; expanded result for A shows full output
438
+ const comp = r.renderResult(
439
+ "read",
440
+ { file_path: "a.ts" },
441
+ { content: [{ type: "text", text: "contentA_line1\ncontentA_line2" }] },
442
+ { isPartial: false, expanded: true },
443
+ theme,
444
+ { ...makeContext("a", stateA), isError: false } as any,
445
+ );
446
+ expect((comp as any).text).toContain("contentA_line1");
447
+ expect((comp as any).text).toContain("contentA_line2");
448
+ });
449
+
450
+ test("expanded partial result does not show output", () => {
451
+ const r = new CompactRenderer();
452
+ const theme = makeTheme() as any;
453
+ const state: Record<string, any> = {};
454
+ r.renderCall("bash", { command: "sleep 1" }, theme, makeContext("a", state) as any);
455
+ const comp = r.renderResult(
456
+ "bash",
457
+ { command: "sleep 1" },
458
+ { content: [{ type: "text", text: "intermediate" }] },
459
+ { isPartial: true, expanded: true },
460
+ theme,
461
+ { ...makeContext("a", state), isError: false } as any,
462
+ );
463
+ expect((comp as any).text).toBe("");
464
+ });
465
+
466
+ test("expanded error still shows error text", () => {
467
+ const r = new CompactRenderer();
468
+ const theme = makeTheme() as any;
469
+ r.renderCall("read", { file_path: "bad.ts" }, theme, makeContext("a") as any);
470
+ const comp = r.renderResult(
471
+ "read",
472
+ { file_path: "bad.ts" },
473
+ { content: [{ type: "text", text: "Error: not found" }] },
474
+ { isPartial: false, expanded: true },
475
+ theme,
476
+ { ...makeContext("a"), isError: true } as any,
477
+ );
478
+ expect((comp as any).text).toContain("Error: not found");
479
+ });
480
+
481
+ test("group stays 'Exploring' after all discovery calls complete (no non-discovery call yet)", () => {
482
+ const r = new CompactRenderer();
483
+ const theme = makeTheme() as any;
484
+ const stateA: Record<string, any> = {};
485
+ const stateB: Record<string, any> = {};
486
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
487
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("b", stateB) as any);
488
+ r.renderResult(
489
+ "read",
490
+ { file_path: "a.ts" },
491
+ { content: [{ type: "text", text: "ok" }] },
492
+ { isPartial: false },
493
+ theme,
494
+ { ...makeContext("a", stateA), isError: false } as any,
495
+ );
496
+ r.renderResult(
497
+ "grep",
498
+ { pattern: "foo" },
499
+ { content: [{ type: "text", text: "ok" }] },
500
+ { isPartial: false },
501
+ theme,
502
+ { ...makeContext("b", stateB), isError: false } as any,
503
+ );
504
+ // Re-render the owner (A) to see the group label
505
+ const groupCall = r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any) as any;
506
+ expect(groupCall.text).toContain("Exploring");
507
+ expect(groupCall.text).not.toContain("Explored");
508
+ });
509
+
510
+ test("group flips to 'Explored' after a non-discovery tool call", () => {
511
+ const r = new CompactRenderer();
512
+ const theme = makeTheme() as any;
513
+ const stateA: Record<string, any> = {};
514
+ const stateB: Record<string, any> = {};
515
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
516
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("b", stateB) as any);
517
+ r.renderResult(
518
+ "read",
519
+ { file_path: "a.ts" },
520
+ { content: [{ type: "text", text: "ok" }] },
521
+ { isPartial: false },
522
+ theme,
523
+ { ...makeContext("a", stateA), isError: false } as any,
524
+ );
525
+ r.renderResult(
526
+ "grep",
527
+ { pattern: "foo" },
528
+ { content: [{ type: "text", text: "ok" }] },
529
+ { isPartial: false },
530
+ theme,
531
+ { ...makeContext("b", stateB), isError: false } as any,
532
+ );
533
+ // Non-discovery call sets the group's hasNonDiscovery flag
534
+ r.renderCall("edit", { file_path: "x.ts" }, theme, makeContext("c") as any);
535
+ // Re-render the OWNER (A, the first discovery call) to pick up the new label
536
+ const refreshed = r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any) as any;
537
+ expect(refreshed.text).toContain("Explored");
538
+ });
539
+
540
+ test("discovery calls within a turn join the same group with first-member owner", () => {
541
+ const r = new CompactRenderer();
542
+ const theme = makeTheme() as any;
543
+ const stateA: Record<string, any> = {};
544
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
545
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("b") as any);
546
+ const recA = (r as any).calls.get("a");
547
+ const recB = (r as any).calls.get("b");
548
+ expect(recA.group).toBeDefined();
549
+ expect(recB.group).toBe(recA.group);
550
+ // First member (A) remains owner
551
+ expect(recA.group.renderOwner).toBe(recA);
552
+ // Re-rendering the owner shows all children
553
+ const ownerComp = r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any) as any;
554
+ expect(ownerComp.text).toContain("a.ts");
555
+ expect(ownerComp.text).toContain("foo");
556
+ });
557
+
558
+ test("discovery calls do NOT join the previous turn's group after visible text", () => {
559
+ const r = new CompactRenderer();
560
+ const theme = makeTheme() as any;
561
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a") as any);
562
+ r.beginTurn();
563
+ r.noteVisibleText();
564
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("b") as any);
565
+ const recB = (r as any).calls.get("b");
566
+ // New turn's discovery call starts a fresh group (or is standalone)
567
+ expect(recB.group).toBeUndefined();
568
+ });
569
+
570
+ test("thinking-only turn keeps discovery calls grouped with previous turn", () => {
571
+ const r = new CompactRenderer();
572
+ const theme = makeTheme() as any;
573
+ const stateA: Record<string, any> = {};
574
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
575
+ r.beginTurn();
576
+ // No noteVisibleText() — thinking-only turn
577
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("b") as any);
578
+ const recA = (r as any).calls.get("a");
579
+ const recB = (r as any).calls.get("b");
580
+ expect(recB.group).toBeDefined();
581
+ expect(recB.group).toBe(recA.group);
582
+ // Owner is still the first member from the previous turn
583
+ expect(recA.group.renderOwner).toBe(recA);
584
+ });
585
+
586
+ test("group hasNonDiscovery flag is sticky within a turn", () => {
587
+ const r = new CompactRenderer();
588
+ const theme = makeTheme() as any;
589
+ const stateA: Record<string, any> = {};
590
+ const stateB: Record<string, any> = {};
591
+ r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
592
+ r.renderCall("grep", { pattern: "foo" }, theme, makeContext("b", stateB) as any);
593
+ r.renderResult(
594
+ "read",
595
+ { file_path: "a.ts" },
596
+ { content: [{ type: "text", text: "ok" }] },
597
+ { isPartial: false },
598
+ theme,
599
+ { ...makeContext("a", stateA), isError: false } as any,
600
+ );
601
+ r.renderResult(
602
+ "grep",
603
+ { pattern: "foo" },
604
+ { content: [{ type: "text", text: "ok" }] },
605
+ { isPartial: false },
606
+ theme,
607
+ { ...makeContext("b", stateB), isError: false } as any,
608
+ );
609
+ // Non-discovery call sets the sticky flag within the same turn
610
+ r.renderCall("edit", { file_path: "x.ts" }, theme, makeContext("c") as any);
611
+ const ownerComp = r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any) as any;
612
+ expect(ownerComp.text).toContain("Explored");
613
+ expect(ownerComp.text).not.toContain("Exploring");
614
+ });
615
+
616
+ test("joining a group invalidates only the owner", () => {
617
+ const r = new CompactRenderer();
618
+ const theme = makeTheme() as any;
619
+ const invalidateA = mock(() => {});
620
+ const invalidateB = mock(() => {});
621
+ const ctxA = { args: {}, toolCallId: "a", invalidate: invalidateA, state: {} };
622
+ const ctxB = { args: {}, toolCallId: "b", invalidate: invalidateB, state: {} };
623
+ r.renderCall("read", { file_path: "a.ts" }, theme, ctxA as any);
624
+ // B joins the group
625
+ r.renderCall("grep", { pattern: "foo" }, theme, ctxB as any);
626
+ // After B joins, re-render A (the owner) to confirm the group is intact
627
+ const ownerComp = r.renderCall("read", { file_path: "a.ts" }, theme, ctxA as any) as any;
628
+ expect(ownerComp.text).toContain("a.ts");
629
+ expect(ownerComp.text).toContain("foo");
630
+ expect(invalidateA).toHaveBeenCalledTimes(1);
631
+ expect(invalidateB).not.toHaveBeenCalled();
632
+ });
633
+
634
+ test("group survives Pi rebuild (thinking-toggle) with fresh context.state and invalidate", () => {
635
+ const r = new CompactRenderer();
636
+ const theme = makeTheme() as any;
637
+
638
+ // --- First render: build a discovery group (read + grep) ---
639
+ const stateA1: Record<string, any> = {};
640
+ const stateB1: Record<string, any> = {};
641
+ const invA1 = mock(() => {});
642
+ const invB1 = mock(() => {});
643
+ const ctxA1 = { args: {}, toolCallId: "a", invalidate: invA1, state: stateA1 };
644
+ const ctxB1 = { args: {}, toolCallId: "b", invalidate: invB1, state: stateB1 };
645
+
646
+ r.renderCall("read", { file_path: "a.ts" }, theme, ctxA1 as any);
647
+ r.renderCall("grep", { pattern: "foo", path: "src/" }, theme, ctxB1 as any);
648
+ r.renderResult(
649
+ "read",
650
+ { file_path: "a.ts" },
651
+ { content: [{ type: "text", text: "ok" }] },
652
+ { isPartial: false },
653
+ theme,
654
+ { ...ctxA1, isError: false } as any,
655
+ );
656
+ r.renderResult(
657
+ "grep",
658
+ { pattern: "foo", path: "src/" },
659
+ { content: [{ type: "text", text: "match" }], details: { totalMatched: 1 } },
660
+ { isPartial: false },
661
+ theme,
662
+ { ...ctxB1, isError: false } as any,
663
+ );
664
+
665
+ const recA = (r as any).calls.get("a");
666
+ const recB = (r as any).calls.get("b");
667
+ expect(recA.group).toBeDefined();
668
+ expect(recB.group).toBe(recA.group);
669
+ expect(recA.group.renderOwner).toBe(recA);
670
+
671
+ // --- Simulate Pi rebuild: chatContainer.clear() + rebuildChatFromMessages() ---
672
+ // Pi destroys every ToolExecutionComponent and creates fresh ones with
673
+ // new rendererState = {} and new invalidate callbacks for the SAME ids.
674
+ const stateA2: Record<string, any> = {};
675
+ const stateB2: Record<string, any> = {};
676
+ const invA2 = mock(() => {});
677
+ const invB2 = mock(() => {});
678
+ const ctxA2 = { args: {}, toolCallId: "a", invalidate: invA2, state: stateA2 };
679
+ const ctxB2 = { args: {}, toolCallId: "b", invalidate: invB2, state: stateB2 };
680
+
681
+ // Owner re-renders the full group into a fresh Text
682
+ const ownerComp = r.renderCall("read", { file_path: "a.ts" }, theme, ctxA2 as any) as any;
683
+ expect(ownerComp.text).toContain("Exploring");
684
+ expect(ownerComp.text).toContain("a.ts");
685
+ expect(ownerComp.text).toContain("foo");
686
+ expect(ownerComp.text.match(/•/g)?.length).toBe(1);
687
+
688
+ // Non-owner renders empty (zero vertical space)
689
+ const memberComp = r.renderCall("grep", { pattern: "foo", path: "src/" }, theme, ctxB2 as any) as any;
690
+ expect(memberComp.text).toBe("");
691
+
692
+ // The group's shared callText is now the live owner's Text
693
+ expect(recA.group.callText).toBe(ownerComp);
694
+
695
+ // Re-deliver results with fresh wrappers (Pi creates new {content,details})
696
+ r.renderResult(
697
+ "read",
698
+ { file_path: "a.ts" },
699
+ { content: [{ type: "text", text: "ok" }] },
700
+ { isPartial: false },
701
+ theme,
702
+ { ...ctxA2, isError: false } as any,
703
+ );
704
+ r.renderResult(
705
+ "grep",
706
+ { pattern: "foo", path: "src/" },
707
+ { content: [{ type: "text", text: "match" }], details: { totalMatched: 1 } },
708
+ { isPartial: false },
709
+ theme,
710
+ { ...ctxB2, isError: false } as any,
711
+ );
712
+
713
+ // The shared callText was updated directly (no owner invalidation)
714
+ expect(ownerComp.text).toContain("a.ts");
715
+ expect(ownerComp.text).toContain("foo");
716
+ // No synchronous invalidate recursion: the new owner invalidate was NOT
717
+ // called by setResult (members write callText directly).
718
+ expect(invA2).not.toHaveBeenCalled();
719
+
720
+ // Old destroyed invalidates are disconnected from the pulse set: the
721
+ // record now points at the live invalidate, not the destroyed one.
722
+ expect(recA.invalidate).toBe(invA2);
723
+ expect(recB.invalidate).toBe(invB2);
724
+ expect(recA.invalidate).not.toBe(invA1);
725
+ expect(recB.invalidate).not.toBe(invB1);
726
+ });
727
+ });