@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.
- package/README.md +83 -83
- package/package.json +63 -59
- package/plugins/devin-auth/extensions/index.ts +23 -0
- 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/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/pi-compact-tools/index.ts +19 -1
- package/plugins/pi-compact-tools/renderer.ts +231 -61
- package/plugins/pi-custom-agents/index.ts +310 -102
- package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
- package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
- 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 +241 -177
- package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
- package/plugins/pi-ember-fff/index.ts +275 -17
- package/plugins/pi-ember-fff/query.ts +170 -10
- package/plugins/pi-ember-fff/test/query.test.ts +157 -1
- package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
- package/plugins/pi-ember-tps/index.ts +27 -5
- package/plugins/pi-ember-ui/ember.json +3 -0
- package/plugins/pi-ember-ui/index.ts +223 -36
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
223
|
-
expect(recA.group.renderOwner).toBe(
|
|
224
|
-
expect(recA.group.renderOwner).not.toBe(
|
|
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
|
-
//
|
|
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).
|
|
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: "
|
|
337
|
-
{ content: [{ type: "text", text: "
|
|
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("
|
|
444
|
+
{ ...makeContext("a", stateA), isError: false } as any,
|
|
341
445
|
);
|
|
342
|
-
expect((comp as any).text).toContain("
|
|
343
|
-
expect((comp as any).text).toContain("
|
|
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
|
});
|