postgresai 0.16.0-dev.10 → 0.16.0-dev.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -84
- package/bin/postgres-ai.ts +140 -540
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +790 -2282
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -0
- package/lib/joe.ts +333 -391
- package/lib/mcp-server.ts +0 -625
- package/lib/util.ts +145 -29
- package/package.json +1 -1
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.test.ts +35 -0
- package/test/joe.cli.test.ts +355 -196
- package/test/joe.test.ts +565 -568
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +116 -0
- package/lib/dblab.ts +0 -449
- package/test/dblab.cli.test.ts +0 -373
- package/test/dblab.test.ts +0 -488
- package/test/e2e/pgai-e2e-smoke.sh +0 -192
package/test/joe.cli.test.ts
CHANGED
|
@@ -2,9 +2,6 @@ import { describe, test, expect } from "bun:test";
|
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
import { mkdtempSync } from "fs";
|
|
4
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
5
|
|
|
9
6
|
// Sync runner for OFFLINE tests only (help / fail-fast). Never use it for a test
|
|
10
7
|
// that round-trips the in-process Bun.serve: spawnSync blocks the JS event loop, so
|
|
@@ -39,7 +36,7 @@ async function runCliAsync(args: string[], env: Record<string, string> = {}) {
|
|
|
39
36
|
return { status, stdout, stderr };
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
/** Isolate config so tests never touch real user state
|
|
39
|
+
/** Isolate config so tests never touch real user state. */
|
|
43
40
|
function isolatedEnv(extra: Record<string, string> = {}) {
|
|
44
41
|
const cfgHome = mkdtempSync(resolve(tmpdir(), "joe-cli-test-"));
|
|
45
42
|
return { XDG_CONFIG_HOME: cfgHome, HOME: cfgHome, ...extra };
|
|
@@ -51,15 +48,20 @@ interface RecordedRequest {
|
|
|
51
48
|
}
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
|
-
* Fake PostgREST server for the Joe rpcs
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* Fake PostgREST server for the sync Joe rpcs. Instance 55 ("cold-db") never
|
|
52
|
+
* posts a result (stays `pending`, exercising the resume path); instance 66
|
|
53
|
+
* ("no-role") returns the Joe-API-v2 role-gate PT403; instance 3 completes.
|
|
54
|
+
* Command ids are ABOVE 2^53 so any parseInt round-trip in the CLI corrupts
|
|
55
|
+
* them and the string-preservation assertions fail.
|
|
57
56
|
*/
|
|
58
57
|
function startFakeApi() {
|
|
59
58
|
const requests: RecordedRequest[] = [];
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const
|
|
59
|
+
const COLD_INSTANCE = 55;
|
|
60
|
+
const FORBIDDEN_INSTANCE = 66;
|
|
61
|
+
const ERROR_INSTANCE = 33;
|
|
62
|
+
const FLAKY_INSTANCE = 44;
|
|
63
|
+
let nextId = 9007199254740993000n; // > Number.MAX_SAFE_INTEGER
|
|
64
|
+
const commands = new Map<string, { command: string; instanceId: number }>();
|
|
63
65
|
|
|
64
66
|
const server = Bun.serve({
|
|
65
67
|
hostname: "127.0.0.1",
|
|
@@ -80,51 +82,94 @@ function startFakeApi() {
|
|
|
80
82
|
|
|
81
83
|
if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
|
|
82
84
|
return respond([
|
|
83
|
-
{
|
|
84
|
-
{
|
|
85
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3 },
|
|
86
|
+
{ project_id: 77, alias: "cold-db", name: "Cold DB", joe_ready: true, tunnel: true, instance_id: COLD_INSTANCE },
|
|
87
|
+
{ project_id: 99, alias: "no-role", name: "No Role", joe_ready: true, tunnel: true, instance_id: FORBIDDEN_INSTANCE },
|
|
88
|
+
{ project_id: 14, alias: "no-joe", name: "No Joe", joe_ready: false, tunnel: false, instance_id: null },
|
|
89
|
+
{ project_id: 15, alias: "err-db", name: "Err DB", joe_ready: true, tunnel: true, instance_id: ERROR_INSTANCE },
|
|
90
|
+
{ project_id: 16, alias: "flaky-db", name: "Flaky DB", joe_ready: true, tunnel: true, instance_id: FLAKY_INSTANCE },
|
|
85
91
|
]);
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
if (req.method === "POST" && url.pathname.endsWith("/rpc/
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
return respond(
|
|
94
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_run")) {
|
|
95
|
+
const instanceId = Number(body.instance_id);
|
|
96
|
+
if (instanceId === FORBIDDEN_INSTANCE) {
|
|
97
|
+
return respond(
|
|
98
|
+
{ code: "PT403", message: "Forbidden", details: "Joe API v2 requires the All Features role." },
|
|
99
|
+
403
|
|
100
|
+
);
|
|
92
101
|
}
|
|
93
102
|
const commandId = String(nextId++);
|
|
94
|
-
commands.set(commandId, { command: String(body.command),
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
return respond({ command_id: commandId, session_id: sessionId, status: "queued" });
|
|
103
|
+
commands.set(commandId, { command: String(body.command), instanceId });
|
|
104
|
+
// The rpc returns the id as a bare JSON string.
|
|
105
|
+
return respond(commandId);
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
if (req.method === "POST" && url.pathname.endsWith("/rpc/
|
|
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
|
+
if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_output")) {
|
|
108
109
|
const commandId = String(body.command_id);
|
|
109
110
|
if (commandId === "424242") {
|
|
110
|
-
//
|
|
111
|
-
return respond({ command_id: commandId, status: "
|
|
111
|
+
// Terminal failure without a structured row (e.g. clone init failed).
|
|
112
|
+
return respond({ command_id: commandId, status: "error", created_at: "2026-07-22T10:00:00", error: "ERROR: something failed" });
|
|
112
113
|
}
|
|
113
|
-
if (commandId === "
|
|
114
|
-
|
|
115
|
-
return respond({ command_id: commandId, command: "exec", status: "error", error: "Query error" });
|
|
114
|
+
if (commandId === "424244") {
|
|
115
|
+
return respond({ command_id: commandId, status: "pending", created_at: "2026-07-22T10:00:00" });
|
|
116
116
|
}
|
|
117
117
|
const cmd = commands.get(commandId);
|
|
118
|
-
if (cmd
|
|
119
|
-
return respond({
|
|
118
|
+
if (!cmd) {
|
|
119
|
+
return respond({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404);
|
|
120
|
+
}
|
|
121
|
+
if (cmd.instanceId === COLD_INSTANCE) {
|
|
122
|
+
return respond({ command_id: commandId, status: "pending", created_at: "2026-07-22T10:00:00" });
|
|
123
|
+
}
|
|
124
|
+
if (cmd.instanceId === ERROR_INSTANCE) {
|
|
125
|
+
// Joe posted a terminal failure for this command.
|
|
126
|
+
return respond({
|
|
127
|
+
command_id: commandId,
|
|
128
|
+
status: "error",
|
|
129
|
+
created_at: "2026-07-22T10:00:00",
|
|
130
|
+
queryid: null,
|
|
131
|
+
plan_json: null,
|
|
132
|
+
plan_text: null,
|
|
133
|
+
error: "ERROR: relation \"nope\" does not exist",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (cmd.instanceId === FLAKY_INSTANCE) {
|
|
137
|
+
// The output endpoint is down (proxy hiccup / pod restart) — every
|
|
138
|
+
// poll 502s while the command id remains perfectly valid.
|
|
139
|
+
return new Response("Bad Gateway", { status: 502 });
|
|
140
|
+
}
|
|
141
|
+
if (cmd.command.startsWith("exec ") || cmd.command.startsWith("\\d")) {
|
|
142
|
+
return respond({
|
|
143
|
+
command_id: commandId,
|
|
144
|
+
status: "ok",
|
|
145
|
+
created_at: "2026-07-22T10:00:00",
|
|
146
|
+
command: cmd.command.split(" ")[0],
|
|
147
|
+
query: null,
|
|
148
|
+
queryid: null,
|
|
149
|
+
response: cmd.command.startsWith("exec ") ? "CREATE INDEX" : "Table \"public.users\"",
|
|
150
|
+
plan_text: null,
|
|
151
|
+
plan_json: null,
|
|
152
|
+
plan_execution_text: null,
|
|
153
|
+
plan_execution_json: null,
|
|
154
|
+
stats: null,
|
|
155
|
+
recommendations: null,
|
|
156
|
+
error: null,
|
|
157
|
+
});
|
|
120
158
|
}
|
|
121
159
|
return respond({
|
|
122
160
|
command_id: commandId,
|
|
123
|
-
status: "
|
|
161
|
+
status: "ok",
|
|
162
|
+
created_at: "2026-07-22T10:00:00",
|
|
163
|
+
command: cmd.command.split(" ")[0],
|
|
164
|
+
query: cmd.command.replace(/^\S+\s*/, "") || null,
|
|
124
165
|
queryid: "7712349901234567890",
|
|
125
|
-
|
|
166
|
+
response: null,
|
|
126
167
|
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" } },
|
|
168
|
+
plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
|
|
169
|
+
plan_execution_text: "Seq Scan on users (actual time=0.05..12.30 rows=1 loops=1)",
|
|
170
|
+
plan_execution_json: [{ Plan: {} }],
|
|
171
|
+
stats: "Time: 12.4 ms",
|
|
172
|
+
recommendations: null,
|
|
128
173
|
error: null,
|
|
129
174
|
});
|
|
130
175
|
}
|
|
@@ -138,38 +183,37 @@ function startFakeApi() {
|
|
|
138
183
|
}
|
|
139
184
|
|
|
140
185
|
describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
141
|
-
test("top-level help lists the joe
|
|
186
|
+
test("top-level help lists the joe group and top-level projects", () => {
|
|
142
187
|
const r = runCli(["--help"], isolatedEnv());
|
|
143
188
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
for (const group of ["joe", "dblab", "projects"]) {
|
|
147
|
-
expect(out).toContain(group);
|
|
148
|
-
}
|
|
189
|
+
expect(out).toContain("joe");
|
|
190
|
+
expect(out).toContain("projects");
|
|
149
191
|
});
|
|
150
192
|
|
|
151
|
-
test("pgai joe --help lists all the Joe verbs", () => {
|
|
193
|
+
test("pgai joe --help lists all the Joe verbs (and no async-era flags)", () => {
|
|
152
194
|
const r = runCli(["joe", "--help"], isolatedEnv());
|
|
153
195
|
expect(r.status).toBe(0);
|
|
154
196
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
155
|
-
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result"
|
|
197
|
+
for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result"]) {
|
|
156
198
|
expect(out).toContain(verb);
|
|
157
199
|
}
|
|
200
|
+
// The async surface's session flags and status/history verbs are gone.
|
|
201
|
+
expect(out).not.toContain("--session");
|
|
202
|
+
expect(out).not.toContain("new-session");
|
|
203
|
+
expect(out).not.toContain("history");
|
|
158
204
|
});
|
|
159
205
|
|
|
160
|
-
test("pgai joe
|
|
161
|
-
|
|
162
|
-
|
|
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);
|
|
206
|
+
test("pgai joe plan --help offers --instance-id and --budget but not --session", () => {
|
|
207
|
+
const r = runCli(["joe", "plan", "--help"], isolatedEnv());
|
|
208
|
+
expect(r.status).toBe(0);
|
|
166
209
|
const out = `${r.stdout}\n${r.stderr}`;
|
|
167
|
-
expect(out).toContain("
|
|
168
|
-
expect(out).toContain("
|
|
169
|
-
expect(out).
|
|
210
|
+
expect(out).toContain("--instance-id");
|
|
211
|
+
expect(out).toContain("--budget");
|
|
212
|
+
expect(out).toContain("--project");
|
|
213
|
+
expect(out).not.toContain("--session");
|
|
170
214
|
});
|
|
171
215
|
|
|
172
|
-
test("pgai projects prints the
|
|
216
|
+
test("pgai projects prints the table", async () => {
|
|
173
217
|
const api = startFakeApi();
|
|
174
218
|
try {
|
|
175
219
|
const r = await runCliAsync(["projects"], isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
|
|
@@ -187,7 +231,23 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
|
187
231
|
}
|
|
188
232
|
});
|
|
189
233
|
|
|
190
|
-
test("pgai
|
|
234
|
+
test("pgai projects --json prints a machine-readable project array", async () => {
|
|
235
|
+
const api = startFakeApi();
|
|
236
|
+
try {
|
|
237
|
+
const r = await runCliAsync(
|
|
238
|
+
["projects", "--json"],
|
|
239
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
240
|
+
);
|
|
241
|
+
expect(r.status).toBe(0);
|
|
242
|
+
const projects = JSON.parse(r.stdout) as Array<{ project_id: number; alias: string }>;
|
|
243
|
+
expect(projects).toBeArray();
|
|
244
|
+
expect(projects[0]).toMatchObject({ project_id: 12, alias: "main-db" });
|
|
245
|
+
} finally {
|
|
246
|
+
api.stop();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("pgai joe plan --project 12 sends the RAW text against the project's instance", async () => {
|
|
191
251
|
const api = startFakeApi();
|
|
192
252
|
try {
|
|
193
253
|
const r = await runCliAsync(
|
|
@@ -196,13 +256,48 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
|
196
256
|
);
|
|
197
257
|
expect(r.status).toBe(0);
|
|
198
258
|
expect(r.stdout).toContain("Seq Scan on users");
|
|
199
|
-
// client-side plan flag
|
|
259
|
+
// client-side plan flag from the structured plan_json
|
|
200
260
|
expect(r.stdout).toContain("⚑");
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
261
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
262
|
+
// The run rpc keys on the INSTANCE id (3), resolved from project 12, and
|
|
263
|
+
// the command is the raw text with the verb prefix — no structured body.
|
|
264
|
+
expect(run?.body).toEqual({
|
|
265
|
+
instance_id: 3,
|
|
266
|
+
command: "plan select * from users where email = 'x@acme.io'",
|
|
267
|
+
});
|
|
268
|
+
// A numeric project still needs the listing — the instance id lives there.
|
|
269
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
|
|
270
|
+
} finally {
|
|
271
|
+
api.stop();
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("pgai joe plan --json prints the full output row as JSON", async () => {
|
|
276
|
+
const api = startFakeApi();
|
|
277
|
+
try {
|
|
278
|
+
const r = await runCliAsync(
|
|
279
|
+
["joe", "plan", "select 1", "--project", "12", "--json"],
|
|
280
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
281
|
+
);
|
|
282
|
+
expect(r.status).toBe(0);
|
|
283
|
+
const result = JSON.parse(r.stdout) as {
|
|
284
|
+
command_id: string;
|
|
285
|
+
status: string;
|
|
286
|
+
plan_text: string;
|
|
287
|
+
plan_execution_text: string;
|
|
288
|
+
stats: string;
|
|
289
|
+
plan_json: unknown;
|
|
290
|
+
queryid: string;
|
|
291
|
+
};
|
|
292
|
+
expect(result.command_id).toMatch(/^\d+$/);
|
|
293
|
+
expect(result.status).toBe("ok");
|
|
294
|
+
expect(result.plan_text).toContain("Seq Scan on users");
|
|
295
|
+
// A >2^53 queryid survives the output rpc round-trip VERBATIM as a string.
|
|
296
|
+
expect(result.queryid).toBe("7712349901234567890");
|
|
297
|
+
// The sync contract surfaces the FULL body — execution plan and stats too.
|
|
298
|
+
expect(result.plan_execution_text).toContain("actual time");
|
|
299
|
+
expect(result.stats).toBe("Time: 12.4 ms");
|
|
300
|
+
expect(Array.isArray(result.plan_json)).toBe(true);
|
|
206
301
|
} finally {
|
|
207
302
|
api.stop();
|
|
208
303
|
}
|
|
@@ -217,33 +312,66 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
|
217
312
|
);
|
|
218
313
|
expect(r.status).toBe(0);
|
|
219
314
|
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
|
|
220
|
-
const
|
|
221
|
-
expect(
|
|
315
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
316
|
+
expect(run?.body.instance_id).toBe(3);
|
|
222
317
|
} finally {
|
|
223
318
|
api.stop();
|
|
224
319
|
}
|
|
225
320
|
});
|
|
226
321
|
|
|
227
|
-
test("
|
|
322
|
+
test("every verb maps to its raw Joe command text", async () => {
|
|
323
|
+
const cases: Array<{ argv: string[]; text: string }> = [
|
|
324
|
+
{ argv: ["joe", "explain", "select 1", "--project", "12"], text: "explain select 1" },
|
|
325
|
+
{ argv: ["joe", "exec", "create index on users (email)", "--project", "12"], text: "exec create index on users (email)" },
|
|
326
|
+
{ argv: ["joe", "hypo", "create index on users (email)", "--project", "12"], text: "hypo create index on users (email)" },
|
|
327
|
+
{ argv: ["joe", "activity", "--project", "12"], text: "activity" },
|
|
328
|
+
{ argv: ["joe", "reset", "--project", "12"], text: "reset" },
|
|
329
|
+
{ argv: ["joe", "terminate", "4711", "--project", "12"], text: "terminate 4711" },
|
|
330
|
+
{ argv: ["joe", "describe", "users", "--project", "12"], text: "\\d users" },
|
|
331
|
+
{ argv: ["joe", "describe", "users", "--project", "12", "--variant", "\\d+"], text: "\\d+ users" },
|
|
332
|
+
];
|
|
333
|
+
for (const c of cases) {
|
|
334
|
+
const api = startFakeApi();
|
|
335
|
+
try {
|
|
336
|
+
const r = await runCliAsync(c.argv, isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
|
|
337
|
+
expect(r.status).toBe(0);
|
|
338
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
339
|
+
expect(run?.body.command).toBe(c.text);
|
|
340
|
+
expect(run?.body.instance_id).toBe(3);
|
|
341
|
+
} finally {
|
|
342
|
+
api.stop();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("pgai joe explain --instance-id targets the instance directly (no projects_list)", async () => {
|
|
348
|
+
// The v1 path: projects_list is not deployed on main/.green, so the joe
|
|
349
|
+
// verbs must be fully usable with a manual instance id — resolution is
|
|
350
|
+
// skipped entirely and the id goes on the wire as the given string.
|
|
228
351
|
const api = startFakeApi();
|
|
229
|
-
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
230
352
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
expect(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
353
|
+
const r = await runCliAsync(
|
|
354
|
+
["joe", "explain", "select 1", "--instance-id", "1"],
|
|
355
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
356
|
+
);
|
|
357
|
+
expect(r.status).toBe(0);
|
|
358
|
+
const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
|
|
359
|
+
expect(run?.body).toEqual({ instance_id: "1", command: "explain select 1" });
|
|
360
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(false);
|
|
361
|
+
} finally {
|
|
362
|
+
api.stop();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("exec surfaces Joe's response body", async () => {
|
|
367
|
+
const api = startFakeApi();
|
|
368
|
+
try {
|
|
369
|
+
const r = await runCliAsync(
|
|
370
|
+
["joe", "exec", "create index on users (email)", "--project", "12"],
|
|
371
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
372
|
+
);
|
|
373
|
+
expect(r.status).toBe(0);
|
|
374
|
+
expect(r.stdout).toContain("CREATE INDEX");
|
|
247
375
|
} finally {
|
|
248
376
|
api.stop();
|
|
249
377
|
}
|
|
@@ -253,153 +381,243 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
|
253
381
|
const api = startFakeApi();
|
|
254
382
|
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
255
383
|
try {
|
|
256
|
-
//
|
|
257
|
-
const r = await runCliAsync(["joe", "plan", "select 1", "--project", "
|
|
384
|
+
// cold-db (instance 55) stays `pending`; --budget 0 forces immediate resume.
|
|
385
|
+
const r = await runCliAsync(["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0"], env);
|
|
258
386
|
expect(r.status).toBe(0);
|
|
259
387
|
expect(r.stdout).toContain("resume:");
|
|
260
388
|
expect(r.stdout).toMatch(/pgai joe result \d+/);
|
|
261
389
|
|
|
262
390
|
const match = r.stdout.match(/pgai joe result (\d+)/);
|
|
263
391
|
const commandId = match ? match[1] : "";
|
|
264
|
-
|
|
392
|
+
// The id is one the fake minted ABOVE 2^53 — it must be the exact string
|
|
393
|
+
// the run rpc returned (a parseInt round-trip would corrupt it).
|
|
394
|
+
expect(commandId).toMatch(/^900719925474099\d+$/);
|
|
395
|
+
|
|
396
|
+
// `pgai joe result <id>` still returns pending for the cold instance and
|
|
397
|
+
// exits 1 — the output rpc must receive the id VERBATIM as a string.
|
|
398
|
+
const resumed = await runCliAsync(["joe", "result", commandId], env);
|
|
399
|
+
expect(resumed.status).toBe(1);
|
|
400
|
+
expect(resumed.stderr).toContain("result is not ready");
|
|
401
|
+
const outputReq = api.requests.filter((x) => x.pathname.endsWith("/rpc/joe_command_output")).pop();
|
|
402
|
+
expect(outputReq?.body.command_id).toBe(commandId);
|
|
403
|
+
|
|
404
|
+
const jsonRun = await runCliAsync(
|
|
405
|
+
["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0", "--json"],
|
|
406
|
+
env
|
|
407
|
+
);
|
|
408
|
+
expect(jsonRun.status).toBe(0);
|
|
409
|
+
const jsonResume = JSON.parse(jsonRun.stdout) as {
|
|
410
|
+
command_id: string;
|
|
411
|
+
status: string;
|
|
412
|
+
budget_expired: boolean;
|
|
413
|
+
resume: string;
|
|
414
|
+
};
|
|
415
|
+
expect(jsonResume.budget_expired).toBe(true);
|
|
416
|
+
expect(jsonResume.status).toBe("pending");
|
|
417
|
+
expect(jsonResume.resume).toBe(`pgai joe result ${jsonResume.command_id}`);
|
|
418
|
+
} finally {
|
|
419
|
+
api.stop();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("pgai joe result renders a completed command's full output", async () => {
|
|
424
|
+
const api = startFakeApi();
|
|
425
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
426
|
+
try {
|
|
427
|
+
// Complete a command on the warm instance, then re-fetch it by id.
|
|
428
|
+
const run = await runCliAsync(["joe", "plan", "select 1", "--project", "12", "--json"], env);
|
|
429
|
+
expect(run.status).toBe(0);
|
|
430
|
+
const commandId = (JSON.parse(run.stdout) as { command_id: string }).command_id;
|
|
265
431
|
|
|
266
432
|
const resumed = await runCliAsync(["joe", "result", commandId], env);
|
|
267
433
|
expect(resumed.status).toBe(0);
|
|
268
434
|
expect(resumed.stdout).toContain("Seq Scan on users");
|
|
435
|
+
expect(resumed.stdout).toContain("stats:");
|
|
436
|
+
|
|
437
|
+
const resumedJson = await runCliAsync(["joe", "result", commandId, "--json"], env);
|
|
438
|
+
expect(resumedJson.status).toBe(0);
|
|
439
|
+
const jsonResult = JSON.parse(resumedJson.stdout) as { status: string; plan_text: string };
|
|
440
|
+
expect(jsonResult.status).toBe("ok");
|
|
441
|
+
expect(jsonResult.plan_text).toContain("Seq Scan on users");
|
|
442
|
+
} finally {
|
|
443
|
+
api.stop();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("pgai joe result exits 1 for a terminal error (JSON still printed with --json)", async () => {
|
|
448
|
+
const api = startFakeApi();
|
|
449
|
+
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
450
|
+
try {
|
|
451
|
+
const human = await runCliAsync(["joe", "result", "424242"], env);
|
|
452
|
+
expect(human.status).toBe(1);
|
|
453
|
+
expect(human.stderr).toContain("ERROR: something failed");
|
|
454
|
+
|
|
455
|
+
const asJson = await runCliAsync(["joe", "result", "424242", "--json"], env);
|
|
456
|
+
expect(asJson.status).toBe(1);
|
|
457
|
+
expect((JSON.parse(asJson.stdout) as { status: string }).status).toBe("error");
|
|
458
|
+
|
|
459
|
+
const pending = await runCliAsync(["joe", "result", "424244", "--json"], env);
|
|
460
|
+
expect(pending.status).toBe(1);
|
|
461
|
+
expect((JSON.parse(pending.stdout) as { status: string }).status).toBe("pending");
|
|
269
462
|
} finally {
|
|
270
463
|
api.stop();
|
|
271
464
|
}
|
|
272
465
|
});
|
|
273
466
|
|
|
274
|
-
test("
|
|
275
|
-
//
|
|
276
|
-
// `timed_out` is a non-result — a script doing `pgai joe result $id && next`
|
|
277
|
-
// must NOT proceed.
|
|
467
|
+
test("a terminal error from the one-shot exits 1 with the error message", async () => {
|
|
468
|
+
// A plan against the role-gated instance surfaces the PT403 detail and exits 1.
|
|
278
469
|
const api = startFakeApi();
|
|
279
470
|
try {
|
|
280
471
|
const r = await runCliAsync(
|
|
281
|
-
["joe", "
|
|
472
|
+
["joe", "plan", "select 1", "--project", "no-role"],
|
|
282
473
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
283
474
|
);
|
|
284
475
|
expect(r.status).toBe(1);
|
|
285
|
-
expect(r.stderr).toContain("
|
|
476
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Joe API v2 requires the All Features role");
|
|
286
477
|
} finally {
|
|
287
478
|
api.stop();
|
|
288
479
|
}
|
|
289
480
|
});
|
|
290
481
|
|
|
291
|
-
test("
|
|
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.
|
|
482
|
+
test("a command Joe fails (status=error) exits 1 with the error; --json still prints the row", async () => {
|
|
295
483
|
const api = startFakeApi();
|
|
296
484
|
const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
|
|
297
485
|
try {
|
|
298
|
-
const
|
|
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);
|
|
486
|
+
const human = await runCliAsync(["joe", "plan", "select * from nope", "--project", "err-db"], env);
|
|
308
487
|
expect(human.status).toBe(1);
|
|
309
|
-
expect(human.stderr).toContain("
|
|
488
|
+
expect(human.stderr).toContain('ERROR: relation "nope" does not exist');
|
|
489
|
+
|
|
490
|
+
const asJson = await runCliAsync(["joe", "plan", "select * from nope", "--project", "err-db", "--json"], env);
|
|
491
|
+
expect(asJson.status).toBe(1);
|
|
492
|
+
const row = JSON.parse(asJson.stdout) as { status: string; error: string };
|
|
493
|
+
expect(row.status).toBe("error");
|
|
494
|
+
expect(row.error).toContain("does not exist");
|
|
310
495
|
} finally {
|
|
311
496
|
api.stop();
|
|
312
497
|
}
|
|
313
498
|
});
|
|
314
499
|
|
|
315
|
-
test("
|
|
500
|
+
test("a 502-ing output endpoint surfaces the resume handle instead of losing the id", async () => {
|
|
501
|
+
// The run rpc succeeded (valid command id); every output poll 502s. The
|
|
502
|
+
// one-shot must NOT throw the id away — it exits 0 with the resume line,
|
|
503
|
+
// and the id is the exact string the run rpc returned.
|
|
316
504
|
const api = startFakeApi();
|
|
317
505
|
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
506
|
const r = await runCliAsync(
|
|
321
|
-
["joe", "plan", "select 1", "--project", "
|
|
507
|
+
["joe", "plan", "select 1", "--project", "flaky-db", "--budget", "0.2"],
|
|
322
508
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
323
509
|
);
|
|
324
510
|
expect(r.status).toBe(0);
|
|
325
|
-
expect(r.stdout).toContain("
|
|
326
|
-
|
|
511
|
+
expect(r.stdout).toContain("resume:");
|
|
512
|
+
const match = r.stdout.match(/pgai joe result (\d+)/);
|
|
513
|
+
expect(match?.[1]).toMatch(/^900719925474099\d+$/);
|
|
514
|
+
// The run rpc really was hit and the polls really did 502.
|
|
515
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_run"))).toBe(true);
|
|
516
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_output"))).toBe(true);
|
|
327
517
|
} finally {
|
|
328
518
|
api.stop();
|
|
329
519
|
}
|
|
330
520
|
});
|
|
331
521
|
|
|
332
|
-
test("
|
|
522
|
+
test("a terminal 404 on joe result aborts with a legible message", async () => {
|
|
333
523
|
const api = startFakeApi();
|
|
334
524
|
try {
|
|
335
525
|
const r = await runCliAsync(
|
|
336
|
-
["joe", "
|
|
526
|
+
["joe", "result", "111"],
|
|
337
527
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
338
528
|
);
|
|
339
529
|
expect(r.status).toBe(1);
|
|
340
|
-
expect(
|
|
530
|
+
expect(r.stderr).toContain("Failed to fetch command output");
|
|
531
|
+
expect(r.stderr).toContain("404");
|
|
341
532
|
} finally {
|
|
342
533
|
api.stop();
|
|
343
534
|
}
|
|
344
535
|
});
|
|
345
536
|
|
|
346
|
-
test("
|
|
537
|
+
test("budget-expiry resume hint reports the ACTUAL --budget, not the hardcoded default", async () => {
|
|
347
538
|
const api = startFakeApi();
|
|
348
539
|
try {
|
|
349
540
|
const r = await runCliAsync(
|
|
350
|
-
["joe", "
|
|
541
|
+
["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0"],
|
|
351
542
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
352
543
|
);
|
|
353
544
|
expect(r.status).toBe(0);
|
|
354
|
-
|
|
355
|
-
expect(
|
|
356
|
-
expect(submit?.body.args).toEqual({ pid: 4711 });
|
|
357
|
-
expect(submit?.body.sql).toBeNull();
|
|
545
|
+
expect(r.stdout).toContain("budget 0s reached");
|
|
546
|
+
expect(r.stdout).not.toContain("budget 25s reached");
|
|
358
547
|
} finally {
|
|
359
548
|
api.stop();
|
|
360
549
|
}
|
|
361
550
|
});
|
|
362
551
|
|
|
363
|
-
test("
|
|
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.
|
|
552
|
+
test("negative --budget is rejected before any network call", async () => {
|
|
367
553
|
const api = startFakeApi();
|
|
368
554
|
try {
|
|
369
|
-
|
|
555
|
+
const r = await runCliAsync(
|
|
556
|
+
["joe", "plan", "select 1", "--project", "12", "--budget", "-5"],
|
|
557
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
558
|
+
);
|
|
559
|
+
expect(r.status).toBe(1);
|
|
560
|
+
expect(r.stderr).toContain("--budget must be a non-negative number");
|
|
561
|
+
expect(api.requests.length).toBe(0);
|
|
562
|
+
} finally {
|
|
563
|
+
api.stop();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("joe terminate rejects a non-numeric / trailing-garbage pid (no network at all)", async () => {
|
|
568
|
+
// A pid must be a bare positive integer. parseInt() would silently accept
|
|
569
|
+
// "12x"→12, "-5"→-5, "1.5"→1 and terminate a WRONG backend. Those must be
|
|
570
|
+
// a clean, typed rejection that never reaches any rpc.
|
|
571
|
+
const api = startFakeApi();
|
|
572
|
+
try {
|
|
573
|
+
for (const bad of ["0", "12x", "abc", "1.5", "0x10", " "]) {
|
|
370
574
|
const r = await runCliAsync(
|
|
371
575
|
["joe", "terminate", bad, "--project", "12"],
|
|
372
576
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
373
577
|
);
|
|
374
578
|
expect(r.status).toBe(1);
|
|
375
|
-
expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a
|
|
579
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a positive integer");
|
|
376
580
|
}
|
|
377
|
-
expect(api.requests.
|
|
581
|
+
expect(api.requests.length).toBe(0);
|
|
378
582
|
} finally {
|
|
379
583
|
api.stop();
|
|
380
584
|
}
|
|
381
585
|
});
|
|
382
586
|
|
|
383
|
-
test("joe describe
|
|
587
|
+
test("joe describe rejects a variant outside Joe's psql allowlist", async () => {
|
|
384
588
|
const api = startFakeApi();
|
|
385
589
|
try {
|
|
386
590
|
const r = await runCliAsync(
|
|
387
|
-
["joe", "describe", "users", "--project", "12", "--variant", "\\
|
|
591
|
+
["joe", "describe", "users", "--project", "12", "--variant", "\\dx"],
|
|
388
592
|
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
389
593
|
);
|
|
390
|
-
expect(r.status).toBe(
|
|
391
|
-
|
|
392
|
-
expect(
|
|
393
|
-
expect(submit?.body.args).toEqual({ object: "users", variant: "\\d+" });
|
|
594
|
+
expect(r.status).toBe(1);
|
|
595
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("Unsupported describe variant");
|
|
596
|
+
expect(api.requests.length).toBe(0);
|
|
394
597
|
} finally {
|
|
395
598
|
api.stop();
|
|
396
599
|
}
|
|
397
600
|
});
|
|
398
601
|
|
|
399
|
-
test("
|
|
602
|
+
test("a project without a Joe instance fails with a clear message", async () => {
|
|
603
|
+
const api = startFakeApi();
|
|
604
|
+
try {
|
|
605
|
+
const r = await runCliAsync(
|
|
606
|
+
["joe", "plan", "select 1", "--project", "no-joe"],
|
|
607
|
+
isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
|
|
608
|
+
);
|
|
609
|
+
expect(r.status).toBe(1);
|
|
610
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("has no Joe instance");
|
|
611
|
+
expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_run"))).toBe(false);
|
|
612
|
+
} finally {
|
|
613
|
+
api.stop();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("neither --project nor --instance-id fails fast with the v1 hint", () => {
|
|
400
618
|
const r = runCli(["joe", "plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
|
|
401
619
|
expect(r.status).toBe(1);
|
|
402
|
-
expect(`${r.stdout}\n${r.stderr}`).toContain("
|
|
620
|
+
expect(`${r.stdout}\n${r.stderr}`).toContain("--instance-id");
|
|
403
621
|
});
|
|
404
622
|
|
|
405
623
|
test("missing API key fails fast", () => {
|
|
@@ -408,62 +626,3 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
|
|
|
408
626
|
expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
|
|
409
627
|
});
|
|
410
628
|
});
|
|
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
|
-
});
|