unbound-cli 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +7 -3
- package/src/chartRender.js +16 -0
- package/src/commands/chat.js +183 -153
- package/src/commands/setup.js +66 -4
- package/test/chart-render.test.js +24 -0
- package/test/chat-internals.test.js +114 -0
- package/test/setup-args.test.js +168 -1
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -57,11 +57,15 @@ function request(method, path, { body, query, apiKey, baseUrl, timeoutMs } = {})
|
|
|
57
57
|
'Accept': 'application/json',
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
61
|
+
|
|
62
|
+
if (payload !== null) {
|
|
61
63
|
headers['Content-Type'] = 'application/json';
|
|
64
|
+
// Without this Node falls back to Transfer-Encoding: chunked, and Django's
|
|
65
|
+
// runserver (wsgiref) reads CONTENT_LENGTH only — so a chunked body arrives
|
|
66
|
+
// empty and every POST looks like it was sent with no fields at all.
|
|
67
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
62
68
|
}
|
|
63
|
-
|
|
64
|
-
const payload = body ? JSON.stringify(body) : null;
|
|
65
69
|
const transport = url.protocol === 'https:' ? https : http;
|
|
66
70
|
|
|
67
71
|
return new Promise((resolve, reject) => {
|
package/src/chartRender.js
CHANGED
|
@@ -82,6 +82,22 @@ function reconstructRows(chart_option, chart_meta) {
|
|
|
82
82
|
return sanitizeForTerminal(v);
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
+
// 0) table charts — the builder emits {columns, rows} under `table` rather than
|
|
86
|
+
// an axis/series pair, so this shape has no overlap with the ones below. Without
|
|
87
|
+
// it every table (any result with too many series or dimensions to plot) decodes
|
|
88
|
+
// to nothing and renders as "(no rows to render)".
|
|
89
|
+
const table = chart_option.table;
|
|
90
|
+
if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) {
|
|
91
|
+
const columns = table.columns.map((c) => sanitizeForTerminal(String(c)));
|
|
92
|
+
const rows = table.rows.slice(0, MAX_RECONSTRUCT_ROWS).map((r) => (
|
|
93
|
+
Object.fromEntries(columns.map((k, i) => {
|
|
94
|
+
const raw = Array.isArray(r) ? r[i] : r?.[table.columns[i]];
|
|
95
|
+
return [k, sanitizeLabel(raw)];
|
|
96
|
+
}))
|
|
97
|
+
));
|
|
98
|
+
return { columns, rows };
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
// 1) dataset.source — preferred when present
|
|
86
102
|
const source = chart_option.dataset?.source;
|
|
87
103
|
if (Array.isArray(source) && source.length > 0) {
|
package/src/commands/chat.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `unbound chat` — interactive session or one-shot wrapper around
|
|
3
|
-
* POST /api/v1/query_builder/
|
|
4
|
-
* only; nothing is written to disk. In one-shot mode (-m), `--json`
|
|
5
|
-
* the response to stdout and `-o <path>` writes it to a file.
|
|
3
|
+
* POST /api/v1/query_builder/agent/converse/. Conversation history lives in
|
|
4
|
+
* memory only; nothing is written to disk. In one-shot mode (-m), `--json`
|
|
5
|
+
* writes the response to stdout and `-o <path>` writes it to a file.
|
|
6
|
+
*
|
|
7
|
+
* A turn is an ordered transcript: the agent interleaves prose and charts, and
|
|
8
|
+
* both the terminal rendering and the JSON preserve that order. The JSON carries
|
|
9
|
+
* decoded rows rather than the chart option the wire format uses, so whatever
|
|
10
|
+
* consumes it — usually a coding agent — gets tabular data it can work with.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
const fs = require('fs');
|
|
@@ -16,9 +21,10 @@ const { ensureLoggedIn } = require('../auth');
|
|
|
16
21
|
const chart = require('../chartRender');
|
|
17
22
|
const { sanitizeForTerminal } = chart;
|
|
18
23
|
|
|
19
|
-
const ENDPOINT = '/api/v1/query_builder/
|
|
24
|
+
const ENDPOINT = '/api/v1/query_builder/agent/converse/';
|
|
25
|
+
// Above the server's own 300s cap, so a turn is never abandoned while it works.
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 300000;
|
|
20
27
|
const HISTORY_CAP = 20;
|
|
21
|
-
const RECENT_RESULTS_CAP = 5;
|
|
22
28
|
const MAX_MESSAGE_LEN = 4000;
|
|
23
29
|
const MAX_TURN_CONTENT_LEN = 8000;
|
|
24
30
|
const ALLOWED_ROLES = new Set(['user', 'assistant']);
|
|
@@ -79,24 +85,64 @@ function safeWriteFile(target, data) {
|
|
|
79
85
|
return resolved;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
function buildBody(message, history,
|
|
83
|
-
|
|
88
|
+
function buildBody(message, history, conversationId) {
|
|
89
|
+
const body = {
|
|
84
90
|
message,
|
|
85
91
|
conversation_history: history.slice(-HISTORY_CAP),
|
|
86
|
-
recent_results: recent.slice(-RECENT_RESULTS_CAP),
|
|
87
92
|
};
|
|
93
|
+
if (conversationId) body.conversation_id = conversationId;
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The model gets the words, not the tables. Same flattening the web client uses.
|
|
98
|
+
function flattenBlocks(blocks) {
|
|
99
|
+
return (blocks || [])
|
|
100
|
+
.map((b) => (b.type === 'text' ? b.text : `[chart: ${b.title}]`))
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join('\n\n');
|
|
88
103
|
}
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The transcript, in order, with charts decoded to rows.
|
|
107
|
+
*
|
|
108
|
+
* This is the CLI's JSON contract. The wire format carries an ECharts option
|
|
109
|
+
* because the browser renders one; nothing downstream of `--json` wants that, so
|
|
110
|
+
* charts are reduced to view_spec + columns + rows.
|
|
111
|
+
*/
|
|
112
|
+
function toJsonPayload(resp) {
|
|
113
|
+
const blocks = Array.isArray(resp?.blocks) ? resp.blocks : [];
|
|
114
|
+
return {
|
|
115
|
+
success: resp?.success !== false,
|
|
116
|
+
// Without it a --json consumer gets exit 1 and no reason.
|
|
117
|
+
error: resp?.error ?? null,
|
|
118
|
+
conversation_id: resp?.conversation_id ?? null,
|
|
119
|
+
log_id: resp?.log_id ?? null,
|
|
120
|
+
// Ran out of budget mid-answer; the blocks already here are real.
|
|
121
|
+
truncated: resp?.truncated === true,
|
|
122
|
+
blocks: blocks.map((b) => {
|
|
123
|
+
if (b.type === 'text') return { type: 'text', text: b.text };
|
|
124
|
+
const result = b.result || {};
|
|
125
|
+
let columns = [];
|
|
126
|
+
let rows = [];
|
|
127
|
+
let decoded = true;
|
|
128
|
+
try {
|
|
129
|
+
({ columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta));
|
|
130
|
+
} catch {
|
|
131
|
+
decoded = false;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
type: 'chart',
|
|
135
|
+
title: b.title,
|
|
136
|
+
view_spec: result.view_spec ?? null,
|
|
137
|
+
chart_type: result.chart_meta?.chart_type ?? null,
|
|
138
|
+
row_count: result.row_count ?? rows.length,
|
|
139
|
+
columns,
|
|
140
|
+
rows,
|
|
141
|
+
// Empty rows because it would not decode, not because the answer was empty.
|
|
142
|
+
...(decoded ? {} : { decode_failed: true }),
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
100
146
|
}
|
|
101
147
|
|
|
102
148
|
function pushHistory(history, turn) {
|
|
@@ -126,51 +172,31 @@ function friendlyChatError(err) {
|
|
|
126
172
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
127
173
|
}
|
|
128
174
|
|
|
129
|
-
|
|
130
|
-
|
|
175
|
+
/** Print one chart: its title, a one-line summary, then a body or a table. */
|
|
176
|
+
function renderChartBlock(block) {
|
|
177
|
+
const result = block.result || {};
|
|
131
178
|
console.log('');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const label = sanitizeForTerminal(s.label || `Suggestion ${i + 1}`);
|
|
135
|
-
const query = sanitizeForTerminal(s.query || '');
|
|
136
|
-
const desc = sanitizeForTerminal(s.description || '');
|
|
137
|
-
console.log(` ${i + 1}. ${bold(label)}`);
|
|
138
|
-
if (query) console.log(` ${query}`);
|
|
139
|
-
if (desc) console.log(` ${dim(desc)}`);
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Print the assistant message + (if a chart) a one-line summary and a chart
|
|
145
|
-
* body. Suggestions are NOT printed here — caller decides (REPL picker vs
|
|
146
|
-
* one-shot numbered list).
|
|
147
|
-
*/
|
|
148
|
-
function renderResponse(resp) {
|
|
149
|
-
const intent = resp?.intent;
|
|
150
|
-
const message = sanitizeForTerminal(resp?.message || '');
|
|
179
|
+
console.log(bold(' ' + sanitizeForTerminal(block.title || 'Result')));
|
|
180
|
+
console.log(dim(' ' + sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count))));
|
|
151
181
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
182
|
+
let columns = [];
|
|
183
|
+
let rows = [];
|
|
184
|
+
let decoded = true;
|
|
185
|
+
try {
|
|
186
|
+
({ columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta));
|
|
187
|
+
} catch {
|
|
188
|
+
decoded = false;
|
|
155
189
|
}
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
190
|
+
// Not the same as a chart with no rows, and must not read like one.
|
|
191
|
+
if (!decoded) {
|
|
192
|
+
console.log(dim(' (could not read this chart — --json still carries its spec)'));
|
|
159
193
|
return;
|
|
160
194
|
}
|
|
161
|
-
|
|
162
|
-
if (message) console.log(cyan('\u25cf ') + message);
|
|
163
|
-
const result = resp.result;
|
|
164
|
-
const summary = sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count));
|
|
165
|
-
console.log(dim(' ' + summary));
|
|
166
|
-
|
|
167
|
-
const { columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta);
|
|
168
195
|
if (!rows.length) {
|
|
169
196
|
console.log(dim(' (no rows to render)'));
|
|
170
197
|
return;
|
|
171
198
|
}
|
|
172
199
|
|
|
173
|
-
console.log('');
|
|
174
200
|
const cap = chart.MAX_RENDER_ROWS;
|
|
175
201
|
const renderRows = rows.slice(0, cap);
|
|
176
202
|
const chartType = result.chart_meta?.chart_type;
|
|
@@ -201,19 +227,50 @@ function renderResponse(resp) {
|
|
|
201
227
|
}
|
|
202
228
|
}
|
|
203
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Print the turn in the order the agent produced it — prose where it wrote
|
|
232
|
+
* prose, a chart where it ran one. A turn can hold several of each.
|
|
233
|
+
*/
|
|
234
|
+
function renderResponse(resp) {
|
|
235
|
+
if (resp?.success === false) {
|
|
236
|
+
output.error(sanitizeForTerminal(resp?.error || 'The request could not be processed.'));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const blocks = Array.isArray(resp?.blocks) ? resp.blocks : [];
|
|
241
|
+
if (!blocks.length) {
|
|
242
|
+
output.warn('The assistant returned nothing. Try rephrasing the question.');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const block of blocks) {
|
|
247
|
+
if (block?.type === 'text') {
|
|
248
|
+
if (block.text) console.log(cyan('\u25cf ') + sanitizeForTerminal(block.text));
|
|
249
|
+
} else if (block?.type === 'chart') {
|
|
250
|
+
renderChartBlock(block);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (resp?.truncated) {
|
|
255
|
+
console.log('');
|
|
256
|
+
output.warn('That is as far as it got in one turn. Ask for the rest to continue.');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
204
260
|
async function runOneShot({ message, json, outputPath }) {
|
|
205
|
-
const body = buildBody(message, [],
|
|
261
|
+
const body = buildBody(message, [], null);
|
|
206
262
|
|
|
207
263
|
if (json || outputPath) {
|
|
208
264
|
let resp;
|
|
209
265
|
try {
|
|
210
|
-
resp = await api.post(ENDPOINT, { body });
|
|
266
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
211
267
|
} catch (err) {
|
|
212
268
|
output.error(friendlyChatError(err));
|
|
213
269
|
process.exitCode = 1;
|
|
214
270
|
return;
|
|
215
271
|
}
|
|
216
|
-
const
|
|
272
|
+
const payload = toJsonPayload(resp);
|
|
273
|
+
const serialized = JSON.stringify(payload, null, 2) + '\n';
|
|
217
274
|
// Write to stdout first (when requested) so a downstream `-o` write failure
|
|
218
275
|
// doesn't eat the response when both flags are combined.
|
|
219
276
|
if (json) {
|
|
@@ -229,14 +286,14 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
229
286
|
return;
|
|
230
287
|
}
|
|
231
288
|
}
|
|
232
|
-
if (
|
|
289
|
+
if (!payload.success || !payload.blocks.length) process.exitCode = 1;
|
|
233
290
|
return;
|
|
234
291
|
}
|
|
235
292
|
|
|
236
293
|
const spin = output.spinner('Thinking...');
|
|
237
294
|
let resp;
|
|
238
295
|
try {
|
|
239
|
-
resp = await api.post(ENDPOINT, { body });
|
|
296
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
240
297
|
spin.stop();
|
|
241
298
|
} catch (err) {
|
|
242
299
|
spin.fail(friendlyChatError(err));
|
|
@@ -245,8 +302,7 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
245
302
|
}
|
|
246
303
|
|
|
247
304
|
renderResponse(resp);
|
|
248
|
-
|
|
249
|
-
if (resp?.intent === 'invalid') process.exitCode = 1;
|
|
305
|
+
if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
|
|
250
306
|
}
|
|
251
307
|
|
|
252
308
|
function printBanner() {
|
|
@@ -281,19 +337,19 @@ function handleSlash(line, state) {
|
|
|
281
337
|
return 'continue';
|
|
282
338
|
case '/clear':
|
|
283
339
|
state.history.length = 0;
|
|
284
|
-
state.
|
|
285
|
-
state.
|
|
340
|
+
state.conversationId = null;
|
|
341
|
+
state.lastPayload = null;
|
|
286
342
|
output.success('Conversation cleared.');
|
|
287
343
|
return 'continue';
|
|
288
344
|
case '/json':
|
|
289
|
-
if (state.
|
|
345
|
+
if (state.lastPayload) console.log(JSON.stringify(state.lastPayload, null, 2));
|
|
290
346
|
else console.log(dim('(no result yet)'));
|
|
291
347
|
return 'continue';
|
|
292
348
|
case '/export': {
|
|
293
|
-
if (!state.
|
|
349
|
+
if (!state.lastPayload) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
|
|
294
350
|
if (!argStr) { output.error('Usage: /export <path>'); return 'continue'; }
|
|
295
351
|
try {
|
|
296
|
-
const resolved = safeWriteFile(argStr, JSON.stringify(state.
|
|
352
|
+
const resolved = safeWriteFile(argStr, JSON.stringify(state.lastPayload, null, 2) + '\n');
|
|
297
353
|
output.success(`Wrote ${resolved}`);
|
|
298
354
|
} catch (err) {
|
|
299
355
|
output.error(sanitizeForTerminal(err.message));
|
|
@@ -306,8 +362,8 @@ function handleSlash(line, state) {
|
|
|
306
362
|
}
|
|
307
363
|
}
|
|
308
364
|
|
|
309
|
-
async function runRepl(
|
|
310
|
-
const state = { history: [],
|
|
365
|
+
async function runRepl() {
|
|
366
|
+
const state = { history: [], conversationId: null, lastPayload: null };
|
|
311
367
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
312
368
|
let closed = false;
|
|
313
369
|
const setClosed = () => { closed = true; };
|
|
@@ -315,20 +371,13 @@ async function runRepl({ noSuggestions }) {
|
|
|
315
371
|
rl.on('close', setClosed);
|
|
316
372
|
|
|
317
373
|
printBanner();
|
|
318
|
-
let pending = null;
|
|
319
374
|
|
|
320
375
|
while (!closed) {
|
|
321
376
|
let msg;
|
|
322
|
-
|
|
323
|
-
msg =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} else {
|
|
327
|
-
try {
|
|
328
|
-
msg = await rl.question(`${bold('chat>')} `);
|
|
329
|
-
} catch {
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
377
|
+
try {
|
|
378
|
+
msg = await rl.question(`${bold('chat>')} `);
|
|
379
|
+
} catch {
|
|
380
|
+
break;
|
|
332
381
|
}
|
|
333
382
|
if (closed) break;
|
|
334
383
|
msg = (msg || '').trim();
|
|
@@ -347,12 +396,12 @@ async function runRepl({ noSuggestions }) {
|
|
|
347
396
|
// Build the request body BEFORE pushing the current turn into history —
|
|
348
397
|
// otherwise `message` and the last entry of `conversation_history` would
|
|
349
398
|
// both contain the same user text, making the backend process it twice.
|
|
350
|
-
const body = buildBody(msg, state.history, state.
|
|
399
|
+
const body = buildBody(msg, state.history, state.conversationId);
|
|
351
400
|
|
|
352
401
|
const spin = output.spinner('Thinking...');
|
|
353
402
|
let resp;
|
|
354
403
|
try {
|
|
355
|
-
resp = await api.post(ENDPOINT, { body });
|
|
404
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
356
405
|
spin.stop();
|
|
357
406
|
} catch (err) {
|
|
358
407
|
spin.fail(friendlyChatError(err));
|
|
@@ -360,44 +409,32 @@ async function runRepl({ noSuggestions }) {
|
|
|
360
409
|
continue;
|
|
361
410
|
}
|
|
362
411
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
412
|
+
// No answer means no turn: recording the question would send the assistant
|
|
413
|
+
// something it never replied to. The id stays so the server's rows group.
|
|
414
|
+
if (resp?.conversation_id) state.conversationId = resp.conversation_id;
|
|
415
|
+
if (resp?.success === false || !(resp?.blocks || []).length) {
|
|
416
|
+
renderResponse(resp);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
366
419
|
|
|
367
|
-
const assistantTurn = normalizeAssistantTurn(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
420
|
+
const assistantTurn = normalizeAssistantTurn({
|
|
421
|
+
role: 'assistant',
|
|
422
|
+
content: flattenBlocks(resp?.blocks),
|
|
423
|
+
timestamp: nowIso(),
|
|
424
|
+
});
|
|
425
|
+
// The pair goes in together or not at all. A reply that flattens to nothing
|
|
426
|
+
// would leave the question in history with no answer beside it, and the next
|
|
427
|
+
// request would send the model two user turns in a row.
|
|
428
|
+
if (!assistantTurn?.content) {
|
|
429
|
+
renderResponse(resp);
|
|
430
|
+
continue;
|
|
372
431
|
}
|
|
432
|
+
pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
|
|
433
|
+
pushHistory(state.history, assistantTurn);
|
|
434
|
+
state.lastPayload = toJsonPayload(resp);
|
|
373
435
|
|
|
374
436
|
renderResponse(resp);
|
|
375
437
|
|
|
376
|
-
const suggestions = Array.isArray(resp?.suggestions) ? resp.suggestions : [];
|
|
377
|
-
if (suggestions.length && !noSuggestions && process.stdin.isTTY && process.stdout.isTTY) {
|
|
378
|
-
const pickOptions = [
|
|
379
|
-
...suggestions.map((s, i) => ({
|
|
380
|
-
label: sanitizeForTerminal(s.label || `Suggestion ${i + 1}`)
|
|
381
|
-
+ (s.description ? ' ' + dim('— ' + sanitizeForTerminal(s.description)) : ''),
|
|
382
|
-
value: sanitizeForTerminal(s.query || ''),
|
|
383
|
-
})).filter((o) => o.value),
|
|
384
|
-
{ label: dim('(type your own)'), value: null },
|
|
385
|
-
{ label: dim('(exit)'), value: '__exit__' },
|
|
386
|
-
];
|
|
387
|
-
let pick;
|
|
388
|
-
try {
|
|
389
|
-
rl.pause();
|
|
390
|
-
pick = await output.select('Follow up?', pickOptions);
|
|
391
|
-
} catch {
|
|
392
|
-
pick = null;
|
|
393
|
-
} finally {
|
|
394
|
-
rl.resume();
|
|
395
|
-
}
|
|
396
|
-
if (pick === '__exit__') break;
|
|
397
|
-
if (pick) pending = pick;
|
|
398
|
-
} else if (suggestions.length && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
399
|
-
renderSuggestionsForHuman(suggestions);
|
|
400
|
-
}
|
|
401
438
|
}
|
|
402
439
|
|
|
403
440
|
rl.close();
|
|
@@ -411,64 +448,54 @@ function register(program) {
|
|
|
411
448
|
.option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
|
|
412
449
|
.option('--json', 'Write the raw JSON response to stdout. Use with -m.')
|
|
413
450
|
.option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
|
|
414
|
-
.option('--no-suggestions', 'Skip the follow-up picker after each turn (interactive session only).')
|
|
415
451
|
.addHelpText('after', `
|
|
416
452
|
EXAMPLES
|
|
417
453
|
Start an interactive session:
|
|
418
454
|
$ unbound chat
|
|
419
455
|
|
|
420
|
-
Ask a single question and render
|
|
456
|
+
Ask a single question and render it in the terminal:
|
|
421
457
|
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
422
458
|
|
|
423
|
-
Write the
|
|
459
|
+
Write the JSON transcript to stdout (pipe to jq, a script, or a coding agent):
|
|
424
460
|
$ unbound chat -m "cost by provider last 30 days" --json
|
|
425
|
-
$ unbound chat -m "cost by provider last 30 days" --json | jq '.
|
|
426
|
-
$ unbound chat -m "cost by provider last 30 days" --json | jq '.
|
|
461
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[].text'
|
|
462
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[] | select(.type=="chart") | .rows'
|
|
427
463
|
|
|
428
|
-
Write
|
|
464
|
+
Write it to a file:
|
|
429
465
|
$ unbound chat -m "cost by provider last 30 days" -o cost.json
|
|
430
466
|
|
|
431
467
|
Combine --json and -o to pipe and archive at the same time:
|
|
432
|
-
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.
|
|
468
|
+
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
|
|
433
469
|
|
|
434
470
|
OUTPUT FORMAT (--json and -o)
|
|
435
|
-
{
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
DATA EXTRACTION (rows are embedded in chart_option; no separate rows field)
|
|
457
|
-
bar, line chart_option.xAxis.data (category labels, ordered)
|
|
458
|
-
chart_option.series[i].data (values, parallel to xAxis.data)
|
|
459
|
-
donut, pie chart_option.series[0].data ([{name, value}, ...])
|
|
460
|
-
scatter chart_option.series[0].data ([[x, y], ...])
|
|
461
|
-
or chart_option.dataset.source
|
|
462
|
-
table chart_option.dataset.source (first row is the header)
|
|
463
|
-
kpi chart_option.series[0].data[0] (a single number; no axes)
|
|
471
|
+
{ success, conversation_id, log_id, truncated, blocks[] }
|
|
472
|
+
|
|
473
|
+
A turn is an ordered transcript. The agent answers one thing at a time, so a
|
|
474
|
+
single question can return several charts with prose between them, and blocks[]
|
|
475
|
+
preserves that order.
|
|
476
|
+
|
|
477
|
+
blocks[i] is one of:
|
|
478
|
+
|
|
479
|
+
{ type: "text", text }
|
|
480
|
+
What the agent wrote at this point in the answer.
|
|
481
|
+
|
|
482
|
+
{ type: "chart", title, view_spec, chart_type, row_count, columns, rows }
|
|
483
|
+
columns string[] left-to-right column names
|
|
484
|
+
rows [{ <column>: value, ... }] the data, already decoded
|
|
485
|
+
chart_type "bar" | "line" | "donut" | "pie" | "kpi" | "scatter" | "table"
|
|
486
|
+
view_spec the query that produced it, for reuse or refinement
|
|
487
|
+
|
|
488
|
+
truncated: true means the agent ran out of budget partway. The blocks present
|
|
489
|
+
are real and complete; ask a follow-up for the rest.
|
|
490
|
+
|
|
491
|
+
log_id identifies the stored turn, for feedback and for support to trace.
|
|
464
492
|
|
|
465
493
|
EXIT CODES
|
|
466
494
|
0 success
|
|
467
|
-
1
|
|
495
|
+
1 no blocks returned, auth/role/network failure, or a write error
|
|
468
496
|
|
|
469
497
|
INTERACTIVE SESSION
|
|
470
498
|
$ unbound chat start the session
|
|
471
|
-
$ unbound chat --no-suggestions skip the follow-up picker
|
|
472
499
|
|
|
473
500
|
Commands (type inside the session):
|
|
474
501
|
/help show these commands
|
|
@@ -516,7 +543,7 @@ NOTES
|
|
|
516
543
|
}
|
|
517
544
|
|
|
518
545
|
try {
|
|
519
|
-
await runRepl(
|
|
546
|
+
await runRepl();
|
|
520
547
|
} catch (err) {
|
|
521
548
|
output.error(friendlyChatError(err));
|
|
522
549
|
process.exitCode = 1;
|
|
@@ -527,5 +554,8 @@ NOTES
|
|
|
527
554
|
module.exports = {
|
|
528
555
|
register,
|
|
529
556
|
// Exposed for unit tests:
|
|
530
|
-
_internals: {
|
|
557
|
+
_internals: {
|
|
558
|
+
safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
|
|
559
|
+
toJsonPayload, flattenBlocks, buildBody,
|
|
560
|
+
},
|
|
531
561
|
};
|
package/src/commands/setup.js
CHANGED
|
@@ -209,11 +209,15 @@ async function runPythonScript(scriptPath, args, { capture }) {
|
|
|
209
209
|
windowsHide: true,
|
|
210
210
|
});
|
|
211
211
|
let out = '';
|
|
212
|
+
// stderr is also kept on its own. Python block-buffers stdout when piped,
|
|
213
|
+
// so a crash's stdout can flush after the traceback and interleave with it
|
|
214
|
+
// in `out` — failureReason needs an uncontaminated stream to read.
|
|
215
|
+
let errOut = '';
|
|
212
216
|
if (capture) {
|
|
213
217
|
child.stdin.on('error', () => {});
|
|
214
218
|
child.stdin.end();
|
|
215
219
|
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
216
|
-
child.stderr.on('data', (d) => {
|
|
220
|
+
child.stderr.on('data', (d) => { const s = d.toString(); out += s; errOut += s; });
|
|
217
221
|
}
|
|
218
222
|
child.on('close', (code) => {
|
|
219
223
|
if (code === 0) return resolve();
|
|
@@ -223,7 +227,10 @@ async function runPythonScript(scriptPath, args, { capture }) {
|
|
|
223
227
|
// Carry the numeric exit code so callers can report it to the failure
|
|
224
228
|
// ledger (reportSetupFailure) without re-parsing the message.
|
|
225
229
|
err.exitCode = code;
|
|
226
|
-
if (capture)
|
|
230
|
+
if (capture) {
|
|
231
|
+
err.setupOutput = out.trim();
|
|
232
|
+
err.stderr = errOut.trim();
|
|
233
|
+
}
|
|
227
234
|
reject(err);
|
|
228
235
|
});
|
|
229
236
|
child.on('error', reject);
|
|
@@ -576,6 +583,61 @@ async function runBatch(tools, runFn, { clear = false, summary = null, report =
|
|
|
576
583
|
return { ok: true, skipped: mdmSkipped };
|
|
577
584
|
}
|
|
578
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Picks the most useful one-line reason out of a failed setup script's captured
|
|
588
|
+
* output. The python scripts open AND close with a `====` banner, so the first
|
|
589
|
+
* line is never the reason a step failed — taking it verbatim produced the
|
|
590
|
+
* useless `Codex (gateway) (====================)` that hid a real bug. Prefer
|
|
591
|
+
* the last line the script itself flagged as an error, then the exit code.
|
|
592
|
+
*
|
|
593
|
+
* Tolerates a non-Error throw: this runs inside nuke's per-tool catch, so
|
|
594
|
+
* throwing here would abort the remaining tools instead of clearing them.
|
|
595
|
+
*/
|
|
596
|
+
function failureReason(err) {
|
|
597
|
+
err = err || {};
|
|
598
|
+
// Read the traceback from stderr when we have it: `out` merges both streams by
|
|
599
|
+
// arrival order, so buffered stdout can land between the header and the
|
|
600
|
+
// exception. Fall back to the merged text for callers that don't set stderr.
|
|
601
|
+
const crash = exceptionLine(err.stderr) || exceptionLine(err.message);
|
|
602
|
+
if (crash) return truncate(crash);
|
|
603
|
+
const lines = (err.message || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
604
|
+
// Otherwise the lines the scripts themselves flag. The dotted form catches a
|
|
605
|
+
// bare exception line captured without its traceback header.
|
|
606
|
+
const flagged = lines.filter((l) => /^(❌|failed\b|error\b|exception\b|[\w.]*(error|exception):)/i.test(l));
|
|
607
|
+
const pick = flagged.length ? flagged[flagged.length - 1] : null;
|
|
608
|
+
if (pick) return truncate(pick);
|
|
609
|
+
if (err.exitCode != null) return `exit code ${err.exitCode}`;
|
|
610
|
+
return truncate(lines[0] || 'failed');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The exception a python traceback ended on, or null if `text` has no traceback.
|
|
615
|
+
* Structural rather than positional: frame lines are indented, so the exception
|
|
616
|
+
* is the first UNindented line after the header. That holds even if buffered
|
|
617
|
+
* stdout flushed into the middle of the traceback. The LAST header wins, since
|
|
618
|
+
* with chained exceptions ("During handling of the above exception...") the final
|
|
619
|
+
* one is what actually propagated.
|
|
620
|
+
*/
|
|
621
|
+
function exceptionLine(text) {
|
|
622
|
+
const raw = (text || '').split('\n');
|
|
623
|
+
let header = -1;
|
|
624
|
+
for (let i = 0; i < raw.length; i++) {
|
|
625
|
+
if (/^traceback\b/i.test(raw[i].trim())) header = i;
|
|
626
|
+
}
|
|
627
|
+
if (header === -1) return null;
|
|
628
|
+
const exc = raw.slice(header + 1).find(
|
|
629
|
+
(l) => l.trim() && !/^\s/.test(l) && !/^=+$/.test(l.trim())
|
|
630
|
+
);
|
|
631
|
+
return exc ? exc.trim() : null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Slice by code point, not code unit, so an 80-char cut can't split an emoji
|
|
635
|
+
// or other astral character into a broken fragment.
|
|
636
|
+
function truncate(s, max = 80) {
|
|
637
|
+
const chars = Array.from(s);
|
|
638
|
+
return chars.length > max ? chars.slice(0, max).join('') : s;
|
|
639
|
+
}
|
|
640
|
+
|
|
579
641
|
/**
|
|
580
642
|
* Clears a set of tools best-effort: a single tool failing does not abort the
|
|
581
643
|
* rest (unlike runBatch, which stops on first failure). Used by `uninstall`,
|
|
@@ -592,8 +654,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
|
|
|
592
654
|
await runScriptPiped(tool.script, args);
|
|
593
655
|
s.succeed(`${layer}: ${tool.label}`);
|
|
594
656
|
} catch (err) {
|
|
595
|
-
|
|
596
|
-
s.fail(`${layer}: ${tool.label} (${reason})`);
|
|
657
|
+
s.fail(`${layer}: ${tool.label} (${failureReason(err)})`);
|
|
597
658
|
failed.push(tool.label);
|
|
598
659
|
}
|
|
599
660
|
}
|
|
@@ -1183,6 +1244,7 @@ module.exports = {
|
|
|
1183
1244
|
scriptSupportsBackfill,
|
|
1184
1245
|
resolveSetupAllTools,
|
|
1185
1246
|
clearUnboundEnvsEverywhere,
|
|
1247
|
+
failureReason,
|
|
1186
1248
|
NUKE_ENV_VARS,
|
|
1187
1249
|
removeBinaryInstall,
|
|
1188
1250
|
runBinaryClear,
|
|
@@ -203,3 +203,27 @@ test('formatNumber: integer thousands separator, decimal capped', () => {
|
|
|
203
203
|
assert.equal(chart.formatNumber(12.3456), '12.35');
|
|
204
204
|
assert.equal(chart.formatNumber(0), '0');
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
test('reconstructRows: decodes a table chart_option', () => {
|
|
208
|
+
const { columns, rows } = chart.reconstructRows(
|
|
209
|
+
{ table: { columns: ['AI Model', 'Requests'], rows: [{ 'AI Model': 'gpt-4o', Requests: 172 }] } },
|
|
210
|
+
{ chart_type: 'table' },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(columns, ['AI Model', 'Requests']);
|
|
214
|
+
assert.deepEqual(rows, [{ 'AI Model': 'gpt-4o', Requests: 172 }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('reconstructRows: table rows may be arrays aligned to columns', () => {
|
|
218
|
+
const { columns, rows } = chart.reconstructRows(
|
|
219
|
+
{ table: { columns: ['a', 'b'], rows: [['x', 1], ['y', 2]] } },
|
|
220
|
+
{ chart_type: 'table' },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(columns, ['a', 'b']);
|
|
224
|
+
assert.deepEqual(rows, [{ a: 'x', b: 1 }, { a: 'y', b: 2 }]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('reconstructRows: a malformed table falls through instead of throwing', () => {
|
|
228
|
+
assert.deepEqual(chart.reconstructRows({ table: { columns: 'nope' } }, {}), { columns: [], rows: [] });
|
|
229
|
+
});
|
|
@@ -142,3 +142,117 @@ test('friendlyChatError: sanitizes ANSI from backend error messages', () => {
|
|
|
142
142
|
const msg = friendlyChatError({ statusCode: 500, message: '\x1b]0;title\x07boom\x1b[31m' });
|
|
143
143
|
assert.equal(msg, 'boom');
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
// --- agent transcript mapping -------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const { toJsonPayload, flattenBlocks, buildBody } = chat._internals;
|
|
149
|
+
|
|
150
|
+
// One chart, shaped the way the agent endpoint returns it: a bar chart whose
|
|
151
|
+
// rows live inside the ECharts option rather than in a rows field.
|
|
152
|
+
function chartBlock(title) {
|
|
153
|
+
return {
|
|
154
|
+
type: 'chart',
|
|
155
|
+
title,
|
|
156
|
+
result: {
|
|
157
|
+
view_spec: { base_table: 'gateway_metrics_summary' },
|
|
158
|
+
row_count: 2,
|
|
159
|
+
chart_meta: { chart_type: 'bar', x: 'dimension', y: ['metric_value'], column_labels: {} },
|
|
160
|
+
chart_option: {
|
|
161
|
+
xAxis: { data: ['openai', 'anthropic'] },
|
|
162
|
+
series: [{ name: 'metric_value', data: [10, 20] }],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test('toJsonPayload: keeps prose and charts in the order the agent produced them', () => {
|
|
169
|
+
const payload = toJsonPayload({
|
|
170
|
+
success: true,
|
|
171
|
+
conversation_id: 'c1',
|
|
172
|
+
log_id: 'l1',
|
|
173
|
+
blocks: [
|
|
174
|
+
{ type: 'text', text: 'Here is spend.' },
|
|
175
|
+
chartBlock('Cost by provider'),
|
|
176
|
+
{ type: 'text', text: 'Note the gap.' },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.deepEqual(payload.blocks.map((b) => b.type), ['text', 'chart', 'text']);
|
|
181
|
+
assert.equal(payload.conversation_id, 'c1');
|
|
182
|
+
assert.equal(payload.log_id, 'l1');
|
|
183
|
+
assert.equal(payload.truncated, false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('toJsonPayload: decodes chart rows so consumers never parse an ECharts option', () => {
|
|
187
|
+
const payload = toJsonPayload({ blocks: [chartBlock('Cost by provider')] });
|
|
188
|
+
const chartOut = payload.blocks[0];
|
|
189
|
+
|
|
190
|
+
assert.equal(chartOut.title, 'Cost by provider');
|
|
191
|
+
assert.equal(chartOut.chart_type, 'bar');
|
|
192
|
+
assert.equal(chartOut.row_count, 2);
|
|
193
|
+
assert.ok(chartOut.columns.length >= 2, 'columns should be populated');
|
|
194
|
+
assert.equal(chartOut.rows.length, 2);
|
|
195
|
+
assert.deepEqual(chartOut.view_spec, { base_table: 'gateway_metrics_summary' });
|
|
196
|
+
// The wire format is an implementation detail of the browser renderer.
|
|
197
|
+
assert.equal(chartOut.chart_option, undefined);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('toJsonPayload: an undecodable chart still appears, with its spec intact', () => {
|
|
201
|
+
const payload = toJsonPayload({
|
|
202
|
+
blocks: [{ type: 'chart', title: 'Broken', result: { view_spec: { a: 1 }, row_count: 7 } }],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(payload.blocks.length, 1);
|
|
206
|
+
assert.equal(payload.blocks[0].row_count, 7);
|
|
207
|
+
assert.deepEqual(payload.blocks[0].view_spec, { a: 1 });
|
|
208
|
+
assert.deepEqual(payload.blocks[0].rows, []);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('toJsonPayload: surfaces truncation and failure rather than hiding them', () => {
|
|
212
|
+
assert.equal(toJsonPayload({ blocks: [], truncated: true }).truncated, true);
|
|
213
|
+
assert.equal(toJsonPayload({ success: false, error: 'nope' }).success, false);
|
|
214
|
+
assert.deepEqual(toJsonPayload(null).blocks, []);
|
|
215
|
+
assert.deepEqual(toJsonPayload({ blocks: 'not-an-array' }).blocks, []);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('flattenBlocks: a chart becomes its title, so history carries words only', () => {
|
|
219
|
+
const flat = flattenBlocks([
|
|
220
|
+
{ type: 'text', text: 'First.' },
|
|
221
|
+
{ type: 'chart', title: 'Cost by provider' },
|
|
222
|
+
{ type: 'text', text: 'Second.' },
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
assert.equal(flat, 'First.\n\n[chart: Cost by provider]\n\nSecond.');
|
|
226
|
+
assert.equal(flattenBlocks(undefined), '');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('buildBody: omits conversation_id on the first turn, sends it after', () => {
|
|
230
|
+
assert.equal('conversation_id' in buildBody('hi', [], null), false);
|
|
231
|
+
assert.equal(buildBody('hi', [], 'c9').conversation_id, 'c9');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('toJsonPayload: carries the failure reason, not just the failure', () => {
|
|
235
|
+
const payload = toJsonPayload({ success: false, error: 'The assistant is unavailable right now.' });
|
|
236
|
+
|
|
237
|
+
assert.equal(payload.success, false);
|
|
238
|
+
// Without this a --json consumer gets an exit code and nothing to act on.
|
|
239
|
+
assert.equal(payload.error, 'The assistant is unavailable right now.');
|
|
240
|
+
assert.equal(toJsonPayload({ blocks: [] }).error, null);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('toJsonPayload: an empty chart and an unreadable one are not the same block', () => {
|
|
244
|
+
const unreadable = { get table() { throw new TypeError('unreadable'); } };
|
|
245
|
+
const payload = toJsonPayload({
|
|
246
|
+
blocks: [
|
|
247
|
+
{ type: 'chart', title: 'Broken', result: { chart_option: unreadable, view_spec: { a: 1 }, row_count: 7 } },
|
|
248
|
+
{ type: 'chart', title: 'Empty', result: { chart_option: {}, view_spec: { b: 2 }, row_count: 0 } },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
assert.equal(payload.blocks[0].decode_failed, true);
|
|
253
|
+
assert.deepEqual(payload.blocks[0].rows, []);
|
|
254
|
+
// The spec survives, so the caller still knows what was asked.
|
|
255
|
+
assert.deepEqual(payload.blocks[0].view_spec, { a: 1 });
|
|
256
|
+
// A genuinely empty answer carries no such marker.
|
|
257
|
+
assert.equal('decode_failed' in payload.blocks[1], false);
|
|
258
|
+
});
|
package/test/setup-args.test.js
CHANGED
|
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
|
|
6
|
+
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
|
|
7
7
|
|
|
8
8
|
// shellEscape single-quotes every value, so a real key surfaces as
|
|
9
9
|
// --api-key '<key>' at the head of the argv tail.
|
|
@@ -451,3 +451,170 @@ test('runBinaryClear: clear exits non-zero — fail-open (ran:true, ok:false), n
|
|
|
451
451
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
452
452
|
}
|
|
453
453
|
});
|
|
454
|
+
|
|
455
|
+
// The python clears open AND close with a `====` banner, so the old
|
|
456
|
+
// `.split('\n')[0]` reason rendered `Codex (gateway) (==================)` —
|
|
457
|
+
// which is what hid the real MDM-clear bug. Prefer the script's own error line.
|
|
458
|
+
test('failureReason: skips the ==== banner and reports the script error line', () => {
|
|
459
|
+
const err = new Error([
|
|
460
|
+
'============================================================',
|
|
461
|
+
'Codex - Clearing MDM Setup',
|
|
462
|
+
'============================================================',
|
|
463
|
+
'',
|
|
464
|
+
'Clearing environment variables...',
|
|
465
|
+
'Failed to clear openai_base_url in codex config for alice',
|
|
466
|
+
'',
|
|
467
|
+
'Clear Complete!',
|
|
468
|
+
].join('\n'));
|
|
469
|
+
err.exitCode = 1;
|
|
470
|
+
assert.equal(failureReason(err), 'Failed to clear openai_base_url in codex config for alice');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('failureReason: picks the LAST flagged line when a script reports several', () => {
|
|
474
|
+
const err = new Error('Failed to clear API_KEY for 1 user(s)\nFailed to clear BASE_URL for 1 user(s)');
|
|
475
|
+
assert.equal(failureReason(err), 'Failed to clear BASE_URL for 1 user(s)');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('failureReason: recognises the scripts’ ❌ prefix', () => {
|
|
479
|
+
const err = new Error('====\nSetup\n====\n❌ No user home directories found');
|
|
480
|
+
err.exitCode = 1;
|
|
481
|
+
assert.equal(failureReason(err), '❌ No user home directories found');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Banner-only output with no error line must not surface "Clear Complete!" as
|
|
485
|
+
// the reason — the exit code is the honest answer there.
|
|
486
|
+
test('failureReason: no flagged line falls back to the exit code, not the banner', () => {
|
|
487
|
+
const err = new Error('============\nCodex - Clearing MDM Setup\n============\n\nClear Complete!');
|
|
488
|
+
err.exitCode = 2;
|
|
489
|
+
assert.equal(failureReason(err), 'exit code 2');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Download failures carry no exitCode; their message is already the reason.
|
|
493
|
+
test('failureReason: download failure with no exitCode keeps its own message', () => {
|
|
494
|
+
const err = new Error('Download failed: 404 Not Found');
|
|
495
|
+
err.downloadFailed = true;
|
|
496
|
+
assert.equal(failureReason(err), 'Download failed: 404 Not Found');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('failureReason: empty message never returns undefined', () => {
|
|
500
|
+
assert.equal(failureReason(new Error('')), 'failed');
|
|
501
|
+
assert.equal(failureReason({}), 'failed');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('failureReason: long error lines are truncated to 80 chars', () => {
|
|
505
|
+
const err = new Error('Failed to ' + 'x'.repeat(200));
|
|
506
|
+
assert.equal(failureReason(err).length, 80);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// A python crash prints a traceback whose LAST line is the real cause. Falling
|
|
510
|
+
// back to `exit code 1` there would be less useful than the old first-line rule.
|
|
511
|
+
test('failureReason: python traceback reports the exception, not the header', () => {
|
|
512
|
+
const err = new Error([
|
|
513
|
+
'Traceback (most recent call last):',
|
|
514
|
+
' File "/tmp/setup.py", line 704, in clear_setup',
|
|
515
|
+
' remove_codex_config_base_url_for_user(username, home_dir)',
|
|
516
|
+
"NameError: name 'home_dir' is not defined",
|
|
517
|
+
].join('\n'));
|
|
518
|
+
err.exitCode = 1;
|
|
519
|
+
assert.equal(failureReason(err), "NameError: name 'home_dir' is not defined");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// exitCode 0 is falsy — must not fall through to the banner-first branch.
|
|
523
|
+
// Module-qualified exception names have dots, so a name-shaped regex misses
|
|
524
|
+
// them. The traceback tail is taken instead, whatever the exception is called.
|
|
525
|
+
test('failureReason: dotted exception name in a traceback is reported', () => {
|
|
526
|
+
const err = new Error([
|
|
527
|
+
'Traceback (most recent call last):',
|
|
528
|
+
' File "/tmp/setup.py", line 800, in clear_managed_hooks',
|
|
529
|
+
' settings = json.load(f)',
|
|
530
|
+
'json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)',
|
|
531
|
+
].join('\n'));
|
|
532
|
+
err.exitCode = 1;
|
|
533
|
+
assert.equal(failureReason(err), 'json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// Not every python exception name contains "Error"/"Exception".
|
|
537
|
+
test('failureReason: traceback tail works for a non-Error exception name', () => {
|
|
538
|
+
const err = new Error('Traceback (most recent call last):\n File "x.py", line 1\nsocket.timeout: timed out');
|
|
539
|
+
err.exitCode = 1;
|
|
540
|
+
assert.equal(failureReason(err), 'socket.timeout: timed out');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// A bare exception line captured without its traceback header still flags.
|
|
544
|
+
test('failureReason: dotted exception line without a traceback header flags', () => {
|
|
545
|
+
const err = new Error('====\nCodex - Clearing MDM Setup\n====\nsubprocess.CalledProcessError: returned non-zero exit status 1');
|
|
546
|
+
err.exitCode = 1;
|
|
547
|
+
assert.equal(failureReason(err), 'subprocess.CalledProcessError: returned non-zero exit status 1');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Banner lines after the traceback must not be mistaken for the cause.
|
|
551
|
+
test('failureReason: banner trailing a traceback is skipped', () => {
|
|
552
|
+
const err = new Error('Traceback (most recent call last):\nValueError: bad\n============');
|
|
553
|
+
err.exitCode = 1;
|
|
554
|
+
assert.equal(failureReason(err), 'ValueError: bad');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// runPythonScript merges stdout+stderr by arrival order and python block-buffers
|
|
558
|
+
// stdout when piped, so a crash's progress lines can land AFTER the traceback.
|
|
559
|
+
// Picking the tail would report "Clear Complete!" as the failure reason.
|
|
560
|
+
test('failureReason: stdout flushed after the traceback does not displace the exception', () => {
|
|
561
|
+
const err = new Error([
|
|
562
|
+
'Traceback (most recent call last):',
|
|
563
|
+
' File "/tmp/setup.py", line 9, in clear_setup',
|
|
564
|
+
'ValueError: the real cause',
|
|
565
|
+
'============================================================',
|
|
566
|
+
'Clear Complete!',
|
|
567
|
+
].join('\n'));
|
|
568
|
+
err.exitCode = 1;
|
|
569
|
+
assert.equal(failureReason(err), 'ValueError: the real cause');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// stderr is captured separately precisely so interleaving can't matter.
|
|
573
|
+
test('failureReason: prefers the clean stderr stream over the merged output', () => {
|
|
574
|
+
const err = new Error('====\nClearing environment variables...\nTraceback (most recent call last):\nOSError: merged and interleaved\nClear Complete!');
|
|
575
|
+
err.stderr = 'Traceback (most recent call last):\n File "x.py", line 1\nPermissionError: from stderr';
|
|
576
|
+
err.exitCode = 1;
|
|
577
|
+
assert.equal(failureReason(err), 'PermissionError: from stderr');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Chained exceptions: the LAST traceback is the one that actually propagated.
|
|
581
|
+
test('failureReason: chained exceptions report the final one', () => {
|
|
582
|
+
const err = new Error([
|
|
583
|
+
'Traceback (most recent call last):',
|
|
584
|
+
' File "x.py", line 1',
|
|
585
|
+
'ValueError: the first one',
|
|
586
|
+
'During handling of the above exception, another exception occurred:',
|
|
587
|
+
'Traceback (most recent call last):',
|
|
588
|
+
' File "x.py", line 5',
|
|
589
|
+
'RuntimeError: the one that propagated',
|
|
590
|
+
].join('\n'));
|
|
591
|
+
err.exitCode = 1;
|
|
592
|
+
assert.equal(failureReason(err), 'RuntimeError: the one that propagated');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Indented frame lines are never the reason.
|
|
596
|
+
test('failureReason: never reports an indented traceback frame line', () => {
|
|
597
|
+
const err = new Error('Traceback (most recent call last):\n File "x.py", line 1, in f\n boom()\nKeyError: \'gateway_url\'');
|
|
598
|
+
err.exitCode = 1;
|
|
599
|
+
assert.equal(failureReason(err), "KeyError: 'gateway_url'");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test('failureReason: exitCode 0 is reported, not treated as absent', () => {
|
|
603
|
+
assert.equal(failureReason({ message: '====\nCodex\n====', exitCode: 0 }), 'exit code 0');
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Truncation slices code points, so an emoji at the boundary stays intact.
|
|
607
|
+
test('failureReason: truncation never splits an astral character', () => {
|
|
608
|
+
const reason = failureReason(new Error('Failed ' + '🔥'.repeat(100)));
|
|
609
|
+
assert.equal(Array.from(reason).length, 80);
|
|
610
|
+
assert.ok(!reason.includes('\ufffd'), reason);
|
|
611
|
+
assert.ok(/🔥$/.test(reason), reason);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Runs inside nuke's per-tool catch — throwing here would abort the remaining
|
|
615
|
+
// tools instead of clearing them, so a non-Error throw must not blow up.
|
|
616
|
+
test('failureReason: non-Error throw (null/undefined) never throws', () => {
|
|
617
|
+
assert.equal(failureReason(null), 'failed');
|
|
618
|
+
assert.equal(failureReason(undefined), 'failed');
|
|
619
|
+
assert.equal(failureReason('a string'), 'failed');
|
|
620
|
+
});
|