@astrofoundry/pi-astro 0.5.1 → 0.6.1

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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Parse a free-text review/tweak utterance into partial parameter updates.
3
+ *
4
+ * Intentionally conservative: we recognize a known vocabulary (models,
5
+ * aspect ratios, sizes, numeric counts, person-gen tokens, and explicit
6
+ * `prompt:` / `negative:` / `seed:` / `save to <path>` phrases). Unknown
7
+ * tokens are returned in `unrecognized` so the caller can surface them.
8
+ */
9
+
10
+ import {
11
+ ALL_MODELS,
12
+ GEMINI_ASPECT_RATIOS,
13
+ IMAGEN_ASPECT_RATIOS,
14
+ GEMINI_IMAGE_SIZES,
15
+ PERSON_GENERATION_VALUES,
16
+ type ImageModel,
17
+ } from "./models.ts";
18
+
19
+ export interface TweakPatch {
20
+ prompt?: string;
21
+ model?: ImageModel;
22
+ aspect_ratio?: string;
23
+ image_size?: "1K" | "2K" | "4K";
24
+ number_of_images?: number;
25
+ negative_prompt?: string;
26
+ person_generation?: (typeof PERSON_GENERATION_VALUES)[number];
27
+ seed?: number;
28
+ save_to?: string;
29
+ verbatim?: boolean;
30
+ }
31
+
32
+ export interface ParseOutcome {
33
+ patch: TweakPatch;
34
+ unrecognized: string[];
35
+ intent: "confirm" | "cancel" | "tweak";
36
+ }
37
+
38
+ const MODEL_ALIASES: Record<string, ImageModel> = {
39
+ flash: "gemini-2.5-flash-image",
40
+ "flash image": "gemini-2.5-flash-image",
41
+ "gemini flash": "gemini-2.5-flash-image",
42
+ "2.5 flash": "gemini-2.5-flash-image",
43
+ "nano banana 2": "gemini-3.1-flash-image-preview",
44
+ "nano banana": "gemini-3.1-flash-image-preview",
45
+ "3.1 flash": "gemini-3.1-flash-image-preview",
46
+ pro: "gemini-3-pro-image-preview",
47
+ "pro model": "gemini-3-pro-image-preview",
48
+ "gemini pro": "gemini-3-pro-image-preview",
49
+ "3 pro": "gemini-3-pro-image-preview",
50
+ "nano banana pro": "gemini-3-pro-image-preview",
51
+ imagen: "imagen-4.0-generate-001",
52
+ "imagen standard": "imagen-4.0-generate-001",
53
+ "imagen 4": "imagen-4.0-generate-001",
54
+ "imagen fast": "imagen-4.0-fast-generate-001",
55
+ "imagen 4 fast": "imagen-4.0-fast-generate-001",
56
+ "imagen ultra": "imagen-4.0-ultra-generate-001",
57
+ "imagen 4 ultra": "imagen-4.0-ultra-generate-001",
58
+ };
59
+
60
+ const ALL_ASPECT_RATIOS = [
61
+ ...new Set([...GEMINI_ASPECT_RATIOS, ...IMAGEN_ASPECT_RATIOS]),
62
+ ];
63
+ const ALL_SIZES = [...GEMINI_IMAGE_SIZES];
64
+
65
+ function trimQuotes(s: string): string {
66
+ return s.replace(/^['"`](.*)['"`]$/s, "$1").trim();
67
+ }
68
+
69
+ export function parseTweaks(raw: string): ParseOutcome {
70
+ const patch: TweakPatch = {};
71
+ const unrecognized: string[] = [];
72
+ const trimmed = raw.trim();
73
+
74
+ if (trimmed === "") {
75
+ return { patch, unrecognized, intent: "confirm" };
76
+ }
77
+ if (/^(y|yes|ok|go|confirm|proceed)$/i.test(trimmed)) {
78
+ return { patch, unrecognized, intent: "confirm" };
79
+ }
80
+ if (/^(n|no|cancel|abort|quit|exit)$/i.test(trimmed)) {
81
+ return { patch, unrecognized, intent: "cancel" };
82
+ }
83
+
84
+ let rest = trimmed;
85
+
86
+ const extract = (pattern: RegExp, onMatch: (m: RegExpExecArray) => void): void => {
87
+ const match = pattern.exec(rest);
88
+ if (match) {
89
+ onMatch(match);
90
+ rest = (rest.slice(0, match.index) + rest.slice(match.index + match[0].length)).trim();
91
+ }
92
+ };
93
+
94
+ // prompt: <rest> / use prompt <rest> (greedy — takes rest of string unless another keyword follows)
95
+ extract(
96
+ /(?:^|\s)(?:use\s+prompt\s*[:=]?|prompt\s*[:=])\s*(.+?)(?=(?:\s+(?:model|size|image_size|aspect|ratio|images?|n=|count|negative|seed|save|person|verbatim|raw|as-?is|dont_allow|allow_adult|allow_all)\b)|$)/i,
97
+ (m) => {
98
+ patch.prompt = trimQuotes(m[1]);
99
+ },
100
+ );
101
+
102
+ extract(/(?:^|\s)(?:negative(?:_prompt)?)\s*[:=]\s*(.+?)(?=$|\s+(?:model|size|aspect|ratio|images?|n=|count|seed|save|person|verbatim|raw)\b)/i, (m) => {
103
+ patch.negative_prompt = trimQuotes(m[1]);
104
+ });
105
+
106
+ extract(/(?:^|\s)seed\s*[:=]?\s*(-?\d+)/i, (m) => {
107
+ patch.seed = Number(m[1]);
108
+ });
109
+
110
+ extract(/(?:^|\s)save\s+to\s+(\S+)/i, (m) => {
111
+ patch.save_to = m[1];
112
+ });
113
+
114
+ extract(/(?:^|\s)(verbatim|as-?is|raw)\b/i, () => {
115
+ patch.verbatim = true;
116
+ });
117
+
118
+ extract(/(?:^|\s)(\d+)\s*(?:images?|imgs?)\b/i, (m) => {
119
+ patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
120
+ });
121
+ extract(/(?:^|\s)(?:n|count)\s*[:=]\s*(\d+)/i, (m) => {
122
+ patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
123
+ });
124
+ extract(/(?:^|\s)give\s+me\s+(\d+)\b/i, (m) => {
125
+ patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
126
+ });
127
+
128
+ for (const size of ALL_SIZES) {
129
+ const re = new RegExp(`(?:^|\\s)${size}(?=\\s|$)`, "i");
130
+ const m = re.exec(rest);
131
+ if (m) {
132
+ patch.image_size = size;
133
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
134
+ break;
135
+ }
136
+ }
137
+
138
+ for (const ratio of ALL_ASPECT_RATIOS) {
139
+ const re = new RegExp(`(?:^|\\s)${ratio.replace(":", "\\s*:\\s*")}(?=\\s|$)`);
140
+ const m = re.exec(rest);
141
+ if (m) {
142
+ patch.aspect_ratio = ratio;
143
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
144
+ break;
145
+ }
146
+ }
147
+
148
+ for (const pg of PERSON_GENERATION_VALUES) {
149
+ const re = new RegExp(`(?:^|\\s)${pg}(?=\\s|$)`, "i");
150
+ const m = re.exec(rest);
151
+ if (m) {
152
+ patch.person_generation = pg;
153
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
154
+ break;
155
+ }
156
+ }
157
+
158
+ // Full model id exact match
159
+ for (const model of ALL_MODELS) {
160
+ const idx = rest.toLowerCase().indexOf(model.toLowerCase());
161
+ if (idx !== -1) {
162
+ patch.model = model;
163
+ rest = (rest.slice(0, idx) + rest.slice(idx + model.length)).trim();
164
+ break;
165
+ }
166
+ }
167
+
168
+ // Model aliases (only if model not set yet) — check longest first so "nano banana pro" beats "pro"
169
+ if (!patch.model) {
170
+ const aliases = Object.keys(MODEL_ALIASES).sort((a, b) => b.length - a.length);
171
+ for (const alias of aliases) {
172
+ const re = new RegExp(`(?:^|\\s)${alias.replace(/\s+/g, "\\s+")}(?=\\s|$|[.,])`, "i");
173
+ const m = re.exec(rest);
174
+ if (m) {
175
+ patch.model = MODEL_ALIASES[alias];
176
+ rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
177
+ break;
178
+ }
179
+ }
180
+ }
181
+
182
+ // Split leftover into whitespace-separated tokens; non-filler ones are "unrecognized".
183
+ const leftover = rest.split(/\s+/).filter((t) => t.length > 0 && !/^(use|and|with|,)$/i.test(t));
184
+ if (leftover.length > 0) unrecognized.push(...leftover);
185
+
186
+ return {
187
+ patch,
188
+ unrecognized,
189
+ intent: Object.keys(patch).length > 0 ? "tweak" : "tweak",
190
+ };
191
+ }
@@ -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
+ });