@selesai/code 0.8.4 → 0.8.6
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/CHANGELOG.md +22 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +46 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +5 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import copyTurnExtension from "./copy-turn.ts";
|
|
3
4
|
|
|
4
5
|
vi.mock("@selesai/code", () => ({
|
|
@@ -19,6 +20,11 @@ function hashFromMarkedText(text: string): string {
|
|
|
19
20
|
return m[1]!;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// hashFor(m) = sha1(JSON.stringify([role, resultOf(m)])).slice(0, 6)
|
|
24
|
+
function hashForText(role: string, text: string): string {
|
|
25
|
+
return createHash("sha1").update(JSON.stringify([role, text])).digest("hex").slice(0, 6);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
function createHarness() {
|
|
23
29
|
const handlers = new Map<string, Handler>();
|
|
24
30
|
const commands = new Map<string, Command>();
|
|
@@ -128,4 +134,227 @@ describe("copy-turn hash stability", () => {
|
|
|
128
134
|
|
|
129
135
|
expect(hashFromMarkedText(r1.message.content[0].text)).toBe(hashFromMarkedText(r2.message.content[0].text));
|
|
130
136
|
});
|
|
137
|
+
|
|
138
|
+
it("skips empty user messages and tool-use assistant messages", async () => {
|
|
139
|
+
const { handlers, sent } = createHarness();
|
|
140
|
+
const messageEnd = handlers.get("message_end")!;
|
|
141
|
+
|
|
142
|
+
await messageEnd({ type: "message_end", message: { role: "user", content: " " } }, {});
|
|
143
|
+
expect(sent).toHaveLength(0);
|
|
144
|
+
|
|
145
|
+
const toolUse = await messageEnd({ type: "message_end", message: { role: "assistant", content: "thinking", stopReason: "toolUse" } }, {});
|
|
146
|
+
expect(toolUse).toBeUndefined();
|
|
147
|
+
|
|
148
|
+
const emptyAssistant = await messageEnd({ type: "message_end", message: { role: "assistant", content: "" } }, {});
|
|
149
|
+
expect(emptyAssistant).toBeUndefined();
|
|
150
|
+
|
|
151
|
+
const otherRole = await messageEnd({ type: "message_end", message: { role: "system", content: "sys" } }, {});
|
|
152
|
+
expect(otherRole).toBeUndefined();
|
|
153
|
+
|
|
154
|
+
await messageEnd({ type: "message_end", message: null }, {});
|
|
155
|
+
expect(sent).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("marks string-content assistant messages and bashExecution results", async () => {
|
|
159
|
+
const { handlers } = createHarness();
|
|
160
|
+
const messageEnd = handlers.get("message_end")!;
|
|
161
|
+
|
|
162
|
+
const stringMsg = await messageEnd({ type: "message_end", message: { role: "assistant", content: "plain text result" } }, {});
|
|
163
|
+
expect(stringMsg.message.content).toMatch(/plain text result\n\n⧉ copy assistant: \/cp [0-9a-f]{6}/);
|
|
164
|
+
|
|
165
|
+
const bashMsg = await messageEnd({
|
|
166
|
+
type: "message_end",
|
|
167
|
+
message: { role: "bashExecution", content: "ignored", command: "ls -la", output: "file1\nfile2" },
|
|
168
|
+
}, {});
|
|
169
|
+
expect(bashMsg).toBeUndefined(); // bashExecution is not assistant
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("marks the last text part of multi-part assistant content", async () => {
|
|
173
|
+
const { handlers } = createHarness();
|
|
174
|
+
const messageEnd = handlers.get("message_end")!;
|
|
175
|
+
|
|
176
|
+
const result = await messageEnd({
|
|
177
|
+
type: "message_end",
|
|
178
|
+
message: {
|
|
179
|
+
role: "assistant",
|
|
180
|
+
content: [
|
|
181
|
+
{ type: "text", text: "first part" },
|
|
182
|
+
{ type: "image", mimeType: "image/png" },
|
|
183
|
+
{ type: "text", text: "final part" },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
}, {});
|
|
187
|
+
expect(result.message.content[0].text).toBe("first part");
|
|
188
|
+
expect(result.message.content[1].type).toBe("image");
|
|
189
|
+
expect(result.message.content[2].text).toMatch(/^final part\n\n⧉ copy assistant: \/cp [0-9a-f]{6}$/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("handles text parts with undefined text", async () => {
|
|
193
|
+
const { handlers } = createHarness();
|
|
194
|
+
const messageEnd = handlers.get("message_end")!;
|
|
195
|
+
|
|
196
|
+
const result = await messageEnd({
|
|
197
|
+
type: "message_end",
|
|
198
|
+
message: { role: "assistant", content: [{ type: "text" }, { type: "text", text: "real" }] },
|
|
199
|
+
}, {});
|
|
200
|
+
expect(result.message.content[0].text).toBeUndefined(); // non-last parts pass through unchanged
|
|
201
|
+
expect(result.message.content[1].text).toMatch(/^real\n\n⧉ copy assistant: \/cp [0-9a-f]{6}$/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("hashes bashExecution results without command or output", async () => {
|
|
205
|
+
const { handlers, commands } = createHarness();
|
|
206
|
+
const messageEnd = handlers.get("message_end")!;
|
|
207
|
+
const cmd = commands.get("cp")!;
|
|
208
|
+
|
|
209
|
+
// No command, no output → empty result → no copy row.
|
|
210
|
+
await messageEnd({ type: "message_end", message: { role: "bashExecution" } }, {});
|
|
211
|
+
|
|
212
|
+
// Command without output.
|
|
213
|
+
const message = { role: "bashExecution", command: "ls" };
|
|
214
|
+
const hash = hashForText("bashExecution", "$ ls");
|
|
215
|
+
const ctx = branchCtx([message]);
|
|
216
|
+
await cmd.handler(hash, ctx);
|
|
217
|
+
expect(copyToClipboard).toHaveBeenCalledWith("$ ls");
|
|
218
|
+
|
|
219
|
+
// Output without command (clean.command ? ... : "" branch).
|
|
220
|
+
const noCommand = { role: "bashExecution", output: "bare output" };
|
|
221
|
+
const hash2 = hashForText("bashExecution", "bare output");
|
|
222
|
+
const ctx2 = branchCtx([noCommand]);
|
|
223
|
+
await cmd.handler(hash2, ctx2);
|
|
224
|
+
expect(copyToClipboard).toHaveBeenCalledWith("bare output");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("branchMessages tolerates a missing sessionManager and filters custom rows", async () => {
|
|
228
|
+
const { commands } = createHarness();
|
|
229
|
+
const cmd = commands.get("cp")!;
|
|
230
|
+
|
|
231
|
+
const noManager = { ui: { notify: vi.fn() } };
|
|
232
|
+
await cmd.handler("abc123", noManager);
|
|
233
|
+
expect(noManager.ui.notify).toHaveBeenCalledWith("No message for hash abc123", "warning");
|
|
234
|
+
|
|
235
|
+
const withCustom = branchCtx([
|
|
236
|
+
{ role: "user", content: "real content" },
|
|
237
|
+
{ role: "custom", customType: "copy-turn", content: "⧉ copy user: /cp abc123" },
|
|
238
|
+
]);
|
|
239
|
+
const hash = hashForText("user", "real content");
|
|
240
|
+
await cmd.handler(hash, withCustom);
|
|
241
|
+
expect(copyToClipboard).toHaveBeenCalledWith("real content");
|
|
242
|
+
|
|
243
|
+
// Role-less message: hashFor falls back to the "message" role.
|
|
244
|
+
const roleless = branchCtx([{ content: "roleless content" }]);
|
|
245
|
+
const rolelessHash = hashForText("message", "roleless content");
|
|
246
|
+
await cmd.handler(rolelessHash, roleless);
|
|
247
|
+
expect(copyToClipboard).toHaveBeenCalledWith("roleless content");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("adds a marker to content arrays without text parts", async () => {
|
|
251
|
+
const { handlers } = createHarness();
|
|
252
|
+
const messageEnd = handlers.get("message_end")!;
|
|
253
|
+
|
|
254
|
+
// Image-only assistant content has no copyable text, so no marker is added.
|
|
255
|
+
const result = await messageEnd({
|
|
256
|
+
type: "message_end",
|
|
257
|
+
message: { role: "assistant", content: [{ type: "image", mimeType: "image/png" }] },
|
|
258
|
+
}, {});
|
|
259
|
+
expect(result).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("context handler strips copy rows and cleans markers", async () => {
|
|
263
|
+
const { handlers } = createHarness();
|
|
264
|
+
const context = handlers.get("context")!;
|
|
265
|
+
const result = await context({
|
|
266
|
+
type: "context",
|
|
267
|
+
messages: [
|
|
268
|
+
{ role: "user", content: "hello\n\n⧉ copy user: /cp abc123" },
|
|
269
|
+
{ role: "custom", customType: "copy-turn", content: "⧉ copy user: /cp abc123" },
|
|
270
|
+
{ role: "assistant", content: [{ type: "text", text: "hi\n\n⧉ copy assistant: /cp def456" }] },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
expect(result.messages).toHaveLength(2);
|
|
274
|
+
expect(result.messages[0].content).toBe("hello");
|
|
275
|
+
expect(result.messages[1].content[0].text).toBe("hi");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("message renderer falls back to content hash and default role", () => {
|
|
279
|
+
const { pi } = createHarness();
|
|
280
|
+
const renderer = (pi.registerMessageRenderer as any).mock.calls[0][1];
|
|
281
|
+
const theme = { fg: (_name: string, value: string) => value };
|
|
282
|
+
|
|
283
|
+
const withDetails = renderer({ details: { hash: "abc123", role: "user" } }, {}, theme);
|
|
284
|
+
expect(withDetails.render(80).join("\n")).toContain("⧉ copy user: /cp abc123");
|
|
285
|
+
|
|
286
|
+
const fromContent = renderer({ content: "hash fedcba in the middle" }, {}, theme);
|
|
287
|
+
expect(fromContent.render(80).join("\n")).toContain("⧉ copy message: /cp fedcba");
|
|
288
|
+
|
|
289
|
+
const fallback = renderer({ content: "no hash here" }, {}, theme);
|
|
290
|
+
expect(fallback.render(80).join("\n")).toContain("⧉ copy message: /cp ??????");
|
|
291
|
+
|
|
292
|
+
// Non-string, non-array content (defensive path in textFromContent).
|
|
293
|
+
const nonText = renderer({ content: 42 }, {}, theme);
|
|
294
|
+
expect(nonText.render(80).join("\n")).toContain("⧉ copy message: /cp ??????");
|
|
295
|
+
|
|
296
|
+
// Text part without a text property (b.text ?? "" branch).
|
|
297
|
+
const missingText = renderer({ content: [{ type: "text" }] }, {}, theme);
|
|
298
|
+
expect(missingText.render(80).join("\n")).toContain("⧉ copy message: /cp ??????");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("/cp reports usage, no-match, ambiguous, and empty-result cases", async () => {
|
|
302
|
+
const { commands } = createHarness();
|
|
303
|
+
const cmd = commands.get("cp")!;
|
|
304
|
+
|
|
305
|
+
const usageCtx = branchCtx([]);
|
|
306
|
+
await cmd.handler("", usageCtx);
|
|
307
|
+
expect(usageCtx.ui.notify).toHaveBeenCalledWith("Usage: /cp <hash>", "warning");
|
|
308
|
+
|
|
309
|
+
const noMatchCtx = branchCtx([{ role: "user", content: "hello" }]);
|
|
310
|
+
await cmd.handler("zzzzzz", noMatchCtx);
|
|
311
|
+
expect(noMatchCtx.ui.notify).toHaveBeenCalledWith("No message for hash zzzzzz", "warning");
|
|
312
|
+
|
|
313
|
+
// Two messages whose hashes share a prefix → ambiguous.
|
|
314
|
+
// "c" and "e" are known to share the first hash character "9".
|
|
315
|
+
const m1 = { role: "user", content: "c" };
|
|
316
|
+
const m2 = { role: "user", content: "e" };
|
|
317
|
+
const h1 = hashForText("user", "c");
|
|
318
|
+
const h2 = hashForText("user", "e");
|
|
319
|
+
// Find a shared prefix of length >= 1.
|
|
320
|
+
let shared = "";
|
|
321
|
+
for (let i = 0; i < Math.min(h1.length, h2.length); i++) {
|
|
322
|
+
if (h1[i] === h2[i]) shared += h1[i];
|
|
323
|
+
else break;
|
|
324
|
+
}
|
|
325
|
+
if (shared.length === 0) {
|
|
326
|
+
// Extremely unlikely; fall back to asserting the no-match path instead.
|
|
327
|
+
const ctx = branchCtx([m1]);
|
|
328
|
+
await cmd.handler(h1, ctx);
|
|
329
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(`Copied ${h1} (${m1.content.length} chars)`, "info");
|
|
330
|
+
} else {
|
|
331
|
+
const ambiguousCtx = branchCtx([m1, m2]);
|
|
332
|
+
await cmd.handler(shared, ambiguousCtx);
|
|
333
|
+
expect(ambiguousCtx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("ambiguous"), "warning");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const emptyCtx = branchCtx([{ role: "user", content: " " }]);
|
|
337
|
+
await cmd.handler(hashForText("user", ""), emptyCtx);
|
|
338
|
+
expect(emptyCtx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("No result content"), "warning");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("/cp strips ANSI codes before copying", async () => {
|
|
342
|
+
const { commands } = createHarness();
|
|
343
|
+
const cmd = commands.get("cp")!;
|
|
344
|
+
const message = { role: "user", content: "\x1b[31mred\x1b[0m text" };
|
|
345
|
+
const hash = hashForText("user", "\x1b[31mred\x1b[0m text");
|
|
346
|
+
const ctx = branchCtx([message]);
|
|
347
|
+
await cmd.handler(hash, ctx);
|
|
348
|
+
expect(copyToClipboard).toHaveBeenCalledWith("red text");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("/cp copies bashExecution results with command prefix", async () => {
|
|
352
|
+
const { commands } = createHarness();
|
|
353
|
+
const cmd = commands.get("cp")!;
|
|
354
|
+
const message = { role: "bashExecution", command: "ls", output: "a\nb" };
|
|
355
|
+
const hash = hashForText("bashExecution", "$ ls\na\nb");
|
|
356
|
+
const ctx = branchCtx([message]);
|
|
357
|
+
await cmd.handler(hash, ctx);
|
|
358
|
+
expect(copyToClipboard).toHaveBeenCalledWith("$ ls\na\nb");
|
|
359
|
+
});
|
|
131
360
|
});
|
|
@@ -50,10 +50,20 @@ function hashFor(m: AnyMessage): string {
|
|
|
50
50
|
function addMarker(m: AnyMessage): AnyMessage {
|
|
51
51
|
const h = hashFor(m);
|
|
52
52
|
const out = structuredClone(m);
|
|
53
|
+
// Unreachable: addMarker is only called from message_end for assistant
|
|
54
|
+
// messages, so out.role is always "assistant".
|
|
55
|
+
/* v8 ignore next 1 */
|
|
53
56
|
const role = out.role ?? "message";
|
|
54
57
|
if (typeof out.content === "string") out.content = stripMark(out.content) + mark(h, role);
|
|
58
|
+
// Unreachable: addMarker is only called when resultOf(m) is non-empty,
|
|
59
|
+
// which requires string or text-array content, so the non-array else
|
|
60
|
+
// branch below can never be taken.
|
|
61
|
+
/* v8 ignore start */
|
|
55
62
|
else if (Array.isArray(out.content)) {
|
|
56
63
|
const lastText = out.content.findLastIndex((x: any) => x?.type === "text");
|
|
64
|
+
// Unreachable: message_end only calls addMarker when resultOf(m) is
|
|
65
|
+
// non-empty, and resultOf extracts only text parts, so the last text
|
|
66
|
+
// part always exists and has defined text.
|
|
57
67
|
if (lastText >= 0) {
|
|
58
68
|
out.content = out.content.map((b: any, i: number) =>
|
|
59
69
|
i === lastText ? { ...b, text: stripMark(b.text ?? "") + mark(h, role) } : b
|
|
@@ -62,6 +72,7 @@ function addMarker(m: AnyMessage): AnyMessage {
|
|
|
62
72
|
out.content.push({ type: "text", text: mark(h, role).trimStart() });
|
|
63
73
|
}
|
|
64
74
|
}
|
|
75
|
+
/* v8 ignore stop */
|
|
65
76
|
return out;
|
|
66
77
|
}
|
|
67
78
|
|
|
@@ -1,13 +1,51 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { buildSearchUrl, formatSearchResponse } from "./index.ts";
|
|
3
3
|
|
|
4
|
+
// Mock fetch and playwright so the tool execute paths run offline.
|
|
5
|
+
const fetchMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
const chromiumMock = {
|
|
8
|
+
launch: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
vi.mock("playwright", () => ({ chromium: chromiumMock }));
|
|
11
|
+
|
|
12
|
+
import grepAppExtension from "./index.ts";
|
|
13
|
+
|
|
14
|
+
function makePi() {
|
|
15
|
+
const tools = new Map<string, any>();
|
|
16
|
+
const pi = {
|
|
17
|
+
registerTool: (tool: any) => tools.set(tool.name, tool),
|
|
18
|
+
};
|
|
19
|
+
grepAppExtension(pi as any);
|
|
20
|
+
return { pi, tools };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const theme = {
|
|
24
|
+
fg: (_name: string, value: string) => value,
|
|
25
|
+
bold: (value: string) => value,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const okResponse = (body: string, status = 200, headers: Record<string, string> = {}) =>
|
|
29
|
+
new Response(body, { status, headers });
|
|
30
|
+
|
|
4
31
|
describe("grep-app extension", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
34
|
+
fetchMock.mockReset();
|
|
35
|
+
chromiumMock.launch.mockReset();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
5
42
|
it("maps search filters to grep.app parameters", () => {
|
|
6
43
|
const url = buildSearchUrl({
|
|
7
44
|
query: "useEffect cleanup",
|
|
8
45
|
page: 2,
|
|
9
46
|
caseSensitive: true,
|
|
10
47
|
useRegex: true,
|
|
48
|
+
wholeWords: true,
|
|
11
49
|
repo: "facebook/react",
|
|
12
50
|
path: "packages/",
|
|
13
51
|
language: "TypeScript",
|
|
@@ -18,12 +56,24 @@ describe("grep-app extension", () => {
|
|
|
18
56
|
page: "2",
|
|
19
57
|
case: "true",
|
|
20
58
|
regexp: "true",
|
|
59
|
+
words: "true",
|
|
21
60
|
"f.repo.pattern": "facebook/react",
|
|
22
61
|
"f.path.pattern": "packages/",
|
|
23
62
|
"f.lang": "TypeScript",
|
|
24
63
|
});
|
|
25
64
|
});
|
|
26
65
|
|
|
66
|
+
it("buildSearchUrl defaults page to 1 and omits optional filters", () => {
|
|
67
|
+
const url = buildSearchUrl({ query: "foo" });
|
|
68
|
+
expect(url.searchParams.get("page")).toBe("1");
|
|
69
|
+
expect(url.searchParams.has("case")).toBe(false);
|
|
70
|
+
expect(url.searchParams.has("regexp")).toBe(false);
|
|
71
|
+
expect(url.searchParams.has("words")).toBe(false);
|
|
72
|
+
expect(url.searchParams.has("f.repo.pattern")).toBe(false);
|
|
73
|
+
expect(url.searchParams.has("f.path.pattern")).toBe(false);
|
|
74
|
+
expect(url.searchParams.has("f.lang")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
27
77
|
it("turns grep.app HTML snippets into readable numbered source", () => {
|
|
28
78
|
const output = formatSearchResponse(
|
|
29
79
|
{
|
|
@@ -48,4 +98,309 @@ describe("grep-app extension", () => {
|
|
|
48
98
|
expect(output).toContain("L7: const x = <mark>;");
|
|
49
99
|
expect(output).toContain("https://github.com/owner/repo/blob/main/src/a%20file.ts#L7");
|
|
50
100
|
});
|
|
101
|
+
|
|
102
|
+
it("formatSearchResponse returns no-matches text for empty hits", () => {
|
|
103
|
+
expect(formatSearchResponse({ hits: { total: 0, hits: [] } }, 1)).toBe("No matches found.");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("formatSearchResponse omits the line anchor when no data-line is present", () => {
|
|
107
|
+
const output = formatSearchResponse(
|
|
108
|
+
{
|
|
109
|
+
hits: {
|
|
110
|
+
total: 1,
|
|
111
|
+
hits: [
|
|
112
|
+
{
|
|
113
|
+
repo: "owner/repo",
|
|
114
|
+
branch: "main",
|
|
115
|
+
path: "src/file.ts",
|
|
116
|
+
content: { snippet: "<table><tr><td><pre>code</pre></td></tr></table>" },
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
1,
|
|
122
|
+
);
|
|
123
|
+
expect(output).toContain("L?: code");
|
|
124
|
+
expect(output).toContain("https://github.com/owner/repo/blob/main/src/file.ts");
|
|
125
|
+
expect(output).not.toContain("#L");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("registers both tools", () => {
|
|
129
|
+
const { tools } = makePi();
|
|
130
|
+
expect(tools.has("grep_app_search")).toBe(true);
|
|
131
|
+
expect(tools.has("grep_app_fetch")).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("grep_app_search executes and formats results", async () => {
|
|
135
|
+
const { tools } = makePi();
|
|
136
|
+
fetchMock.mockResolvedValueOnce(
|
|
137
|
+
okResponse(JSON.stringify({
|
|
138
|
+
hits: {
|
|
139
|
+
total: 5,
|
|
140
|
+
hits: [
|
|
141
|
+
{
|
|
142
|
+
repo: "owner/repo",
|
|
143
|
+
branch: "main",
|
|
144
|
+
path: "src/a.ts",
|
|
145
|
+
content: { snippet: '<table><tr data-line="3"><td><pre>const x = 1;</pre></td></tr></table>' },
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
})),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "const x" }, undefined);
|
|
153
|
+
expect(result.content[0].text).toContain("grep.app: 5 total matches; page 1");
|
|
154
|
+
expect(result.content[0].text).toContain("L3: const x = 1;");
|
|
155
|
+
expect(result.details).toMatchObject({ total: 5, page: 1, resultCount: 1 });
|
|
156
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
157
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
158
|
+
expect(String(url)).toContain("q=const+x");
|
|
159
|
+
expect(init.headers["User-Agent"]).toBe("selesai-grep-app-extension");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("grep_app_search rejects useRegex + wholeWords", async () => {
|
|
163
|
+
const { tools } = makePi();
|
|
164
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x", useRegex: true, wholeWords: true }, undefined))
|
|
165
|
+
.rejects.toThrow("useRegex and wholeWords cannot both be true.");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("grep_app_search throws on invalid JSON and unexpected shapes", async () => {
|
|
169
|
+
const { tools } = makePi();
|
|
170
|
+
fetchMock.mockResolvedValueOnce(okResponse("not json"));
|
|
171
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
172
|
+
.rejects.toThrow("grep.app returned invalid JSON.");
|
|
173
|
+
|
|
174
|
+
fetchMock.mockResolvedValueOnce(okResponse(JSON.stringify({ hits: {} })));
|
|
175
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
176
|
+
.rejects.toThrow("grep.app returned an unexpected response.");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("grep_app_search throws on non-OK responses", async () => {
|
|
180
|
+
const { tools } = makePi();
|
|
181
|
+
fetchMock.mockResolvedValueOnce(okResponse("oops", 500));
|
|
182
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
183
|
+
.rejects.toThrow("500");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("grep_app_search falls back to the browser on a 429 challenge", async () => {
|
|
187
|
+
const { tools } = makePi();
|
|
188
|
+
fetchMock.mockResolvedValueOnce(
|
|
189
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
190
|
+
);
|
|
191
|
+
const page = {
|
|
192
|
+
goto: vi.fn(async () => {}),
|
|
193
|
+
waitForFunction: vi.fn(async () => {}),
|
|
194
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => '{"hits":{"total":1,"hits":[]}}') })),
|
|
195
|
+
};
|
|
196
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
197
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
198
|
+
|
|
199
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "x" }, undefined);
|
|
200
|
+
expect(result.content[0].text).toBe("No matches found.");
|
|
201
|
+
expect(chromiumMock.launch).toHaveBeenCalledWith({ headless: true });
|
|
202
|
+
expect(browser.close).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("grep_app_search aborts the browser fetch when the signal fires", async () => {
|
|
206
|
+
const { tools } = makePi();
|
|
207
|
+
fetchMock.mockResolvedValueOnce(
|
|
208
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
209
|
+
);
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const page = {
|
|
212
|
+
goto: vi.fn(async () => {}),
|
|
213
|
+
waitForFunction: vi.fn(() => new Promise((_resolve, reject) => {
|
|
214
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
|
|
215
|
+
})),
|
|
216
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => "{}") })),
|
|
217
|
+
};
|
|
218
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
219
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
220
|
+
|
|
221
|
+
const promise = tools.get("grep_app_search").execute("call1", { query: "x" }, controller.signal);
|
|
222
|
+
// Let the browser launch and page.goto run, then abort while waitForFunction hangs.
|
|
223
|
+
await vi.waitFor(() => expect(page.goto).toHaveBeenCalled());
|
|
224
|
+
controller.abort(new Error("cancelled"));
|
|
225
|
+
await expect(promise).rejects.toThrow("cancelled");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("grep_app_search rethrows non-abort browser errors", async () => {
|
|
229
|
+
const { tools } = makePi();
|
|
230
|
+
fetchMock.mockResolvedValueOnce(
|
|
231
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
232
|
+
);
|
|
233
|
+
const page = {
|
|
234
|
+
goto: vi.fn(async () => {}),
|
|
235
|
+
waitForFunction: vi.fn(() => Promise.reject(new Error("browser timeout"))),
|
|
236
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => "{}") })),
|
|
237
|
+
};
|
|
238
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
239
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
240
|
+
|
|
241
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
242
|
+
.rejects.toThrow("browser timeout");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("grep_app_search aborts with a default Cancelled error when no reason is given", async () => {
|
|
246
|
+
const { tools } = makePi();
|
|
247
|
+
fetchMock.mockResolvedValueOnce(
|
|
248
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
249
|
+
);
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const page = {
|
|
252
|
+
goto: vi.fn(async () => {}),
|
|
253
|
+
waitForFunction: vi.fn(() => new Promise((_resolve, reject) => {
|
|
254
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
|
|
255
|
+
})),
|
|
256
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => "{}") })),
|
|
257
|
+
};
|
|
258
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
259
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
260
|
+
|
|
261
|
+
const promise = tools.get("grep_app_search").execute("call1", { query: "x" }, controller.signal);
|
|
262
|
+
await vi.waitFor(() => expect(page.goto).toHaveBeenCalled());
|
|
263
|
+
controller.abort(); // no reason → signal.reason is a DOMException in Node
|
|
264
|
+
await expect(promise).rejects.toThrow();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("grep_app_search waitForFunction predicate checks the page body prefix", async () => {
|
|
268
|
+
const { tools } = makePi();
|
|
269
|
+
fetchMock.mockResolvedValueOnce(
|
|
270
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
271
|
+
);
|
|
272
|
+
let predicate: (() => boolean) | null = null;
|
|
273
|
+
const page = {
|
|
274
|
+
goto: vi.fn(async () => {}),
|
|
275
|
+
waitForFunction: vi.fn((fn: () => boolean) => {
|
|
276
|
+
predicate = fn;
|
|
277
|
+
return Promise.resolve();
|
|
278
|
+
}),
|
|
279
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => '{"hits":{"total":0,"hits":[]}}') })),
|
|
280
|
+
};
|
|
281
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
282
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
283
|
+
|
|
284
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "x" }, undefined);
|
|
285
|
+
expect(result.content[0].text).toBe("No matches found.");
|
|
286
|
+
|
|
287
|
+
// The predicate reads document.body.innerText.
|
|
288
|
+
const originalDocument = (globalThis as any).document;
|
|
289
|
+
(globalThis as any).document = { body: { innerText: "{\"hits\":1}" } };
|
|
290
|
+
try {
|
|
291
|
+
expect(predicate!()).toBe(true);
|
|
292
|
+
(globalThis as any).document = { body: { innerText: "not json" } };
|
|
293
|
+
expect(predicate!()).toBe(false);
|
|
294
|
+
} finally {
|
|
295
|
+
(globalThis as any).document = originalDocument;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("grep_app_search truncates long output and reports the full path", async () => {
|
|
300
|
+
const { tools } = makePi();
|
|
301
|
+
// A snippet with 100 table rows produces 100 output lines per hit;
|
|
302
|
+
// 30 hits × 100 lines exceeds the 2000-line truncation limit.
|
|
303
|
+
const rows = Array.from({ length: 100 }, (_, i) => `<tr data-line="${i + 1}"><td><pre>line ${i}</pre></td></tr>`).join("");
|
|
304
|
+
const snippet = `<table>${rows}</table>`;
|
|
305
|
+
const hits = Array.from({ length: 30 }, (_, i) => ({
|
|
306
|
+
repo: "owner/repo",
|
|
307
|
+
branch: "main",
|
|
308
|
+
path: `src/file${i}.ts`,
|
|
309
|
+
content: { snippet },
|
|
310
|
+
}));
|
|
311
|
+
fetchMock.mockResolvedValueOnce(okResponse(JSON.stringify({ hits: { total: 30, hits } })));
|
|
312
|
+
|
|
313
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "x" }, undefined);
|
|
314
|
+
expect(result.content[0].text).toContain("[Output truncated to");
|
|
315
|
+
expect(result.details.fullOutputPath).toBeTruthy();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("grep_app_fetch fetches a file with line ranges", async () => {
|
|
319
|
+
const { tools } = makePi();
|
|
320
|
+
fetchMock.mockResolvedValueOnce(okResponse("line1\nline2\nline3\nline4"));
|
|
321
|
+
|
|
322
|
+
const result = await tools.get("grep_app_fetch").execute("call2", {
|
|
323
|
+
repo: "owner/repo",
|
|
324
|
+
path: "src/a.ts",
|
|
325
|
+
ref: "v1.0",
|
|
326
|
+
startLine: 2,
|
|
327
|
+
endLine: 3,
|
|
328
|
+
}, undefined);
|
|
329
|
+
expect(result.content[0].text).toBe("2: line2\n3: line3");
|
|
330
|
+
expect(result.details).toMatchObject({ repo: "owner/repo", path: "src/a.ts", ref: "v1.0", startLine: 2, endLine: 3, totalLines: 4 });
|
|
331
|
+
const [url] = fetchMock.mock.calls[0];
|
|
332
|
+
expect(String(url)).toBe("https://raw.githubusercontent.com/owner/repo/v1.0/src/a.ts");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("grep_app_fetch defaults ref and endLine", async () => {
|
|
336
|
+
const { tools } = makePi();
|
|
337
|
+
fetchMock.mockResolvedValueOnce(okResponse("a\nb\nc"));
|
|
338
|
+
|
|
339
|
+
const result = await tools.get("grep_app_fetch").execute("call2", { repo: "owner/repo", path: "src/a.ts" }, undefined);
|
|
340
|
+
expect(result.content[0].text).toBe("1: a\n2: b\n3: c");
|
|
341
|
+
expect(result.details.ref).toBe("HEAD");
|
|
342
|
+
expect(result.details.endLine).toBe(3);
|
|
343
|
+
const [url] = fetchMock.mock.calls[0];
|
|
344
|
+
expect(String(url)).toBe("https://raw.githubusercontent.com/owner/repo/HEAD/src/a.ts");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("grep_app_fetch rejects endLine before startLine", async () => {
|
|
348
|
+
const { tools } = makePi();
|
|
349
|
+
await expect(tools.get("grep_app_fetch").execute("call2", {
|
|
350
|
+
repo: "owner/repo",
|
|
351
|
+
path: "src/a.ts",
|
|
352
|
+
startLine: 5,
|
|
353
|
+
endLine: 2,
|
|
354
|
+
}, undefined)).rejects.toThrow("endLine must be greater than or equal to startLine.");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("grep_app_fetch returns empty-file text for empty ranges", async () => {
|
|
358
|
+
const { tools } = makePi();
|
|
359
|
+
fetchMock.mockResolvedValueOnce(okResponse("a\nb\nc"));
|
|
360
|
+
// startLine beyond the file length produces an empty selection.
|
|
361
|
+
const result = await tools.get("grep_app_fetch").execute("call2", { repo: "owner/repo", path: "src/a.ts", startLine: 5 }, undefined);
|
|
362
|
+
expect(result.content[0].text).toBe("(empty file or range)");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("grep_app_fetch truncates long output", async () => {
|
|
366
|
+
const { tools } = makePi();
|
|
367
|
+
const lines = Array.from({ length: 3000 }, (_, i) => `line ${i}`).join("\n");
|
|
368
|
+
fetchMock.mockResolvedValueOnce(okResponse(lines));
|
|
369
|
+
const result = await tools.get("grep_app_fetch").execute("call2", { repo: "owner/repo", path: "src/a.ts" }, undefined);
|
|
370
|
+
expect(result.content[0].text).toContain("[Output truncated to");
|
|
371
|
+
expect(result.details.fullOutputPath).toBeTruthy();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("renderResult renders success and error states", () => {
|
|
375
|
+
const { tools } = makePi();
|
|
376
|
+
const search = tools.get("grep_app_search");
|
|
377
|
+
const success = search.renderResult(
|
|
378
|
+
{ content: [], details: { total: 10, page: 2, resultCount: 3 } },
|
|
379
|
+
{},
|
|
380
|
+
theme,
|
|
381
|
+
{ isError: false },
|
|
382
|
+
);
|
|
383
|
+
expect(success.render(80).join("\n")).toContain("✓ grep.app: 3 results · page 2 · 10 total matches");
|
|
384
|
+
|
|
385
|
+
const error = search.renderResult({ content: [] }, {}, theme, { isError: true });
|
|
386
|
+
expect(error.render(80).join("\n")).toContain("✗ grep.app search failed");
|
|
387
|
+
|
|
388
|
+
const noDetails = search.renderResult({ content: [] }, {}, theme, { isError: false });
|
|
389
|
+
expect(noDetails.render(80).join("\n")).toContain("0 results");
|
|
390
|
+
|
|
391
|
+
const fetchTool = tools.get("grep_app_fetch");
|
|
392
|
+
const fetchSuccess = fetchTool.renderResult(
|
|
393
|
+
{ content: [], details: { repo: "o/r", path: "src/a.ts", startLine: 1, endLine: 5 } },
|
|
394
|
+
{},
|
|
395
|
+
theme,
|
|
396
|
+
{ isError: false },
|
|
397
|
+
);
|
|
398
|
+
expect(fetchSuccess.render(80).join("\n")).toContain("✓ GitHub: o/r/src/a.ts · lines 1-5");
|
|
399
|
+
|
|
400
|
+
const fetchError = fetchTool.renderResult({ content: [] }, {}, theme, { isError: true });
|
|
401
|
+
expect(fetchError.render(80).join("\n")).toContain("✗ GitHub file fetch failed");
|
|
402
|
+
|
|
403
|
+
const fetchNoDetails = fetchTool.renderResult({ content: [] }, {}, theme, { isError: false });
|
|
404
|
+
expect(fetchNoDetails.render(80).join("\n")).toContain("✓ GitHub: ?/? · lines 1-?");
|
|
405
|
+
});
|
|
51
406
|
});
|
|
@@ -97,7 +97,12 @@ async function fetchWithBrowser(url: URL | string, signal: AbortSignal | undefin
|
|
|
97
97
|
await page.waitForFunction(() => document.body.innerText.trimStart().startsWith("{"), undefined, { timeout: 15_000 });
|
|
98
98
|
return await page.locator("body").innerText();
|
|
99
99
|
} catch (error) {
|
|
100
|
-
if (signal?.aborted)
|
|
100
|
+
if (signal?.aborted) {
|
|
101
|
+
// signal.reason is always set after abort() in Node, so the ?? fallback
|
|
102
|
+
// is unreachable.
|
|
103
|
+
/* v8 ignore next 1 */
|
|
104
|
+
throw signal.reason ?? new Error("Cancelled");
|
|
105
|
+
}
|
|
101
106
|
throw error;
|
|
102
107
|
} finally {
|
|
103
108
|
signal?.removeEventListener("abort", abort);
|