@astrofoundry/pi-astro 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -0
  2. package/extensions/astro-agents/agents/code-reviewer.md +0 -2
  3. package/extensions/astro-agents/agents/google-tech-lead.md +0 -2
  4. package/extensions/astro-agents/agents/spec-writer.md +0 -2
  5. package/extensions/astro-agents/agents/tester-api.md +0 -2
  6. package/extensions/astro-agents/agents/tester-ui.md +0 -2
  7. package/extensions/astro-agents/agents/ui-architect.md +0 -2
  8. package/extensions/astro-agents/agents/ui-design-system.md +0 -2
  9. package/extensions/astro-agents/agents/ui-frontend-developer.md +0 -2
  10. package/extensions/astro-agents/discovery.test.ts +152 -0
  11. package/extensions/astro-agents/index.test.ts +208 -0
  12. package/extensions/astro-agents/index.ts +22 -4
  13. package/extensions/astro-agents/spawn.test.ts +218 -0
  14. package/extensions/claude-globals/index.test.ts +77 -0
  15. package/extensions/gemini-image/credentials.test.ts +130 -0
  16. package/extensions/gemini-image/credentials.ts +53 -0
  17. package/extensions/gemini-image/index.test.ts +369 -0
  18. package/extensions/gemini-image/index.ts +313 -0
  19. package/extensions/gemini-image/models.test.ts +45 -0
  20. package/extensions/gemini-image/models.ts +50 -0
  21. package/extensions/gemini-image/pricing.test.ts +95 -0
  22. package/extensions/gemini-image/pricing.ts +102 -0
  23. package/extensions/grimoire/index.test.ts +244 -0
  24. package/extensions/multi-edit/classic.test.ts +274 -0
  25. package/extensions/multi-edit/classic.ts +435 -0
  26. package/extensions/multi-edit/diff.test.ts +65 -0
  27. package/extensions/multi-edit/diff.ts +143 -0
  28. package/extensions/multi-edit/index.test.ts +170 -0
  29. package/extensions/multi-edit/index.ts +267 -0
  30. package/extensions/multi-edit/patch.test.ts +242 -0
  31. package/extensions/multi-edit/patch.ts +463 -0
  32. package/extensions/multi-edit/types.ts +53 -0
  33. package/extensions/multi-edit/workspace.test.ts +165 -0
  34. package/extensions/multi-edit/workspace.ts +85 -0
  35. package/package.json +9 -3
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ALL_MODELS,
4
+ DEFAULT_MODEL,
5
+ GEMINI_ASPECT_RATIOS,
6
+ GEMINI_NATIVE_MODELS,
7
+ IMAGEN_ASPECT_RATIOS,
8
+ IMAGEN_MODELS,
9
+ isGeminiNative,
10
+ isImagen,
11
+ } from "./models.ts";
12
+
13
+ describe("models", () => {
14
+ it("has 3 Gemini native + 3 Imagen = 6 models", () => {
15
+ expect(GEMINI_NATIVE_MODELS).toHaveLength(3);
16
+ expect(IMAGEN_MODELS).toHaveLength(3);
17
+ expect(ALL_MODELS).toHaveLength(6);
18
+ });
19
+
20
+ it("default is gemini-2.5-flash-image", () => {
21
+ expect(DEFAULT_MODEL).toBe("gemini-2.5-flash-image");
22
+ });
23
+
24
+ it("Gemini aspect ratios include 4:5 and 5:4 (missing from the junior's impl)", () => {
25
+ expect(GEMINI_ASPECT_RATIOS).toContain("4:5");
26
+ expect(GEMINI_ASPECT_RATIOS).toContain("5:4");
27
+ });
28
+
29
+ it("Imagen has 5 aspect ratios (no 2:3 / 3:2 / 4:5 / 5:4 / 21:9)", () => {
30
+ expect(IMAGEN_ASPECT_RATIOS).toHaveLength(5);
31
+ expect(IMAGEN_ASPECT_RATIOS).not.toContain("2:3");
32
+ });
33
+
34
+ it("isGeminiNative narrows correctly", () => {
35
+ expect(isGeminiNative("gemini-2.5-flash-image")).toBe(true);
36
+ expect(isGeminiNative("imagen-4.0-generate-001")).toBe(false);
37
+ expect(isGeminiNative("unknown")).toBe(false);
38
+ });
39
+
40
+ it("isImagen narrows correctly", () => {
41
+ expect(isImagen("imagen-4.0-ultra-generate-001")).toBe(true);
42
+ expect(isImagen("gemini-3-pro-image-preview")).toBe(false);
43
+ expect(isImagen("unknown")).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,50 @@
1
+ export const GEMINI_NATIVE_MODELS = [
2
+ "gemini-2.5-flash-image",
3
+ "gemini-3.1-flash-image-preview",
4
+ "gemini-3-pro-image-preview",
5
+ ] as const;
6
+
7
+ export const IMAGEN_MODELS = [
8
+ "imagen-4.0-fast-generate-001",
9
+ "imagen-4.0-generate-001",
10
+ "imagen-4.0-ultra-generate-001",
11
+ ] as const;
12
+
13
+ export type GeminiNativeModel = (typeof GEMINI_NATIVE_MODELS)[number];
14
+ export type ImagenModel = (typeof IMAGEN_MODELS)[number];
15
+ export type ImageModel = GeminiNativeModel | ImagenModel;
16
+
17
+ export const ALL_MODELS: readonly ImageModel[] = [
18
+ ...GEMINI_NATIVE_MODELS,
19
+ ...IMAGEN_MODELS,
20
+ ];
21
+
22
+ export const DEFAULT_MODEL: ImageModel = "gemini-2.5-flash-image";
23
+
24
+ export function isGeminiNative(model: string): model is GeminiNativeModel {
25
+ return (GEMINI_NATIVE_MODELS as readonly string[]).includes(model);
26
+ }
27
+
28
+ export function isImagen(model: string): model is ImagenModel {
29
+ return (IMAGEN_MODELS as readonly string[]).includes(model);
30
+ }
31
+
32
+ export const GEMINI_ASPECT_RATIOS = [
33
+ "1:1",
34
+ "2:3",
35
+ "3:2",
36
+ "3:4",
37
+ "4:3",
38
+ "4:5",
39
+ "5:4",
40
+ "9:16",
41
+ "16:9",
42
+ "21:9",
43
+ ] as const;
44
+
45
+ export const IMAGEN_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
46
+
47
+ export const GEMINI_IMAGE_SIZES = ["1K", "2K", "4K"] as const;
48
+ export const IMAGEN_IMAGE_SIZES = ["1K", "2K"] as const;
49
+
50
+ export const PERSON_GENERATION_VALUES = ["dont_allow", "allow_adult", "allow_all"] as const;
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
3
+
4
+ describe("estimateCost", () => {
5
+ it("Imagen 4 Fast: $0.02 × N images", () => {
6
+ const e = estimateCost({
7
+ model: "imagen-4.0-fast-generate-001",
8
+ prompt: "cat",
9
+ numberOfImages: 3,
10
+ });
11
+ expect(e.estimatedUsd).toBeCloseTo(0.06, 5);
12
+ expect(e.breakdown).toContain("0.02");
13
+ });
14
+
15
+ it("Imagen 4 Standard single image", () => {
16
+ const e = estimateCost({
17
+ model: "imagen-4.0-generate-001",
18
+ prompt: "x",
19
+ numberOfImages: 1,
20
+ });
21
+ expect(e.estimatedUsd).toBeCloseTo(0.04, 5);
22
+ });
23
+
24
+ it("Imagen 4 Ultra", () => {
25
+ const e = estimateCost({
26
+ model: "imagen-4.0-ultra-generate-001",
27
+ prompt: "x",
28
+ numberOfImages: 4,
29
+ });
30
+ expect(e.estimatedUsd).toBeCloseTo(0.24, 5);
31
+ });
32
+
33
+ it("Gemini 3 Pro 4K is more expensive than 2K", () => {
34
+ const at4k = estimateCost({
35
+ model: "gemini-3-pro-image-preview",
36
+ prompt: "small",
37
+ numberOfImages: 1,
38
+ imageSize: "4K",
39
+ });
40
+ const at2k = estimateCost({
41
+ model: "gemini-3-pro-image-preview",
42
+ prompt: "small",
43
+ numberOfImages: 1,
44
+ imageSize: "2K",
45
+ });
46
+ expect(at4k.estimatedUsd).toBeGreaterThan(at2k.estimatedUsd);
47
+ });
48
+
49
+ it("Gemini 3 Pro prompt tokens contribute to cost (long prompt > short)", () => {
50
+ const longPrompt = "x".repeat(10_000);
51
+ const shortEst = estimateCost({
52
+ model: "gemini-3-pro-image-preview",
53
+ prompt: "x",
54
+ numberOfImages: 1,
55
+ });
56
+ const longEst = estimateCost({
57
+ model: "gemini-3-pro-image-preview",
58
+ prompt: longPrompt,
59
+ numberOfImages: 1,
60
+ });
61
+ expect(longEst.estimatedUsd).toBeGreaterThan(shortEst.estimatedUsd);
62
+ });
63
+
64
+ it("Flash estimate scales linearly with numberOfImages", () => {
65
+ const one = estimateCost({
66
+ model: "gemini-2.5-flash-image",
67
+ prompt: "x",
68
+ numberOfImages: 1,
69
+ });
70
+ const four = estimateCost({
71
+ model: "gemini-2.5-flash-image",
72
+ prompt: "x",
73
+ numberOfImages: 4,
74
+ });
75
+ // Output dominates; roughly 4x
76
+ expect(four.estimatedUsd).toBeGreaterThan(3.5 * one.estimatedUsd * 0.99);
77
+ });
78
+ });
79
+
80
+ describe("formatUsd", () => {
81
+ it("renders sub-cent as <$0.01", () => {
82
+ expect(formatUsd(0.002)).toBe("<$0.01");
83
+ });
84
+
85
+ it("renders 4 decimals for normal amounts", () => {
86
+ expect(formatUsd(0.1234)).toBe("$0.1234");
87
+ });
88
+ });
89
+
90
+ describe("hasPricing", () => {
91
+ it("returns true for all known models", () => {
92
+ expect(hasPricing("gemini-2.5-flash-image")).toBe(true);
93
+ expect(hasPricing("imagen-4.0-fast-generate-001")).toBe(true);
94
+ });
95
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Gemini image-generation price table.
3
+ *
4
+ * Last verified: 2026-04-24 via ai.google.dev/gemini-api/docs/pricing.
5
+ * Prices drift — edit when re-checked.
6
+ */
7
+
8
+ import type { ImageModel } from "./models.ts";
9
+
10
+ interface ImagenPrice {
11
+ kind: "flat";
12
+ perImage: number; // USD
13
+ }
14
+
15
+ interface GeminiNativePrice {
16
+ kind: "token";
17
+ // $ per 1M input tokens
18
+ inputPer1M: number;
19
+ // $ per output image at 1K/2K resolution
20
+ outputPerImage1Kor2K: number;
21
+ // $ per output image at 4K resolution
22
+ outputPerImage4K: number;
23
+ // Approx output tokens per image (for reference; not used in estimate)
24
+ tokensPerImage: number;
25
+ }
26
+
27
+ type ModelPrice = ImagenPrice | GeminiNativePrice;
28
+
29
+ const PRICES: Record<ImageModel, ModelPrice> = {
30
+ "imagen-4.0-fast-generate-001": { kind: "flat", perImage: 0.02 },
31
+ "imagen-4.0-generate-001": { kind: "flat", perImage: 0.04 },
32
+ "imagen-4.0-ultra-generate-001": { kind: "flat", perImage: 0.06 },
33
+ "gemini-2.5-flash-image": {
34
+ kind: "token",
35
+ inputPer1M: 0.3,
36
+ outputPerImage1Kor2K: 0.039,
37
+ outputPerImage4K: 0.039,
38
+ tokensPerImage: 1290,
39
+ },
40
+ "gemini-3.1-flash-image-preview": {
41
+ kind: "token",
42
+ inputPer1M: 0.3,
43
+ outputPerImage1Kor2K: 0.039,
44
+ outputPerImage4K: 0.078,
45
+ tokensPerImage: 1290,
46
+ },
47
+ "gemini-3-pro-image-preview": {
48
+ kind: "token",
49
+ inputPer1M: 2.0,
50
+ outputPerImage1Kor2K: 0.134,
51
+ outputPerImage4K: 0.24,
52
+ tokensPerImage: 1290,
53
+ },
54
+ };
55
+
56
+ export interface EstimateInput {
57
+ model: ImageModel;
58
+ prompt: string;
59
+ numberOfImages: number;
60
+ imageSize?: "1K" | "2K" | "4K";
61
+ inputImagesBytes?: number;
62
+ }
63
+
64
+ export interface EstimateResult {
65
+ estimatedUsd: number;
66
+ breakdown: string;
67
+ }
68
+
69
+ const CHARS_PER_TOKEN_APPROX = 4;
70
+
71
+ export function estimateCost(input: EstimateInput): EstimateResult {
72
+ const price = PRICES[input.model];
73
+ if (price.kind === "flat") {
74
+ const usd = price.perImage * input.numberOfImages;
75
+ return {
76
+ estimatedUsd: usd,
77
+ breakdown: `${input.numberOfImages} × $${price.perImage.toFixed(2)}/image`,
78
+ };
79
+ }
80
+
81
+ const inputTokens = Math.ceil(input.prompt.length / CHARS_PER_TOKEN_APPROX);
82
+ const inputCost = (inputTokens / 1_000_000) * price.inputPer1M;
83
+ const perImage =
84
+ input.imageSize === "4K" ? price.outputPerImage4K : price.outputPerImage1Kor2K;
85
+ const outputCost = perImage * input.numberOfImages;
86
+ const total = inputCost + outputCost;
87
+ return {
88
+ estimatedUsd: total,
89
+ breakdown:
90
+ `${input.numberOfImages} × $${perImage.toFixed(3)} (${input.imageSize ?? "1K/2K"}) + ` +
91
+ `~${inputTokens} input tokens`,
92
+ };
93
+ }
94
+
95
+ export function formatUsd(usd: number): string {
96
+ if (usd < 0.01) return `<$0.01`;
97
+ return `$${usd.toFixed(4)}`;
98
+ }
99
+
100
+ export function hasPricing(model: ImageModel): boolean {
101
+ return model in PRICES;
102
+ }
@@ -0,0 +1,244 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // Mock node:child_process BEFORE importing the module under test.
5
+ vi.mock("node:child_process", () => {
6
+ const mod = {
7
+ execFileSync: vi.fn(),
8
+ spawn: vi.fn(),
9
+ };
10
+ return mod;
11
+ });
12
+
13
+ type SpawnFactory = () => { proc: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: (sig: string) => void }; emitClose: (code: number) => void; emitError: (err: Error) => void; emitData: (where: "stdout" | "stderr", chunk: string) => void };
14
+
15
+ function makeFakeProc(): ReturnType<SpawnFactory> {
16
+ const proc = Object.assign(new EventEmitter(), {
17
+ stdout: new EventEmitter(),
18
+ stderr: new EventEmitter(),
19
+ kill: vi.fn(),
20
+ });
21
+ return {
22
+ proc,
23
+ emitClose: (code: number) => proc.emit("close", code),
24
+ emitError: (err: Error) => proc.emit("error", err),
25
+ emitData: (where, chunk) => proc[where].emit("data", Buffer.from(chunk)),
26
+ };
27
+ }
28
+
29
+ interface CapturedTool {
30
+ name: string;
31
+ description: string;
32
+ promptGuidelines?: string[];
33
+ execute: (id: string, input: { query: string; source?: string; top?: number }, signal?: AbortSignal, onUpdate?: unknown, ctx?: unknown) => Promise<unknown>;
34
+ }
35
+
36
+ interface CapturedPi {
37
+ registerTool: (tool: CapturedTool) => void;
38
+ tools: CapturedTool[];
39
+ }
40
+
41
+ function makePi(): CapturedPi {
42
+ const tools: CapturedTool[] = [];
43
+ return {
44
+ tools,
45
+ registerTool: (tool) => {
46
+ tools.push(tool);
47
+ },
48
+ };
49
+ }
50
+
51
+ function makeCtx(): { ui: { notify: ReturnType<typeof vi.fn> }; notifications: Array<[string, string | undefined]> } {
52
+ const notifications: Array<[string, string | undefined]> = [];
53
+ return {
54
+ notifications,
55
+ ui: {
56
+ notify: vi.fn((msg: string, type?: string) => {
57
+ notifications.push([msg, type]);
58
+ }),
59
+ },
60
+ };
61
+ }
62
+
63
+ describe("grimoire extension", () => {
64
+ beforeEach(() => {
65
+ vi.resetModules();
66
+ vi.clearAllMocks();
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.clearAllMocks();
71
+ });
72
+
73
+ it("loads --help into promptGuidelines when grimoire is available", async () => {
74
+ const { execFileSync } = await import("node:child_process");
75
+ vi.mocked(execFileSync).mockReturnValue("FAKE HELP OUTPUT" as unknown as ReturnType<typeof execFileSync>);
76
+
77
+ const mod = await import("./index.ts");
78
+ const pi = makePi();
79
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
80
+
81
+ const tool = pi.tools[0];
82
+ expect(tool.name).toBe("grimoire");
83
+ expect(tool.promptGuidelines?.some((g) => g.includes("FAKE HELP OUTPUT"))).toBe(true);
84
+ });
85
+
86
+ it("falls back to 'not available' promptGuidelines when grimoire CLI is missing", async () => {
87
+ const { execFileSync } = await import("node:child_process");
88
+ vi.mocked(execFileSync).mockImplementation(() => {
89
+ throw new Error("ENOENT");
90
+ });
91
+
92
+ const mod = await import("./index.ts");
93
+ const pi = makePi();
94
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
95
+
96
+ expect(pi.tools[0].promptGuidelines?.[0]).toMatch(/NOT available/i);
97
+ });
98
+
99
+ it("execute throws when CLI was not available at load time", async () => {
100
+ const { execFileSync } = await import("node:child_process");
101
+ vi.mocked(execFileSync).mockImplementation(() => {
102
+ throw new Error("ENOENT");
103
+ });
104
+ const mod = await import("./index.ts");
105
+ const pi = makePi();
106
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
107
+ const ctx = makeCtx();
108
+ await expect(
109
+ pi.tools[0].execute("t1", { query: "x" }, undefined, undefined, ctx),
110
+ ).rejects.toThrow(/not installed/i);
111
+ expect(ctx.notifications.some((n) => n[1] === "error")).toBe(true);
112
+ });
113
+
114
+ it("execute returns stdout when grimoire exits 0 with results", async () => {
115
+ const { execFileSync, spawn } = await import("node:child_process");
116
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
117
+ const fake = makeFakeProc();
118
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
119
+ const mod = await import("./index.ts");
120
+ const pi = makePi();
121
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
122
+ const ctx = makeCtx();
123
+ const promise = pi.tools[0].execute("t1", { query: "react hooks" }, undefined, undefined, ctx);
124
+ fake.emitData("stdout", "result line 1\nresult line 2\n");
125
+ fake.emitClose(0);
126
+ const result = (await promise) as { content: Array<{ text: string }> };
127
+ expect(result.content[0].text).toContain("result line 1");
128
+ });
129
+
130
+ it("execute with source + top passes them through as CLI args", async () => {
131
+ const { execFileSync, spawn } = await import("node:child_process");
132
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
133
+ const fake = makeFakeProc();
134
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
135
+ const mod = await import("./index.ts");
136
+ const pi = makePi();
137
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
138
+ const ctx = makeCtx();
139
+ const promise = pi.tools[0].execute("t", { query: "q", source: "react", top: 3 }, undefined, undefined, ctx);
140
+ fake.emitData("stdout", "out");
141
+ fake.emitClose(0);
142
+ await promise;
143
+ const callArgs = vi.mocked(spawn).mock.calls[0][1] as string[];
144
+ expect(callArgs).toContain("--source");
145
+ expect(callArgs).toContain("react");
146
+ expect(callArgs).toContain("--top");
147
+ expect(callArgs).toContain("3");
148
+ });
149
+
150
+ it("execute notifies warning and returns soft result on no-results", async () => {
151
+ const { execFileSync, spawn } = await import("node:child_process");
152
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
153
+ const fake = makeFakeProc();
154
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
155
+ const mod = await import("./index.ts");
156
+ const pi = makePi();
157
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
158
+ const ctx = makeCtx();
159
+ const promise = pi.tools[0].execute("t", { query: "zzz" }, undefined, undefined, ctx);
160
+ fake.emitData("stdout", "No results found.\n");
161
+ fake.emitClose(0);
162
+ const result = (await promise) as { content: Array<{ text: string }> };
163
+ expect(result.content[0].text).toMatch(/No results found/);
164
+ expect(ctx.notifications.some((n) => n[1] === "warning")).toBe(true);
165
+ });
166
+
167
+ it("execute also treats empty stdout as no-results and suggests widening scope when source given", async () => {
168
+ const { execFileSync, spawn } = await import("node:child_process");
169
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
170
+ const fake = makeFakeProc();
171
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
172
+ const mod = await import("./index.ts");
173
+ const pi = makePi();
174
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
175
+ const ctx = makeCtx();
176
+ const promise = pi.tools[0].execute("t", { query: "zzz", source: "react" }, undefined, undefined, ctx);
177
+ fake.emitData("stdout", "");
178
+ fake.emitClose(0);
179
+ const result = (await promise) as { content: Array<{ text: string }> };
180
+ expect(result.content[0].text).toMatch(/widening scope/);
181
+ });
182
+
183
+ it("execute rejects on non-zero exit code", async () => {
184
+ const { execFileSync, spawn } = await import("node:child_process");
185
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
186
+ const fake = makeFakeProc();
187
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
188
+ const mod = await import("./index.ts");
189
+ const pi = makePi();
190
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
191
+ const ctx = makeCtx();
192
+ const promise = pi.tools[0].execute("t", { query: "q" }, undefined, undefined, ctx);
193
+ fake.emitData("stderr", "bad source");
194
+ fake.emitClose(1);
195
+ await expect(promise).rejects.toThrow(/exited with code 1/);
196
+ });
197
+
198
+ it("execute rejects on ENOENT at spawn time with install guidance + notify", async () => {
199
+ const { execFileSync, spawn } = await import("node:child_process");
200
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
201
+ const fake = makeFakeProc();
202
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
203
+ const mod = await import("./index.ts");
204
+ const pi = makePi();
205
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
206
+ const ctx = makeCtx();
207
+ const promise = pi.tools[0].execute("t", { query: "q" }, undefined, undefined, ctx);
208
+ const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" });
209
+ fake.emitError(err);
210
+ await expect(promise).rejects.toThrow(/not installed/i);
211
+ expect(ctx.notifications.some((n) => n[1] === "error")).toBe(true);
212
+ });
213
+
214
+ it("execute rejects on non-ENOENT spawn error", async () => {
215
+ const { execFileSync, spawn } = await import("node:child_process");
216
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
217
+ const fake = makeFakeProc();
218
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
219
+ const mod = await import("./index.ts");
220
+ const pi = makePi();
221
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
222
+ const ctx = makeCtx();
223
+ const promise = pi.tools[0].execute("t", { query: "q" }, undefined, undefined, ctx);
224
+ fake.emitError(new Error("mystery"));
225
+ await expect(promise).rejects.toThrow(/failed to run/i);
226
+ });
227
+
228
+ it("execute kills process on abort signal", async () => {
229
+ const { execFileSync, spawn } = await import("node:child_process");
230
+ vi.mocked(execFileSync).mockReturnValue("help" as unknown as ReturnType<typeof execFileSync>);
231
+ const fake = makeFakeProc();
232
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
233
+ const mod = await import("./index.ts");
234
+ const pi = makePi();
235
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
236
+ const ctx = makeCtx();
237
+ const ac = new AbortController();
238
+ const promise = pi.tools[0].execute("t", { query: "q" }, ac.signal, undefined, ctx);
239
+ ac.abort();
240
+ expect(fake.proc.kill).toHaveBeenCalledWith("SIGTERM");
241
+ fake.emitClose(0);
242
+ await expect(promise).resolves.toBeDefined();
243
+ });
244
+ });