@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.
- package/README.md +4 -0
- package/extensions/astro-agents/discovery.test.ts +152 -0
- package/extensions/astro-agents/index.test.ts +208 -0
- package/extensions/astro-agents/spawn.test.ts +218 -0
- package/extensions/claude-globals/index.test.ts +77 -0
- package/extensions/gemini-image/credentials.test.ts +130 -0
- package/extensions/gemini-image/credentials.ts +53 -0
- package/extensions/gemini-image/index.test.ts +400 -0
- package/extensions/gemini-image/index.ts +375 -0
- package/extensions/gemini-image/models.test.ts +45 -0
- package/extensions/gemini-image/models.ts +50 -0
- package/extensions/gemini-image/parseTweaks.test.ts +119 -0
- package/extensions/gemini-image/parseTweaks.ts +191 -0
- package/extensions/gemini-image/pricing.test.ts +95 -0
- package/extensions/gemini-image/pricing.ts +102 -0
- package/extensions/grimoire/index.test.ts +244 -0
- package/extensions/multi-edit/classic.test.ts +274 -0
- package/extensions/multi-edit/classic.ts +435 -0
- package/extensions/multi-edit/diff.test.ts +65 -0
- package/extensions/multi-edit/diff.ts +143 -0
- package/extensions/multi-edit/index.test.ts +170 -0
- package/extensions/multi-edit/index.ts +267 -0
- package/extensions/multi-edit/patch.test.ts +242 -0
- package/extensions/multi-edit/patch.ts +463 -0
- package/extensions/multi-edit/types.ts +53 -0
- package/extensions/multi-edit/workspace.test.ts +165 -0
- package/extensions/multi-edit/workspace.ts +85 -0
- package/package.json +9 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
describe("credentials", () => {
|
|
7
|
+
let fakeHome: string;
|
|
8
|
+
const originalHome = process.env.HOME;
|
|
9
|
+
const originalGemini = process.env.GEMINI_API_KEY;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
fakeHome = mkdtempSync(join(tmpdir(), "gem-cred-"));
|
|
13
|
+
process.env.HOME = fakeHome;
|
|
14
|
+
delete process.env.GEMINI_API_KEY;
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
20
|
+
process.env.HOME = originalHome;
|
|
21
|
+
if (originalGemini === undefined) delete process.env.GEMINI_API_KEY;
|
|
22
|
+
else process.env.GEMINI_API_KEY = originalGemini;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("resolves env var first when set", async () => {
|
|
26
|
+
process.env.GEMINI_API_KEY = "from-env";
|
|
27
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
28
|
+
expect(resolveExistingApiKey()).toBe("from-env");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns undefined when neither env nor file set", async () => {
|
|
32
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
33
|
+
expect(resolveExistingApiKey()).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads auth.json google.key when env is absent", async () => {
|
|
37
|
+
const dir = join(fakeHome, ".pi", "agent");
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(dir, "auth.json"),
|
|
41
|
+
JSON.stringify({ google: { type: "api_key", key: "from-file" } }),
|
|
42
|
+
"utf-8",
|
|
43
|
+
);
|
|
44
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
45
|
+
expect(resolveExistingApiKey()).toBe("from-file");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns undefined when auth.json google entry is malformed", async () => {
|
|
49
|
+
const dir = join(fakeHome, ".pi", "agent");
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
writeFileSync(join(dir, "auth.json"), JSON.stringify({ google: "not-an-object" }), "utf-8");
|
|
52
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
53
|
+
expect(resolveExistingApiKey()).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns undefined when auth.json is corrupt JSON", async () => {
|
|
57
|
+
const dir = join(fakeHome, ".pi", "agent");
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
writeFileSync(join(dir, "auth.json"), "{not-json", "utf-8");
|
|
60
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
61
|
+
expect(resolveExistingApiKey()).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("persistApiKey creates auth.json with google entry", async () => {
|
|
65
|
+
const { persistApiKey } = await import("./credentials.ts");
|
|
66
|
+
persistApiKey("new-key");
|
|
67
|
+
const data = JSON.parse(readFileSync(join(fakeHome, ".pi", "agent", "auth.json"), "utf-8"));
|
|
68
|
+
expect(data.google).toEqual({ type: "api_key", key: "new-key" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("persistApiKey merges with existing entries", async () => {
|
|
72
|
+
const dir = join(fakeHome, ".pi", "agent");
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(dir, "auth.json"),
|
|
76
|
+
JSON.stringify({ anthropic: { type: "api_key", key: "ant-k" } }),
|
|
77
|
+
"utf-8",
|
|
78
|
+
);
|
|
79
|
+
const { persistApiKey } = await import("./credentials.ts");
|
|
80
|
+
persistApiKey("new-google-key");
|
|
81
|
+
const data = JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"));
|
|
82
|
+
expect(data.anthropic).toEqual({ type: "api_key", key: "ant-k" });
|
|
83
|
+
expect(data.google).toEqual({ type: "api_key", key: "new-google-key" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("persistApiKey overwrites an existing google entry", async () => {
|
|
87
|
+
const dir = join(fakeHome, ".pi", "agent");
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
writeFileSync(
|
|
90
|
+
join(dir, "auth.json"),
|
|
91
|
+
JSON.stringify({ google: { type: "api_key", key: "old" } }),
|
|
92
|
+
"utf-8",
|
|
93
|
+
);
|
|
94
|
+
const { persistApiKey } = await import("./credentials.ts");
|
|
95
|
+
persistApiKey("replacement");
|
|
96
|
+
const data = JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"));
|
|
97
|
+
expect(data.google.key).toBe("replacement");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("env var with whitespace is trimmed", async () => {
|
|
101
|
+
process.env.GEMINI_API_KEY = " spaced ";
|
|
102
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
103
|
+
expect(resolveExistingApiKey()).toBe("spaced");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("empty string env var is treated as unset", async () => {
|
|
107
|
+
process.env.GEMINI_API_KEY = " ";
|
|
108
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
109
|
+
expect(resolveExistingApiKey()).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("auth file exists but lacks google entry -> undefined", async () => {
|
|
113
|
+
const dir = join(fakeHome, ".pi", "agent");
|
|
114
|
+
mkdirSync(dir, { recursive: true });
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(dir, "auth.json"),
|
|
117
|
+
JSON.stringify({ anthropic: { type: "api_key", key: "x" } }),
|
|
118
|
+
"utf-8",
|
|
119
|
+
);
|
|
120
|
+
const { resolveExistingApiKey } = await import("./credentials.ts");
|
|
121
|
+
expect(resolveExistingApiKey()).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("persistApiKey creates parent dirs implicitly if needed", async () => {
|
|
125
|
+
expect(existsSync(join(fakeHome, ".pi", "agent"))).toBe(false);
|
|
126
|
+
const { persistApiKey } = await import("./credentials.ts");
|
|
127
|
+
persistApiKey("x");
|
|
128
|
+
expect(existsSync(join(fakeHome, ".pi", "agent", "auth.json"))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
|
|
6
|
+
const PROVIDER_KEY = "google";
|
|
7
|
+
|
|
8
|
+
interface AuthEntry {
|
|
9
|
+
type: "api_key";
|
|
10
|
+
key: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type AuthFile = Record<string, AuthEntry | unknown>;
|
|
14
|
+
|
|
15
|
+
function readAuthFile(): AuthFile {
|
|
16
|
+
if (!existsSync(AUTH_FILE)) return {};
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(AUTH_FILE, "utf-8");
|
|
19
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
20
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as AuthFile) : {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeAuthFile(data: AuthFile): void {
|
|
27
|
+
mkdirSync(dirname(AUTH_FILE), { recursive: true });
|
|
28
|
+
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
29
|
+
try {
|
|
30
|
+
chmodSync(AUTH_FILE, 0o600);
|
|
31
|
+
} catch {
|
|
32
|
+
// non-fatal: filesystem may not support chmod (e.g. some Windows envs)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveExistingApiKey(): string | undefined {
|
|
37
|
+
const fromEnv = process.env.GEMINI_API_KEY?.trim();
|
|
38
|
+
if (fromEnv) return fromEnv;
|
|
39
|
+
|
|
40
|
+
const authFile = readAuthFile();
|
|
41
|
+
const entry = authFile[PROVIDER_KEY];
|
|
42
|
+
if (entry && typeof entry === "object" && "type" in entry && entry.type === "api_key") {
|
|
43
|
+
const { key } = entry as AuthEntry;
|
|
44
|
+
if (typeof key === "string" && key.trim().length > 0) return key.trim();
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function persistApiKey(key: string): void {
|
|
50
|
+
const authFile = readAuthFile();
|
|
51
|
+
authFile[PROVIDER_KEY] = { type: "api_key", key };
|
|
52
|
+
writeAuthFile(authFile);
|
|
53
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const generateContentMock = vi.fn();
|
|
7
|
+
const generateImagesMock = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("@google/genai", () => {
|
|
10
|
+
class FakeGoogleGenAI {
|
|
11
|
+
models = {
|
|
12
|
+
generateContent: generateContentMock,
|
|
13
|
+
generateImages: generateImagesMock,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return { GoogleGenAI: FakeGoogleGenAI };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
type ToolDef = {
|
|
20
|
+
name: string;
|
|
21
|
+
execute: (
|
|
22
|
+
id: string,
|
|
23
|
+
input: Record<string, unknown>,
|
|
24
|
+
signal: AbortSignal | undefined,
|
|
25
|
+
onUpdate: unknown,
|
|
26
|
+
ctx: {
|
|
27
|
+
cwd: string;
|
|
28
|
+
hasUI: boolean;
|
|
29
|
+
ui: {
|
|
30
|
+
notify: ReturnType<typeof vi.fn>;
|
|
31
|
+
confirm: ReturnType<typeof vi.fn>;
|
|
32
|
+
input: ReturnType<typeof vi.fn>;
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
) => Promise<{ content: unknown[]; details: Record<string, unknown> }>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void } {
|
|
39
|
+
const tools: ToolDef[] = [];
|
|
40
|
+
return { tools, registerTool: (t) => tools.push(t) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type InputResponse = string | undefined;
|
|
44
|
+
|
|
45
|
+
function makeCtx(opts: { hasUI?: boolean; cwd?: string; inputs?: InputResponse[] } = {}): {
|
|
46
|
+
cwd: string;
|
|
47
|
+
hasUI: boolean;
|
|
48
|
+
ui: {
|
|
49
|
+
notify: ReturnType<typeof vi.fn>;
|
|
50
|
+
confirm: ReturnType<typeof vi.fn>;
|
|
51
|
+
input: ReturnType<typeof vi.fn>;
|
|
52
|
+
};
|
|
53
|
+
} {
|
|
54
|
+
const responses = [...(opts.inputs ?? [])];
|
|
55
|
+
return {
|
|
56
|
+
cwd: opts.cwd ?? "/tmp/cwd",
|
|
57
|
+
hasUI: opts.hasUI ?? true,
|
|
58
|
+
ui: {
|
|
59
|
+
notify: vi.fn(),
|
|
60
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
61
|
+
input: vi.fn().mockImplementation(() => Promise.resolve(responses.shift())),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("gemini-image extension", () => {
|
|
67
|
+
let fakeHome: string;
|
|
68
|
+
let fakeCwd: string;
|
|
69
|
+
const originalHome = process.env.HOME;
|
|
70
|
+
const originalKey = process.env.GEMINI_API_KEY;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
fakeHome = mkdtempSync(join(tmpdir(), "gem-ext-"));
|
|
74
|
+
fakeCwd = mkdtempSync(join(tmpdir(), "gem-cwd-"));
|
|
75
|
+
process.env.HOME = fakeHome;
|
|
76
|
+
process.env.GEMINI_API_KEY = "test-key";
|
|
77
|
+
vi.resetModules();
|
|
78
|
+
generateContentMock.mockReset();
|
|
79
|
+
generateImagesMock.mockReset();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
rmSync(fakeHome, { recursive: true, force: true });
|
|
84
|
+
rmSync(fakeCwd, { recursive: true, force: true });
|
|
85
|
+
process.env.HOME = originalHome;
|
|
86
|
+
if (originalKey === undefined) delete process.env.GEMINI_API_KEY;
|
|
87
|
+
else process.env.GEMINI_API_KEY = originalKey;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
async function load(): Promise<{ tool: ToolDef }> {
|
|
91
|
+
const mod = await import("./index.ts");
|
|
92
|
+
const pi = makePi();
|
|
93
|
+
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
94
|
+
return { tool: pi.tools[0] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pngImageResponse(): void {
|
|
98
|
+
generateContentMock.mockResolvedValue({
|
|
99
|
+
candidates: [
|
|
100
|
+
{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "BASE64DATA" } }] } },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("registers tool named gemini_image", async () => {
|
|
106
|
+
const { tool } = await load();
|
|
107
|
+
expect(tool.name).toBe("gemini_image");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("skip_confirm bypasses review and calls generateContent", async () => {
|
|
111
|
+
pngImageResponse();
|
|
112
|
+
const { tool } = await load();
|
|
113
|
+
const ctx = makeCtx({ cwd: fakeCwd });
|
|
114
|
+
const res = await tool.execute("t", { prompt: "a cat", skip_confirm: true }, undefined, undefined, ctx);
|
|
115
|
+
expect(res.details.model).toBe("gemini-2.5-flash-image");
|
|
116
|
+
// ui.input should NOT be called — skip_confirm bypasses the review loop
|
|
117
|
+
expect(ctx.ui.input).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("images + text returned in content; image AFTER text", async () => {
|
|
121
|
+
pngImageResponse();
|
|
122
|
+
const { tool } = await load();
|
|
123
|
+
const res = await tool.execute(
|
|
124
|
+
"t",
|
|
125
|
+
{ prompt: "x", skip_confirm: true },
|
|
126
|
+
undefined,
|
|
127
|
+
undefined,
|
|
128
|
+
makeCtx({ cwd: fakeCwd }),
|
|
129
|
+
);
|
|
130
|
+
expect((res.content as Array<{ type: string }>)[0].type).toBe("text");
|
|
131
|
+
expect((res.content as Array<{ type: string }>)[1].type).toBe("image");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("always saves to <cwd>/.gemini-images by default", async () => {
|
|
135
|
+
pngImageResponse();
|
|
136
|
+
const { tool } = await load();
|
|
137
|
+
const res = await tool.execute(
|
|
138
|
+
"t",
|
|
139
|
+
{ prompt: "a-thing", skip_confirm: true },
|
|
140
|
+
undefined,
|
|
141
|
+
undefined,
|
|
142
|
+
makeCtx({ cwd: fakeCwd }),
|
|
143
|
+
);
|
|
144
|
+
const paths = res.details.savedPaths as string[];
|
|
145
|
+
expect(paths).toHaveLength(1);
|
|
146
|
+
expect(paths[0]).toContain(".gemini-images");
|
|
147
|
+
expect(readdirSync(join(fakeCwd, ".gemini-images"))).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("save_to override writes into that directory (resolved against cwd)", async () => {
|
|
151
|
+
pngImageResponse();
|
|
152
|
+
const { tool } = await load();
|
|
153
|
+
const res = await tool.execute(
|
|
154
|
+
"t",
|
|
155
|
+
{ prompt: "x", skip_confirm: true, save_to: "out" },
|
|
156
|
+
undefined,
|
|
157
|
+
undefined,
|
|
158
|
+
makeCtx({ cwd: fakeCwd }),
|
|
159
|
+
);
|
|
160
|
+
const paths = res.details.savedPaths as string[];
|
|
161
|
+
expect(paths[0]).toContain(join(fakeCwd, "out"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("silently saves API key on first paste (no confirm prompt)", async () => {
|
|
165
|
+
delete process.env.GEMINI_API_KEY;
|
|
166
|
+
pngImageResponse();
|
|
167
|
+
const { tool } = await load();
|
|
168
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["pasted-key"] });
|
|
169
|
+
await tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, ctx);
|
|
170
|
+
// Only ONE input call (the key paste). confirm never used.
|
|
171
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
|
173
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/saved/i), "info");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("errors when API key missing and no UI available", async () => {
|
|
177
|
+
delete process.env.GEMINI_API_KEY;
|
|
178
|
+
const { tool } = await load();
|
|
179
|
+
const ctx = makeCtx({ hasUI: false, cwd: fakeCwd });
|
|
180
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/no interactive UI/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("errors when user provides empty key at prompt", async () => {
|
|
184
|
+
delete process.env.GEMINI_API_KEY;
|
|
185
|
+
const { tool } = await load();
|
|
186
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: [" "] });
|
|
187
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/No API key/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("review loop: empty input confirms and proceeds", async () => {
|
|
191
|
+
pngImageResponse();
|
|
192
|
+
const { tool } = await load();
|
|
193
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: [""] });
|
|
194
|
+
await tool.execute("t", { prompt: "hello" }, undefined, undefined, ctx);
|
|
195
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("review loop: 'cancel' throws cancelled", async () => {
|
|
199
|
+
const { tool } = await load();
|
|
200
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["cancel"] });
|
|
201
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/cancelled/i);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("review loop: undefined input (escape) throws cancelled", async () => {
|
|
205
|
+
const { tool } = await load();
|
|
206
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: [undefined] });
|
|
207
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/cancelled/i);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("review loop: tweak '2K' updates size then Enter confirms", async () => {
|
|
211
|
+
pngImageResponse();
|
|
212
|
+
const { tool } = await load();
|
|
213
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["2K", ""] });
|
|
214
|
+
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
215
|
+
// Two input calls: one for 2K tweak, one empty confirm
|
|
216
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(2);
|
|
217
|
+
// The second input's preview message should mention 2K
|
|
218
|
+
const secondPreview = ctx.ui.input.mock.calls[1][1] as string;
|
|
219
|
+
expect(secondPreview).toContain("2K");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("review loop: tweak 'use imagen ultra, 16:9, 3 images' + empty confirms and calls Imagen", async () => {
|
|
223
|
+
generateImagesMock.mockResolvedValue({
|
|
224
|
+
generatedImages: [
|
|
225
|
+
{ image: { imageBytes: "IMG1", mimeType: "image/png" } },
|
|
226
|
+
{ image: { imageBytes: "IMG2", mimeType: "image/png" } },
|
|
227
|
+
{ image: { imageBytes: "IMG3", mimeType: "image/png" } },
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
const { tool } = await load();
|
|
231
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["use imagen ultra 16:9 3 images", ""] });
|
|
232
|
+
const res = await tool.execute("t", { prompt: "landscape" }, undefined, undefined, ctx);
|
|
233
|
+
expect(res.details.model).toBe("imagen-4.0-ultra-generate-001");
|
|
234
|
+
expect(res.details.images).toBe(3);
|
|
235
|
+
const cfg = generateImagesMock.mock.calls[0][0].config;
|
|
236
|
+
expect(cfg.numberOfImages).toBe(3);
|
|
237
|
+
expect(cfg.aspectRatio).toBe("16:9");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("review loop: tweak that fails validation is rejected with notify warning, user can re-tweak", async () => {
|
|
241
|
+
pngImageResponse();
|
|
242
|
+
generateImagesMock.mockResolvedValue({
|
|
243
|
+
generatedImages: [{ image: { imageBytes: "IMG", mimeType: "image/png" } }],
|
|
244
|
+
});
|
|
245
|
+
const { tool } = await load();
|
|
246
|
+
// user tries 4K with Imagen → rejected; then switches back and confirms
|
|
247
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["imagen fast 4K", "imagen fast 2K", ""] });
|
|
248
|
+
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
249
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Change rejected/), "warning");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("review loop: unrecognized token surfaces as warning", async () => {
|
|
253
|
+
pngImageResponse();
|
|
254
|
+
const { tool } = await load();
|
|
255
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["frobnicate plz", ""] });
|
|
256
|
+
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
257
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Didn't understand/), "warning");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects unknown model at call time", async () => {
|
|
261
|
+
const { tool } = await load();
|
|
262
|
+
await expect(
|
|
263
|
+
tool.execute(
|
|
264
|
+
"t",
|
|
265
|
+
{ prompt: "x", model: "bogus-model", skip_confirm: true },
|
|
266
|
+
undefined,
|
|
267
|
+
undefined,
|
|
268
|
+
makeCtx({ cwd: fakeCwd }),
|
|
269
|
+
),
|
|
270
|
+
).rejects.toThrow(/Unknown model/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("rejects invalid aspect_ratio for Imagen at validate time", async () => {
|
|
274
|
+
const { tool } = await load();
|
|
275
|
+
await expect(
|
|
276
|
+
tool.execute(
|
|
277
|
+
"t",
|
|
278
|
+
{
|
|
279
|
+
prompt: "x",
|
|
280
|
+
model: "imagen-4.0-generate-001",
|
|
281
|
+
aspect_ratio: "21:9",
|
|
282
|
+
skip_confirm: true,
|
|
283
|
+
},
|
|
284
|
+
undefined,
|
|
285
|
+
undefined,
|
|
286
|
+
makeCtx({ cwd: fakeCwd }),
|
|
287
|
+
),
|
|
288
|
+
).rejects.toThrow(/aspect_ratio .* Imagen/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("rejects 4K for Imagen", async () => {
|
|
292
|
+
const { tool } = await load();
|
|
293
|
+
await expect(
|
|
294
|
+
tool.execute(
|
|
295
|
+
"t",
|
|
296
|
+
{
|
|
297
|
+
prompt: "x",
|
|
298
|
+
model: "imagen-4.0-generate-001",
|
|
299
|
+
image_size: "4K",
|
|
300
|
+
skip_confirm: true,
|
|
301
|
+
},
|
|
302
|
+
undefined,
|
|
303
|
+
undefined,
|
|
304
|
+
makeCtx({ cwd: fakeCwd }),
|
|
305
|
+
),
|
|
306
|
+
).rejects.toThrow(/image_size .* Imagen/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("rejects input_images on Imagen", async () => {
|
|
310
|
+
const { tool } = await load();
|
|
311
|
+
await expect(
|
|
312
|
+
tool.execute(
|
|
313
|
+
"t",
|
|
314
|
+
{
|
|
315
|
+
prompt: "x",
|
|
316
|
+
model: "imagen-4.0-generate-001",
|
|
317
|
+
input_images: ["/tmp/x.png"],
|
|
318
|
+
skip_confirm: true,
|
|
319
|
+
},
|
|
320
|
+
undefined,
|
|
321
|
+
undefined,
|
|
322
|
+
makeCtx({ cwd: fakeCwd }),
|
|
323
|
+
),
|
|
324
|
+
).rejects.toThrow(/input_images/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("rejects negative_prompt on Gemini native", async () => {
|
|
328
|
+
const { tool } = await load();
|
|
329
|
+
await expect(
|
|
330
|
+
tool.execute(
|
|
331
|
+
"t",
|
|
332
|
+
{ prompt: "x", negative_prompt: "ugly", skip_confirm: true },
|
|
333
|
+
undefined,
|
|
334
|
+
undefined,
|
|
335
|
+
makeCtx({ cwd: fakeCwd }),
|
|
336
|
+
),
|
|
337
|
+
).rejects.toThrow(/negative_prompt/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("rejects seed on Gemini native", async () => {
|
|
341
|
+
const { tool } = await load();
|
|
342
|
+
await expect(
|
|
343
|
+
tool.execute("t", { prompt: "x", seed: 42, skip_confirm: true }, undefined, undefined, makeCtx({ cwd: fakeCwd })),
|
|
344
|
+
).rejects.toThrow(/seed/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("throws when model returns no images", async () => {
|
|
348
|
+
generateContentMock.mockResolvedValue({ candidates: [] });
|
|
349
|
+
const { tool } = await load();
|
|
350
|
+
await expect(
|
|
351
|
+
tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, makeCtx({ cwd: fakeCwd })),
|
|
352
|
+
).rejects.toThrow(/no images/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("input_images are read from disk and passed as inlineData", async () => {
|
|
356
|
+
const sourceDir = mkdtempSync(join(tmpdir(), "gem-input-"));
|
|
357
|
+
const imgPath = join(sourceDir, "input.png");
|
|
358
|
+
writeFileSync(imgPath, "binary-image");
|
|
359
|
+
pngImageResponse();
|
|
360
|
+
const { tool } = await load();
|
|
361
|
+
const ctx = makeCtx({ cwd: fakeCwd });
|
|
362
|
+
await tool.execute(
|
|
363
|
+
"t",
|
|
364
|
+
{ prompt: "edit", input_images: [imgPath], skip_confirm: true },
|
|
365
|
+
undefined,
|
|
366
|
+
undefined,
|
|
367
|
+
ctx,
|
|
368
|
+
);
|
|
369
|
+
const callArgs = generateContentMock.mock.calls[0][0];
|
|
370
|
+
expect(callArgs.contents.some((c: Record<string, unknown>) => "inlineData" in c)).toBe(true);
|
|
371
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("Imagen config: numberOfImages + negative_prompt + seed + person_generation all pass through", async () => {
|
|
375
|
+
generateImagesMock.mockResolvedValue({
|
|
376
|
+
generatedImages: [{ image: { imageBytes: "X", mimeType: "image/png" } }],
|
|
377
|
+
});
|
|
378
|
+
const { tool } = await load();
|
|
379
|
+
await tool.execute(
|
|
380
|
+
"t",
|
|
381
|
+
{
|
|
382
|
+
prompt: "x",
|
|
383
|
+
model: "imagen-4.0-ultra-generate-001",
|
|
384
|
+
number_of_images: 2,
|
|
385
|
+
negative_prompt: "blurry",
|
|
386
|
+
seed: 1337,
|
|
387
|
+
person_generation: "allow_adult",
|
|
388
|
+
skip_confirm: true,
|
|
389
|
+
},
|
|
390
|
+
undefined,
|
|
391
|
+
undefined,
|
|
392
|
+
makeCtx({ cwd: fakeCwd }),
|
|
393
|
+
);
|
|
394
|
+
const cfg = generateImagesMock.mock.calls[0][0].config;
|
|
395
|
+
expect(cfg.numberOfImages).toBe(2);
|
|
396
|
+
expect(cfg.negativePrompt).toBe("blurry");
|
|
397
|
+
expect(cfg.seed).toBe(1337);
|
|
398
|
+
expect(cfg.personGeneration).toBe("allow_adult");
|
|
399
|
+
});
|
|
400
|
+
});
|