@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.
- package/README.md +83 -83
- package/package.json +62 -48
- package/plugins/devin-auth/extensions/index.ts +68 -7
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
- package/plugins/devin-auth/src/models.ts +42 -196
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
- package/plugins/devin-auth/src/oauth/types.ts +71 -71
- package/plugins/devin-auth/src/stream.ts +1 -1
- package/plugins/index.ts +29 -6
- package/plugins/pi-compact-tools/index.ts +35 -192
- package/plugins/pi-compact-tools/renderer.ts +589 -0
- package/plugins/pi-custom-agents/index.ts +727 -373
- package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
- package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
- package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
- package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
- package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
- package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
- package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
- package/plugins/pi-ember-fff/index.ts +975 -0
- package/plugins/pi-ember-fff/query.ts +247 -0
- package/plugins/pi-ember-fff/test/query.test.ts +222 -0
- package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
- package/plugins/pi-ember-tps/index.ts +144 -0
- package/plugins/pi-ember-ui/ember.json +99 -0
- package/plugins/pi-ember-ui/index.ts +805 -0
- package/plugins/pi-ember-ui/mode-colors.ts +196 -0
- package/tsconfig.json +2 -1
- 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
|
+
});
|