postgresai 0.16.0-dev.1 → 0.16.0-dev.10

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.
@@ -407,12 +407,14 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
407
407
  }
408
408
  }, { timeout: TEST_TIMEOUT });
409
409
 
410
- // 60s timeout for PostgreSQL startup + multiple SQL queries in slow CI
411
- test("explain_generic validates input and prevents SQL injection", async () => {
410
+ // Security regression for #387: explain_generic was a SECURITY DEFINER helper that
411
+ // ran caller-supplied SQL through EXPLAIN. The planner folds IMMUTABLE/STABLE functions
412
+ // at plan time in the definer's (superuser) context, making it an RCE/data-exfil vector.
413
+ // It has been removed; prepare-db must NOT create it.
414
+ test("explain_generic is not created by prepare-db (RCE helper removed, #387)", async () => {
412
415
  pg = await createTempPostgres();
413
416
 
414
417
  try {
415
- // Run init first
416
418
  {
417
419
  const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
418
420
  expect(r.status).toBe(0);
@@ -422,82 +424,10 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
422
424
  await c.connect();
423
425
 
424
426
  try {
425
- // Check PostgreSQL version - generic_plan requires 16+
426
- const versionRes = await c.query("show server_version_num");
427
- const version = parseInt(versionRes.rows[0].server_version_num, 10);
428
-
429
- if (version < 160000) {
430
- // Skip this test on older PostgreSQL versions
431
- console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
432
- return;
433
- }
434
-
435
- // Test 1: Empty query should be rejected
436
- await expect(
437
- c.query("select postgres_ai.explain_generic('')")
438
- ).rejects.toThrow(/query cannot be empty/);
439
-
440
- // Test 2: Null query should be rejected
441
- await expect(
442
- c.query("select postgres_ai.explain_generic(null)")
443
- ).rejects.toThrow(/query cannot be empty/);
444
-
445
- // Test 3: Multiple statements (semicolon in middle) should be rejected
446
- await expect(
447
- c.query("select postgres_ai.explain_generic('select 1; select 2')")
448
- ).rejects.toThrow(/semicolon|multiple statements/i);
449
-
450
- // Test 4: Trailing semicolon should be stripped and work
451
- {
452
- const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
453
- expect(res.rows[0].result).toBeTruthy();
454
- expect(res.rows[0].result).toMatch(/Result/i);
455
- }
456
-
457
- // Test 5: Valid query should work
458
- {
459
- const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
460
- expect(res.rows[0].result).toBeTruthy();
461
- }
462
-
463
- // Test 6: JSON format should work
464
- {
465
- const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
466
- const plan = JSON.parse(res.rows[0].result);
467
- expect(Array.isArray(plan)).toBe(true);
468
- expect(plan[0].Plan).toBeTruthy();
469
- }
470
-
471
- // Test 7: Whitespace-only query should be rejected
472
- await expect(
473
- c.query("select postgres_ai.explain_generic(' ')")
474
- ).rejects.toThrow(/query cannot be empty/);
475
-
476
- // Test 8: Semicolon in string literal is rejected (documented limitation)
477
- // Note: This is a known limitation - the simple heuristic cannot parse SQL strings
478
- await expect(
479
- c.query("select postgres_ai.explain_generic('select ''hello;world''')")
480
- ).rejects.toThrow(/semicolon/i);
481
-
482
- // Test 9: SQL comments should work (no semicolons)
483
- {
484
- const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
485
- expect(res.rows[0].result).toBeTruthy();
486
- }
487
-
488
- // Test 10: Escaped quotes should work (no semicolons)
489
- {
490
- const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
491
- expect(res.rows[0].result).toBeTruthy();
492
- }
493
-
494
- // Test 11: Case-insensitive format parameter
495
- {
496
- const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
497
- const plan = JSON.parse(res.rows[0].result);
498
- expect(Array.isArray(plan)).toBe(true);
499
- }
500
-
427
+ const res = await c.query(
428
+ "select count(*)::int as n from pg_proc where proname = 'explain_generic' and pronamespace = (select oid from pg_namespace where nspname = 'postgres_ai')"
429
+ );
430
+ expect(res.rows[0].n).toBe(0);
501
431
  } finally {
502
432
  await c.end();
503
433
  }
@@ -0,0 +1,469 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolve } from "path";
3
+ import { mkdtempSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ // Importing the bin module is safe: argv parsing is gated on `import.meta.main`
6
+ // (same pattern as monitoring.test.ts).
7
+ import { inferCommandFromResult } from "../bin/postgres-ai";
8
+
9
+ // Sync runner for OFFLINE tests only (help / fail-fast). Never use it for a test
10
+ // that round-trips the in-process Bun.serve: spawnSync blocks the JS event loop, so
11
+ // the fake server can't answer and the two deadlock (see runCliAsync).
12
+ function runCli(args: string[], env: Record<string, string> = {}) {
13
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
14
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
15
+ const result = Bun.spawnSync([bunBin, cliPath, ...args], {
16
+ env: { ...process.env, ...env },
17
+ });
18
+ return {
19
+ status: result.exitCode,
20
+ stdout: new TextDecoder().decode(result.stdout),
21
+ stderr: new TextDecoder().decode(result.stderr),
22
+ };
23
+ }
24
+
25
+ // Async runner for tests that hit the fake server — keeps the event loop free.
26
+ async function runCliAsync(args: string[], env: Record<string, string> = {}) {
27
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
28
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
29
+ const proc = Bun.spawn([bunBin, cliPath, ...args], {
30
+ env: { ...process.env, ...env },
31
+ stdout: "pipe",
32
+ stderr: "pipe",
33
+ });
34
+ const [status, stdout, stderr] = await Promise.all([
35
+ proc.exited,
36
+ new Response(proc.stdout).text(),
37
+ new Response(proc.stderr).text(),
38
+ ]);
39
+ return { status, stdout, stderr };
40
+ }
41
+
42
+ /** Isolate config so tests never touch real user state; a shared dir threads sessions. */
43
+ function isolatedEnv(extra: Record<string, string> = {}) {
44
+ const cfgHome = mkdtempSync(resolve(tmpdir(), "joe-cli-test-"));
45
+ return { XDG_CONFIG_HOME: cfgHome, HOME: cfgHome, ...extra };
46
+ }
47
+
48
+ interface RecordedRequest {
49
+ pathname: string;
50
+ body: Record<string, unknown>;
51
+ }
52
+
53
+ /**
54
+ * Fake PostgREST server for the Joe rpcs (backend not built yet). A `plan` on a
55
+ * project whose id is COLD_PROJECT stays `running` (to exercise the resume path);
56
+ * project id 999 returns the ai_enabled=false PT403; everything else completes.
57
+ */
58
+ function startFakeApi() {
59
+ const requests: RecordedRequest[] = [];
60
+ const COLD_PROJECT = 77;
61
+ let nextId = 4711;
62
+ const commands = new Map<string, { command: string; projectId: number }>();
63
+
64
+ const server = Bun.serve({
65
+ hostname: "127.0.0.1",
66
+ port: 0,
67
+ async fetch(req) {
68
+ const url = new URL(req.url);
69
+ const bodyText = await req.text();
70
+ let body: Record<string, unknown> = {};
71
+ try {
72
+ body = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
73
+ } catch {
74
+ body = {};
75
+ }
76
+ requests.push({ pathname: url.pathname, body });
77
+
78
+ const respond = (obj: unknown, status = 200): Response =>
79
+ new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
80
+
81
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
82
+ return respond([
83
+ { id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_api_v2_enabled: true, tunnel_ready: true },
84
+ { id: 77, alias: "cold-db", name: "Cold DB", joe_ready: true, tunnel: true },
85
+ ]);
86
+ }
87
+
88
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_submit")) {
89
+ const projectId = Number(body.project_id);
90
+ if (projectId === 999) {
91
+ return respond({ message: "AI features disabled for this org — enable in AI Assistant Settings" }, 403);
92
+ }
93
+ const commandId = String(nextId++);
94
+ commands.set(commandId, { command: String(body.command), projectId });
95
+ // Echo the incoming session_id, or hand back a fresh one for a null session.
96
+ const sessionId = body.session_id != null ? String(body.session_id) : "88";
97
+ return respond({ command_id: commandId, session_id: sessionId, status: "queued" });
98
+ }
99
+
100
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_status")) {
101
+ const commandId = String(body.command_id);
102
+ const cmd = commands.get(commandId);
103
+ const status = cmd && cmd.projectId === COLD_PROJECT ? "running" : "done";
104
+ return respond({ command_id: commandId, status, error: null });
105
+ }
106
+
107
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_result")) {
108
+ const commandId = String(body.command_id);
109
+ if (commandId === "424242") {
110
+ // A server-side terminal timeout (the dispatcher gave up on the command).
111
+ return respond({ command_id: commandId, status: "timed_out", error: null });
112
+ }
113
+ if (commandId === "424243") {
114
+ // A terminal error result (sanitized message, per joe_error_sanitize).
115
+ return respond({ command_id: commandId, command: "exec", status: "error", error: "Query error" });
116
+ }
117
+ const cmd = commands.get(commandId);
118
+ if (cmd?.command === "exec") {
119
+ return respond({ command_id: commandId, command: "exec", status: "done", row_count: 0, result_rows: [], notices: ["CREATE INDEX"], error: null });
120
+ }
121
+ return respond({
122
+ command_id: commandId,
123
+ status: "done",
124
+ queryid: "7712349901234567890",
125
+ plan_fingerprint: "a1b2c3d4",
126
+ plan_text: "Seq Scan on users (cost=0.00..48210.00 rows=1 width=812)",
127
+ plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } },
128
+ error: null,
129
+ });
130
+ }
131
+
132
+ return new Response("not found", { status: 404 });
133
+ },
134
+ });
135
+
136
+ const baseUrl = `http://${server.hostname}:${server.port}/api/general`;
137
+ return { baseUrl, requests, stop: () => server.stop(true) };
138
+ }
139
+
140
+ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
141
+ test("top-level help lists the joe + dblab groups and top-level projects", () => {
142
+ const r = runCli(["--help"], isolatedEnv());
143
+ const out = `${r.stdout}\n${r.stderr}`;
144
+ // Joe verbs are grouped under `joe`; DBLab verbs under `dblab`. `projects`
145
+ // stays top-level (org-level, not a Joe endpoint).
146
+ for (const group of ["joe", "dblab", "projects"]) {
147
+ expect(out).toContain(group);
148
+ }
149
+ });
150
+
151
+ test("pgai joe --help lists all the Joe verbs", () => {
152
+ const r = runCli(["joe", "--help"], isolatedEnv());
153
+ expect(r.status).toBe(0);
154
+ const out = `${r.stdout}\n${r.stderr}`;
155
+ for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result", "status", "history"]) {
156
+ expect(out).toContain(verb);
157
+ }
158
+ });
159
+
160
+ test("pgai joe history (M1b) is recognized and degrades cleanly", () => {
161
+ // The brief's §2 documents `pgai joe history "users email" --project 12`, but the
162
+ // history-search backend is M1b/not built. The command must be recognized (NOT a
163
+ // Commander "unknown command") and exit 1 with an informative pending message.
164
+ const r = runCli(["joe", "history", "users email", "--project", "12"], isolatedEnv({ PGAI_API_KEY: "k" }));
165
+ expect(r.status).toBe(1);
166
+ const out = `${r.stdout}\n${r.stderr}`;
167
+ expect(out).toContain("not available yet");
168
+ expect(out).toContain("M1b");
169
+ expect(out).not.toContain("unknown command");
170
+ });
171
+
172
+ test("pgai projects prints the brief's table", async () => {
173
+ const api = startFakeApi();
174
+ try {
175
+ const r = await runCliAsync(["projects"], isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
176
+ expect(r.status).toBe(0);
177
+ expect(r.stdout).toContain("PROJECT_ID");
178
+ expect(r.stdout).toContain("ALIAS");
179
+ expect(r.stdout).toContain("JOE");
180
+ expect(r.stdout).toContain("TUNNEL");
181
+ expect(r.stdout).toContain("main-db");
182
+ expect(r.stdout).toContain("ready");
183
+ const listReq = api.requests.find((x) => x.pathname.endsWith("/rpc/projects_list"));
184
+ expect(listReq).toBeDefined();
185
+ } finally {
186
+ api.stop();
187
+ }
188
+ });
189
+
190
+ test("pgai joe plan --project 12 submits command='plan' and prints the plan", async () => {
191
+ const api = startFakeApi();
192
+ try {
193
+ const r = await runCliAsync(
194
+ ["joe", "plan", "select * from users where email = 'x@acme.io'", "--project", "12"],
195
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
196
+ );
197
+ expect(r.status).toBe(0);
198
+ expect(r.stdout).toContain("Seq Scan on users");
199
+ // client-side plan flag
200
+ expect(r.stdout).toContain("⚑");
201
+ const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
202
+ expect(submit?.body.command).toBe("plan");
203
+ expect(submit?.body.project_id).toBe(12);
204
+ // numeric project => no projects_list lookup
205
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(false);
206
+ } finally {
207
+ api.stop();
208
+ }
209
+ });
210
+
211
+ test("pgai joe plan --project main-db resolves the alias via projects_list", async () => {
212
+ const api = startFakeApi();
213
+ try {
214
+ const r = await runCliAsync(
215
+ ["joe", "plan", "select 1", "--project", "main-db"],
216
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
217
+ );
218
+ expect(r.status).toBe(0);
219
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
220
+ const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
221
+ expect(submit?.body.project_id).toBe(12);
222
+ } finally {
223
+ api.stop();
224
+ }
225
+ });
226
+
227
+ test("optimize loop: joe plan then joe exec reuse the same session on one project", async () => {
228
+ const api = startFakeApi();
229
+ const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
230
+ try {
231
+ // First command starts a fresh session; the server hands back session 88.
232
+ const first = await runCliAsync(["joe", "plan", "select 1", "--project", "12", "--new-session"], env);
233
+ expect(first.status).toBe(0);
234
+ // Second command auto-reuses the stored session (88) on the same project.
235
+ const second = await runCliAsync(["joe", "exec", "create index on users (email)", "--project", "12"], env);
236
+ expect(second.status).toBe(0);
237
+ expect(second.stdout).toContain("CREATE INDEX");
238
+
239
+ const submits = api.requests.filter((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
240
+ expect(submits.length).toBe(2);
241
+ // The re-plan/exec threads the session id returned by the first submit.
242
+ expect(submits[0].body.session_id).toBeNull(); // --new-session
243
+ expect(submits[1].body.session_id).toBe("88");
244
+ expect(submits[1].body.command).toBe("exec");
245
+ // exec mints a stable idempotency key.
246
+ expect(typeof submits[1].body.idempotency_key).toBe("string");
247
+ } finally {
248
+ api.stop();
249
+ }
250
+ });
251
+
252
+ test("cold clone exceeds the budget -> resume handle (exit 0), then pgai joe result", async () => {
253
+ const api = startFakeApi();
254
+ const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
255
+ try {
256
+ // project 77 (COLD) stays `running`; --budget 0 forces immediate resume.
257
+ const r = await runCliAsync(["joe", "plan", "select 1", "--project", "77", "--budget", "0"], env);
258
+ expect(r.status).toBe(0);
259
+ expect(r.stdout).toContain("resume:");
260
+ expect(r.stdout).toMatch(/pgai joe result \d+/);
261
+
262
+ const match = r.stdout.match(/pgai joe result (\d+)/);
263
+ const commandId = match ? match[1] : "";
264
+ expect(commandId).not.toBe("");
265
+
266
+ const resumed = await runCliAsync(["joe", "result", commandId], env);
267
+ expect(resumed.status).toBe(0);
268
+ expect(resumed.stdout).toContain("Seq Scan on users");
269
+ } finally {
270
+ api.stop();
271
+ }
272
+ });
273
+
274
+ test("pgai joe result exits 1 for a server-side timed_out command", async () => {
275
+ // `joe result` must match the one-shot path (printJoeOutcome): a terminal
276
+ // `timed_out` is a non-result — a script doing `pgai joe result $id && next`
277
+ // must NOT proceed.
278
+ const api = startFakeApi();
279
+ try {
280
+ const r = await runCliAsync(
281
+ ["joe", "result", "424242"],
282
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
283
+ );
284
+ expect(r.status).toBe(1);
285
+ expect(r.stderr).toContain("timed out");
286
+ } finally {
287
+ api.stop();
288
+ }
289
+ });
290
+
291
+ test("pgai joe result --json exits 1 for terminal error/timed_out (JSON still printed)", async () => {
292
+ // --json keeps the machine-readable body on stdout, but the exit contract
293
+ // must match the human output and the one-shot printJoeOutcome path: a
294
+ // terminal failure is a non-result.
295
+ const api = startFakeApi();
296
+ const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
297
+ try {
298
+ const timedOut = await runCliAsync(["joe", "result", "424242", "--json"], env);
299
+ expect(timedOut.status).toBe(1);
300
+ expect((JSON.parse(timedOut.stdout) as { status: string }).status).toBe("timed_out");
301
+
302
+ const errored = await runCliAsync(["joe", "result", "424243", "--json"], env);
303
+ expect(errored.status).toBe(1);
304
+ expect((JSON.parse(errored.stdout) as { status: string }).status).toBe("error");
305
+
306
+ // Non-json error path: sanitized message on stderr, exit 1.
307
+ const human = await runCliAsync(["joe", "result", "424243"], env);
308
+ expect(human.status).toBe(1);
309
+ expect(human.stderr).toContain("Query error");
310
+ } finally {
311
+ api.stop();
312
+ }
313
+ });
314
+
315
+ test("budget-expiry resume hint reports the ACTUAL --budget, not the hardcoded default", async () => {
316
+ const api = startFakeApi();
317
+ try {
318
+ // Project 77 (COLD) stays `running`; --budget 0 expires immediately. The
319
+ // hint must say "budget 0s reached" — not the DEFAULT_BUDGET_MS 25s.
320
+ const r = await runCliAsync(
321
+ ["joe", "plan", "select 1", "--project", "77", "--budget", "0"],
322
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
323
+ );
324
+ expect(r.status).toBe(0);
325
+ expect(r.stdout).toContain("budget 0s reached");
326
+ expect(r.stdout).not.toContain("budget 25s reached");
327
+ } finally {
328
+ api.stop();
329
+ }
330
+ });
331
+
332
+ test("ai_enabled=false submit is refused (PT403) with a clear message and exit 1", async () => {
333
+ const api = startFakeApi();
334
+ try {
335
+ const r = await runCliAsync(
336
+ ["joe", "plan", "select 1", "--project", "999"],
337
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
338
+ );
339
+ expect(r.status).toBe(1);
340
+ expect(`${r.stdout}\n${r.stderr}`).toContain("AI features disabled for this org");
341
+ } finally {
342
+ api.stop();
343
+ }
344
+ });
345
+
346
+ test("joe terminate requires a numeric pid and maps args.pid", async () => {
347
+ const api = startFakeApi();
348
+ try {
349
+ const r = await runCliAsync(
350
+ ["joe", "terminate", "4711", "--project", "12"],
351
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
352
+ );
353
+ expect(r.status).toBe(0);
354
+ const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
355
+ expect(submit?.body.command).toBe("terminate");
356
+ expect(submit?.body.args).toEqual({ pid: 4711 });
357
+ expect(submit?.body.sql).toBeNull();
358
+ } finally {
359
+ api.stop();
360
+ }
361
+ });
362
+
363
+ test("joe terminate rejects a non-numeric / trailing-garbage pid (no submit)", async () => {
364
+ // A pid must be a bare non-negative integer. parseInt() would silently accept
365
+ // "12x"→12, "-5"→-5, "1.5"→1 and submit a WRONG pg_terminate_backend target.
366
+ // Those must be a clean, typed rejection that never reaches the submit rpc.
367
+ const api = startFakeApi();
368
+ try {
369
+ for (const bad of ["12x", "abc", "1.5", "0x10", " "]) {
370
+ const r = await runCliAsync(
371
+ ["joe", "terminate", bad, "--project", "12"],
372
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
373
+ );
374
+ expect(r.status).toBe(1);
375
+ expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a number");
376
+ }
377
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_submit"))).toBe(false);
378
+ } finally {
379
+ api.stop();
380
+ }
381
+ });
382
+
383
+ test("joe describe maps args.object (and --variant)", async () => {
384
+ const api = startFakeApi();
385
+ try {
386
+ const r = await runCliAsync(
387
+ ["joe", "describe", "users", "--project", "12", "--variant", "\\d+"],
388
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
389
+ );
390
+ expect(r.status).toBe(0);
391
+ const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
392
+ expect(submit?.body.command).toBe("describe");
393
+ expect(submit?.body.args).toEqual({ object: "users", variant: "\\d+" });
394
+ } finally {
395
+ api.stop();
396
+ }
397
+ });
398
+
399
+ test("missing --project fails fast", () => {
400
+ const r = runCli(["joe", "plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
401
+ expect(r.status).toBe(1);
402
+ expect(`${r.stdout}\n${r.stderr}`).toContain("Project is required");
403
+ });
404
+
405
+ test("missing API key fails fast", () => {
406
+ const r = runCli(["joe", "plan", "select 1", "--project", "12"], isolatedEnv());
407
+ expect(r.status).toBe(1);
408
+ expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
409
+ });
410
+ });
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // inferCommandFromResult — the sole logic mapping a bare `joe_command_result`
414
+ // body back to a command for `pgai joe result <id>` rendering. Table-driven
415
+ // over every branch of the per-command contract matrix (CONTRACT_DECISIONS):
416
+ // plan/explain {queryid, plan_fingerprint, plan_text, plan_json} · exec
417
+ // {command, row_count, result_rows, notices} · hypo {hypo_plan, hypo_used} ·
418
+ // activity/describe {snapshot} · terminate {terminated, pid} · reset {reset}.
419
+ // The `!== undefined` chain is order-sensitive, so each contract shape is
420
+ // pinned; per the contract every command's keys are disjoint (a hypo re-plan
421
+ // rides in `hypo_plan`, never `plan_text`/`plan_json`), so `hypo_used` being
422
+ // checked after the plan keys cannot misclassify a contract-shaped body.
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe("inferCommandFromResult (joe result <id> body → command)", () => {
426
+ const cases: Array<{ name: string; body: Parameters<typeof inferCommandFromResult>[0]; expected: ReturnType<typeof inferCommandFromResult> }> = [
427
+ // The `command` echo (DECISION: every result echoes `command`) short-circuits
428
+ // everything — even when overlapping keys are present.
429
+ {
430
+ name: "command echo short-circuits (exec despite plan keys present)",
431
+ body: { command: "exec", plan_text: "Seq Scan", plan_json: {}, row_count: 1, result_rows: [{ n: 1 }] },
432
+ expected: "exec",
433
+ },
434
+ { name: "command echo: describe (snapshot would otherwise infer activity)", body: { command: "describe", snapshot: {} }, expected: "describe" },
435
+ // plan / explain — either plan key suffices; null still counts (key present).
436
+ { name: "plan_text + plan_json → plan", body: { plan_text: "Index Scan", plan_json: { Plan: {} } }, expected: "plan" },
437
+ { name: "plan_text only → plan", body: { plan_text: "Index Scan" }, expected: "plan" },
438
+ { name: "plan_json only → plan", body: { plan_json: { Plan: {} } }, expected: "plan" },
439
+ { name: "null plan_text (key present) → plan", body: { plan_text: null }, expected: "plan" },
440
+ // exec — row_count / result_rows.
441
+ { name: "row_count + result_rows → exec", body: { row_count: 2, result_rows: [{ a: 1 }, { a: 2 }] }, expected: "exec" },
442
+ { name: "result_rows only → exec", body: { result_rows: [] }, expected: "exec" },
443
+ { name: "row_count 0 (falsy but present) → exec", body: { row_count: 0 }, expected: "exec" },
444
+ // hypo — the contract body is {hypo_plan, hypo_used}; hypo_used:false must
445
+ // still classify (the "would NOT be used" verdict).
446
+ { name: "hypo_used true + hypo_plan → hypo", body: { hypo_used: true, hypo_plan: { Plan: {} } }, expected: "hypo" },
447
+ { name: "hypo_used false → hypo", body: { hypo_used: false, hypo_plan: { Plan: {} } }, expected: "hypo" },
448
+ // terminate — terminated/pid; a false verdict still classifies.
449
+ { name: "terminated true → terminate", body: { terminated: true, pid: 66 }, expected: "terminate" },
450
+ { name: "terminated false → terminate", body: { terminated: false, pid: 66 }, expected: "terminate" },
451
+ // reset.
452
+ { name: "reset true → reset", body: { reset: true }, expected: "reset" },
453
+ { name: "reset false → reset", body: { reset: false }, expected: "reset" },
454
+ // activity / describe — both ride `snapshot`; without the `command` echo the
455
+ // inference picks "activity" (rendering is identical for the two).
456
+ { name: "snapshot → activity", body: { snapshot: [{ pid: 1 }] }, expected: "activity" },
457
+ // Unrecognized.
458
+ { name: "empty body → null", body: {}, expected: null },
459
+ // Only `hypo_used` classifies hypo (always present per contract); a lone
460
+ // hypo_plan — a contract-violating body — stays unrecognized, not "plan".
461
+ { name: "hypo_plan without hypo_used → null (not misclassified as plan)", body: { hypo_plan: {} }, expected: null },
462
+ ];
463
+
464
+ for (const c of cases) {
465
+ test(c.name, () => {
466
+ expect(inferCommandFromResult(c.body)).toBe(c.expected);
467
+ });
468
+ }
469
+ });