@nmzpy/pi-ember-stack 0.2.1 → 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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  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/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { normalizePathConstraint, normalizeExcludes, buildQuery } from "../query.ts";
2
+ import path from "node:path";
3
+ import {
4
+ normalizePathConstraint,
5
+ normalizeExcludes,
6
+ buildQuery,
7
+ buildExternalAllowlist,
8
+ resolveExternalTarget,
9
+ PI_CODING_AGENT_ALIAS,
10
+ } from "../query.ts";
3
11
 
4
12
  describe("normalizePathConstraint", () => {
5
13
  test("empty string returns empty", () => {
@@ -64,3 +72,151 @@ describe("buildQuery", () => {
64
72
  expect(buildQuery(undefined, "foo")).toBe("foo");
65
73
  });
66
74
  });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // ExternalAllowlist — auto-detection and resolveExternalTarget
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe("ExternalAllowlist", () => {
81
+ const allowlist = buildExternalAllowlist();
82
+ const has_entries = allowlist.entries.length > 0;
83
+ const cwd = process.cwd();
84
+
85
+ test("buildExternalAllowlist auto-detects the package dir", () => {
86
+ expect(allowlist.entries.length).toBeGreaterThanOrEqual(1);
87
+ expect(allowlist.entries[0].alias).toBe(PI_CODING_AGENT_ALIAS);
88
+ expect(path.isAbsolute(allowlist.entries[0].dir)).toBe(true);
89
+ });
90
+
91
+ test("resolveExternalTarget with ./pi-coding-agent alias → bare dir", () => {
92
+ if (!has_entries) return;
93
+ const target = resolveExternalTarget("./pi-coding-agent", allowlist);
94
+ expect(target).toBeDefined();
95
+ expect(target!.entry.alias).toBe(PI_CODING_AGENT_ALIAS);
96
+ expect(target!.relativePath).toBe("");
97
+ });
98
+
99
+ test("resolveExternalTarget with pi-coding-agent alias (no ./) → bare dir", () => {
100
+ if (!has_entries) return;
101
+ const target = resolveExternalTarget("pi-coding-agent", allowlist);
102
+ expect(target).toBeDefined();
103
+ expect(target!.entry.alias).toBe(PI_CODING_AGENT_ALIAS);
104
+ expect(target!.relativePath).toBe("");
105
+ });
106
+
107
+ test("resolveExternalTarget with ./pi-coding-agent/docs → subpath", () => {
108
+ if (!has_entries) return;
109
+ const target = resolveExternalTarget("./pi-coding-agent/docs", allowlist);
110
+ expect(target).toBeDefined();
111
+ expect(target!.entry.alias).toBe(PI_CODING_AGENT_ALIAS);
112
+ expect(target!.relativePath).toBe("docs");
113
+ });
114
+
115
+ test("resolveExternalTarget with ./pi-coding-agent/docs/extensions.md → deep subpath", () => {
116
+ if (!has_entries) return;
117
+ const target = resolveExternalTarget(
118
+ "./pi-coding-agent/docs/extensions.md",
119
+ allowlist,
120
+ );
121
+ expect(target).toBeDefined();
122
+ expect(target!.entry.alias).toBe(PI_CODING_AGENT_ALIAS);
123
+ expect(target!.relativePath).toBe("docs/extensions.md");
124
+ });
125
+
126
+ test("resolveExternalTarget with absolute path under allowlisted dir", () => {
127
+ if (!has_entries) return;
128
+ const abs = path.join(allowlist.entries[0].dir, "docs", "extensions.md");
129
+ const target = resolveExternalTarget(abs, allowlist);
130
+ expect(target).toBeDefined();
131
+ expect(target!.entry.alias).toBe(PI_CODING_AGENT_ALIAS);
132
+ expect(target!.relativePath).toBe("docs/extensions.md");
133
+ });
134
+
135
+ test("resolveExternalTarget returns undefined for workspace-relative src/", () => {
136
+ expect(resolveExternalTarget("src/", allowlist)).toBeUndefined();
137
+ });
138
+
139
+ test("resolveExternalTarget returns undefined for ./src", () => {
140
+ expect(resolveExternalTarget("./src", allowlist)).toBeUndefined();
141
+ });
142
+
143
+ test("resolveExternalTarget returns undefined for undefined input", () => {
144
+ expect(resolveExternalTarget(undefined, allowlist)).toBeUndefined();
145
+ });
146
+
147
+ test("resolveExternalTarget returns undefined for empty string", () => {
148
+ expect(resolveExternalTarget("", allowlist)).toBeUndefined();
149
+ });
150
+
151
+ test("resolveExternalTarget returns undefined for alias-prefix mismatch (pi-coding-agent-foo)", () => {
152
+ expect(resolveExternalTarget("pi-coding-agent-foo", allowlist)).toBeUndefined();
153
+ });
154
+ });
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // normalizePathConstraint with allowlist
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe("normalizePathConstraint with allowlist", () => {
161
+ const allowlist = buildExternalAllowlist();
162
+ const has_entries = allowlist.entries.length > 0;
163
+ const cwd = process.cwd();
164
+
165
+ test("allowlisted ./pi-coding-agent → null (bare dir)", () => {
166
+ if (!has_entries) return;
167
+ expect(normalizePathConstraint("./pi-coding-agent", cwd, allowlist)).toBeNull();
168
+ });
169
+
170
+ test("allowlisted ./pi-coding-agent/docs → docs/ (trailing slash)", () => {
171
+ if (!has_entries) return;
172
+ expect(normalizePathConstraint("./pi-coding-agent/docs", cwd, allowlist)).toBe("docs/");
173
+ });
174
+
175
+ test("allowlisted ./pi-coding-agent/docs/extensions.md → preserved filename", () => {
176
+ if (!has_entries) return;
177
+ expect(
178
+ normalizePathConstraint("./pi-coding-agent/docs/extensions.md", cwd, allowlist),
179
+ ).toBe("docs/extensions.md");
180
+ });
181
+
182
+ test("allowlisted absolute path under package dir → relative with trailing slash", () => {
183
+ if (!has_entries) return;
184
+ const abs = path.join(allowlist.entries[0].dir, "docs");
185
+ expect(normalizePathConstraint(abs, cwd, allowlist)).toBe("docs/");
186
+ });
187
+
188
+ test("non-allowlisted absolute path /etc/passwd → throws", () => {
189
+ expect(() => normalizePathConstraint("/etc/passwd", cwd, allowlist)).toThrow(
190
+ "Path constraint must be relative to the workspace: /etc/passwd",
191
+ );
192
+ });
193
+
194
+ test("workspace-relative src → src/ (unchanged with allowlist)", () => {
195
+ expect(normalizePathConstraint("src", cwd, allowlist)).toBe("src/");
196
+ });
197
+
198
+ test("workspace-relative glob *.ts → *.ts (unchanged with allowlist)", () => {
199
+ expect(normalizePathConstraint("*.ts", cwd, allowlist)).toBe("*.ts");
200
+ });
201
+
202
+ test("workspace-relative dot → null (unchanged with allowlist)", () => {
203
+ expect(normalizePathConstraint(".", cwd, allowlist)).toBeNull();
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // buildQuery with external target
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe("buildQuery with external target", () => {
212
+ const allowlist = buildExternalAllowlist();
213
+ const has_entries = allowlist.entries.length > 0;
214
+ const cwd = process.cwd();
215
+
216
+ test("buildQuery with ./pi-coding-agent/docs includes docs/ and pattern", () => {
217
+ if (!has_entries) return;
218
+ const q = buildQuery("./pi-coding-agent/docs", "renderCall", undefined, cwd, allowlist);
219
+ expect(q).toContain("docs/");
220
+ expect(q).toContain("renderCall");
221
+ });
222
+ });
@@ -19,17 +19,70 @@ function makeContext(id: string, state: Record<string, any> = {}) {
19
19
  }
20
20
 
21
21
  describe("CompactRenderer", () => {
22
- test("beginTurn does not reset grouping for same-key calls", () => {
22
+ test("beginTurn alone does not reset grouping thinking-only turns stay grouped", () => {
23
23
  const r = new CompactRenderer();
24
24
  const theme = makeTheme() as any;
25
25
  const ctx1 = makeContext("a");
26
26
  r.renderCall("read", { file_path: "foo.ts" }, theme, ctx1 as any);
27
27
  r.beginTurn();
28
+ // No visible text in this turn — discovery calls should join the previous group
28
29
  const ctx2 = makeContext("b");
29
30
  r.renderCall("read", { file_path: "bar.ts" }, theme, ctx2 as any);
30
- // Same group key (__discovery__) should still group across turns
31
31
  const record = (r as any).calls.get("b");
32
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");
33
86
  });
34
87
 
35
88
  test("consecutive discovery tools group together", () => {
@@ -46,6 +99,41 @@ describe("CompactRenderer", () => {
46
99
  expect(recA.group.records.length).toBe(2);
47
100
  });
48
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
+
49
137
  test("non-discovery tools do not group", () => {
50
138
  const r = new CompactRenderer();
51
139
  const theme = makeTheme() as any;
@@ -176,11 +264,10 @@ describe("CompactRenderer", () => {
176
264
  expect((comp as any).text).not.toContain("line1");
177
265
  });
178
266
 
179
- test("bash calls with same cd dir group across turns", () => {
267
+ test("bash calls with same cd dir group within a turn", () => {
180
268
  const r = new CompactRenderer();
181
269
  const theme = makeTheme() as any;
182
270
  r.renderCall("bash", { command: "cd src && ls" }, theme, makeContext("a") as any);
183
- r.beginTurn();
184
271
  r.renderCall("bash", { command: "cd src && pwd" }, theme, makeContext("b") as any);
185
272
  const recA = (r as any).calls.get("a");
186
273
  const recB = (r as any).calls.get("b");
@@ -188,6 +275,17 @@ describe("CompactRenderer", () => {
188
275
  expect(recB.group).toBe(recA.group);
189
276
  });
190
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
+
191
289
  test("DISCOVERY_TOOLS includes grep and find", () => {
192
290
  expect(DISCOVERY_TOOLS.has("grep")).toBe(true);
193
291
  expect(DISCOVERY_TOOLS.has("find")).toBe(true);
@@ -210,7 +308,7 @@ describe("CompactRenderer", () => {
210
308
  expect(result).toContain("main");
211
309
  });
212
310
 
213
- test("renderOwner moves group to latest call", () => {
311
+ test("renderOwner stays on first call", () => {
214
312
  const r = new CompactRenderer();
215
313
  const theme = makeTheme() as any;
216
314
  const ctx1 = makeContext("a");
@@ -219,21 +317,26 @@ describe("CompactRenderer", () => {
219
317
  r.renderCall("read", { file_path: "b.ts" }, theme, ctx2 as any);
220
318
  const recA = (r as any).calls.get("a");
221
319
  const recB = (r as any).calls.get("b");
222
- // After B joins, B should be the renderOwner
223
- expect(recA.group.renderOwner).toBe(recB);
224
- expect(recA.group.renderOwner).not.toBe(recA);
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);
225
323
  });
226
324
 
227
- test("non-owner grouped call renders empty", () => {
325
+ test("non-owner grouped call renders empty; owner renders group", () => {
228
326
  const r = new CompactRenderer();
229
327
  const theme = makeTheme() as any;
230
328
  const ctx1 = makeContext("a");
231
329
  const ctx2 = makeContext("b");
232
330
  r.renderCall("read", { file_path: "a.ts" }, theme, ctx1 as any);
233
331
  r.renderCall("read", { file_path: "b.ts" }, theme, ctx2 as any);
234
- // After B joins and becomes owner, re-rendering A should return empty
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
235
336
  const compA2 = r.renderCall("read", { file_path: "a.ts" }, theme, ctx1 as any);
236
- expect((compA2 as any).text).toBe("");
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");
237
340
  });
238
341
 
239
342
  test("expanded bash shows full output", () => {
@@ -331,16 +434,17 @@ describe("CompactRenderer", () => {
331
434
  const stateB: Record<string, any> = {};
332
435
  r.renderCall("read", { file_path: "a.ts" }, theme, makeContext("a", stateA) as any);
333
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
334
438
  const comp = r.renderResult(
335
439
  "read",
336
- { file_path: "b.ts" },
337
- { content: [{ type: "text", text: "contentB_line1\ncontentB_line2" }] },
440
+ { file_path: "a.ts" },
441
+ { content: [{ type: "text", text: "contentA_line1\ncontentA_line2" }] },
338
442
  { isPartial: false, expanded: true },
339
443
  theme,
340
- { ...makeContext("b", stateB), isError: false } as any,
444
+ { ...makeContext("a", stateA), isError: false } as any,
341
445
  );
342
- expect((comp as any).text).toContain("contentB_line1");
343
- expect((comp as any).text).toContain("contentB_line2");
446
+ expect((comp as any).text).toContain("contentA_line1");
447
+ expect((comp as any).text).toContain("contentA_line2");
344
448
  });
345
449
 
346
450
  test("expanded partial result does not show output", () => {
@@ -373,4 +477,251 @@ describe("CompactRenderer", () => {
373
477
  );
374
478
  expect((comp as any).text).toContain("Error: not found");
375
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
+ });
376
727
  });