unbound-cli 1.11.1 → 1.13.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 +27 -0
- package/src/commands/chat.js +261 -160
- package/test/chart-render.test.js +45 -0
- package/test/chat-internals.test.js +130 -0
- package/test/chat-json-stdout.test.js +85 -0
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) {
|
|
@@ -146,6 +162,17 @@ function reconstructRows(chart_option, chart_meta) {
|
|
|
146
162
|
return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(flat.data[0]) }] };
|
|
147
163
|
}
|
|
148
164
|
|
|
165
|
+
// 5) KPI as the builder actually emits it: series is [] and the number lives,
|
|
166
|
+
// already formatted, in a graphic text element. Nothing above can see it, so a
|
|
167
|
+
// single-number answer decoded to nothing and read as an empty result.
|
|
168
|
+
const graphicText = asArray(chart_option.graphic)
|
|
169
|
+
.map((g) => g?.style?.text)
|
|
170
|
+
.find((t) => typeof t === 'string' && t.trim());
|
|
171
|
+
if (graphicText) {
|
|
172
|
+
const valueKey = yKeys[0] || 'value';
|
|
173
|
+
return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(graphicText.trim()) }] };
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
return { columns: [], rows: [] };
|
|
150
177
|
}
|
|
151
178
|
|
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,70 @@ function safeWriteFile(target, data) {
|
|
|
79
85
|
return resolved;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
89
|
+
|
|
90
|
+
function buildBody(message, history, conversationId) {
|
|
91
|
+
const body = {
|
|
84
92
|
message,
|
|
85
93
|
conversation_history: history.slice(-HISTORY_CAP),
|
|
86
|
-
recent_results: recent.slice(-RECENT_RESULTS_CAP),
|
|
87
94
|
};
|
|
95
|
+
if (conversationId) body.conversation_id = conversationId;
|
|
96
|
+
return body;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
// The model gets the words, not the tables. Same flattening the web client uses.
|
|
100
|
+
function flattenBlocks(blocks) {
|
|
101
|
+
return (blocks || [])
|
|
102
|
+
.map((b) => (b.type === 'text' ? b.text : `[chart: ${b.title}]`))
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join('\n\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The transcript, in order, with charts decoded to rows.
|
|
109
|
+
*
|
|
110
|
+
* This is the CLI's JSON contract. The wire format carries an ECharts option
|
|
111
|
+
* because the browser renders one; nothing downstream of `--json` wants that, so
|
|
112
|
+
* charts are reduced to view_spec + columns + rows.
|
|
113
|
+
*/
|
|
114
|
+
function toJsonPayload(resp) {
|
|
115
|
+
const blocks = Array.isArray(resp?.blocks) ? resp.blocks : [];
|
|
116
|
+
return {
|
|
117
|
+
success: resp?.success !== false,
|
|
118
|
+
// Without it a --json consumer gets exit 1 and no reason.
|
|
119
|
+
error: resp?.error ?? null,
|
|
120
|
+
conversation_id: resp?.conversation_id ?? null,
|
|
121
|
+
log_id: resp?.log_id ?? null,
|
|
122
|
+
// Ran out of budget mid-answer; the blocks already here are real.
|
|
123
|
+
truncated: resp?.truncated === true,
|
|
124
|
+
// False when -c named a conversation the server found nothing for. A mistyped
|
|
125
|
+
// id is still well-formed, so without this the turn reads as a success that
|
|
126
|
+
// quietly lost the thread.
|
|
127
|
+
resumed: resp?.resumed === true,
|
|
128
|
+
blocks: blocks.map((b) => {
|
|
129
|
+
if (b.type === 'text') return { type: 'text', text: b.text };
|
|
130
|
+
const result = b.result || {};
|
|
131
|
+
let columns = [];
|
|
132
|
+
let rows = [];
|
|
133
|
+
let decoded = true;
|
|
134
|
+
try {
|
|
135
|
+
({ columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta));
|
|
136
|
+
} catch {
|
|
137
|
+
decoded = false;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
type: 'chart',
|
|
141
|
+
title: b.title,
|
|
142
|
+
view_spec: result.view_spec ?? null,
|
|
143
|
+
chart_type: result.chart_meta?.chart_type ?? null,
|
|
144
|
+
row_count: result.row_count ?? rows.length,
|
|
145
|
+
columns,
|
|
146
|
+
rows,
|
|
147
|
+
// Empty rows because it would not decode, not because the answer was empty.
|
|
148
|
+
...(decoded ? {} : { decode_failed: true }),
|
|
149
|
+
};
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
100
152
|
}
|
|
101
153
|
|
|
102
154
|
function pushHistory(history, turn) {
|
|
@@ -126,51 +178,31 @@ function friendlyChatError(err) {
|
|
|
126
178
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
127
179
|
}
|
|
128
180
|
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
/** Print one chart: its title, a one-line summary, then a body or a table. */
|
|
182
|
+
function renderChartBlock(block) {
|
|
183
|
+
const result = block.result || {};
|
|
131
184
|
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
|
-
}
|
|
185
|
+
console.log(bold(' ' + sanitizeForTerminal(block.title || 'Result')));
|
|
186
|
+
console.log(dim(' ' + sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count))));
|
|
142
187
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const message = sanitizeForTerminal(resp?.message || '');
|
|
151
|
-
|
|
152
|
-
if (intent === 'invalid') {
|
|
153
|
-
output.error(message || 'The request could not be processed.');
|
|
154
|
-
return;
|
|
188
|
+
let columns = [];
|
|
189
|
+
let rows = [];
|
|
190
|
+
let decoded = true;
|
|
191
|
+
try {
|
|
192
|
+
({ columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta));
|
|
193
|
+
} catch {
|
|
194
|
+
decoded = false;
|
|
155
195
|
}
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
196
|
+
// Not the same as a chart with no rows, and must not read like one.
|
|
197
|
+
if (!decoded) {
|
|
198
|
+
console.log(dim(' (could not read this chart — --json still carries its spec)'));
|
|
159
199
|
return;
|
|
160
200
|
}
|
|
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
201
|
if (!rows.length) {
|
|
169
202
|
console.log(dim(' (no rows to render)'));
|
|
170
203
|
return;
|
|
171
204
|
}
|
|
172
205
|
|
|
173
|
-
console.log('');
|
|
174
206
|
const cap = chart.MAX_RENDER_ROWS;
|
|
175
207
|
const renderRows = rows.slice(0, cap);
|
|
176
208
|
const chartType = result.chart_meta?.chart_type;
|
|
@@ -201,19 +233,65 @@ function renderResponse(resp) {
|
|
|
201
233
|
}
|
|
202
234
|
}
|
|
203
235
|
|
|
204
|
-
|
|
205
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Print the turn in the order the agent produced it — prose where it wrote
|
|
238
|
+
* prose, a chart where it ran one. A turn can hold several of each.
|
|
239
|
+
*/
|
|
240
|
+
function renderResponse(resp) {
|
|
241
|
+
if (resp?.success === false) {
|
|
242
|
+
output.error(sanitizeForTerminal(resp?.error || 'The request could not be processed.'));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const blocks = Array.isArray(resp?.blocks) ? resp.blocks : [];
|
|
247
|
+
if (!blocks.length) {
|
|
248
|
+
output.warn('The assistant returned nothing. Try rephrasing the question.');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const block of blocks) {
|
|
253
|
+
if (block?.type === 'text') {
|
|
254
|
+
if (block.text) console.log(cyan('\u25cf ') + sanitizeForTerminal(block.text));
|
|
255
|
+
} else if (block?.type === 'chart') {
|
|
256
|
+
renderChartBlock(block);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (resp?.truncated) {
|
|
261
|
+
console.log('');
|
|
262
|
+
output.warn('That is as far as it got in one turn. Ask for the rest to continue.');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function warnIfNotResumed(conversationId, payload) {
|
|
267
|
+
if (conversationId && !payload.resumed) {
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`Nothing to continue for ${conversationId} — answering without it. `
|
|
270
|
+
+ 'Check the conversation_id from the previous reply.\n',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function runOneShot({ message, json, outputPath, conversationId }) {
|
|
276
|
+
const body = buildBody(message, [], conversationId);
|
|
206
277
|
|
|
207
278
|
if (json || outputPath) {
|
|
208
279
|
let resp;
|
|
209
280
|
try {
|
|
210
|
-
resp = await api.post(ENDPOINT, { body });
|
|
281
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
211
282
|
} catch (err) {
|
|
212
283
|
output.error(friendlyChatError(err));
|
|
213
284
|
process.exitCode = 1;
|
|
214
285
|
return;
|
|
215
286
|
}
|
|
216
|
-
const
|
|
287
|
+
const payload = toJsonPayload(resp);
|
|
288
|
+
warnIfNotResumed(conversationId, payload);
|
|
289
|
+
// On stderr: stdout is the transcript, and a reader piping it to jq must not
|
|
290
|
+
// find this in the stream. It is still on screen for whoever ran the command.
|
|
291
|
+
if (payload.conversation_id && payload.blocks.length) {
|
|
292
|
+
process.stderr.write(`continue with: -c ${sanitizeForTerminal(String(payload.conversation_id))}\n`);
|
|
293
|
+
}
|
|
294
|
+
const serialized = JSON.stringify(payload, null, 2) + '\n';
|
|
217
295
|
// Write to stdout first (when requested) so a downstream `-o` write failure
|
|
218
296
|
// doesn't eat the response when both flags are combined.
|
|
219
297
|
if (json) {
|
|
@@ -222,21 +300,25 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
222
300
|
if (outputPath) {
|
|
223
301
|
try {
|
|
224
302
|
const resolved = safeWriteFile(outputPath, serialized);
|
|
225
|
-
|
|
303
|
+
// With --json the transcript owns stdout. jq parses the whole stream, so a
|
|
304
|
+
// confirmation printed after it makes the output unparseable and exits 5 —
|
|
305
|
+
// while still emitting the right answer, which is worse than failing outright.
|
|
306
|
+
if (json) process.stderr.write(`Wrote ${resolved}\n`);
|
|
307
|
+
else output.success(`Wrote ${resolved}`);
|
|
226
308
|
} catch (err) {
|
|
227
309
|
output.error(sanitizeForTerminal(err.message));
|
|
228
310
|
process.exitCode = 1;
|
|
229
311
|
return;
|
|
230
312
|
}
|
|
231
313
|
}
|
|
232
|
-
if (
|
|
314
|
+
if (!payload.success || !payload.blocks.length) process.exitCode = 1;
|
|
233
315
|
return;
|
|
234
316
|
}
|
|
235
317
|
|
|
236
318
|
const spin = output.spinner('Thinking...');
|
|
237
319
|
let resp;
|
|
238
320
|
try {
|
|
239
|
-
resp = await api.post(ENDPOINT, { body });
|
|
321
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
240
322
|
spin.stop();
|
|
241
323
|
} catch (err) {
|
|
242
324
|
spin.fail(friendlyChatError(err));
|
|
@@ -244,9 +326,16 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
244
326
|
return;
|
|
245
327
|
}
|
|
246
328
|
|
|
329
|
+
warnIfNotResumed(conversationId, toJsonPayload(resp));
|
|
247
330
|
renderResponse(resp);
|
|
248
|
-
|
|
249
|
-
|
|
331
|
+
// Without this the id only exists in --json, so continuing a conversation from
|
|
332
|
+
// the terminal would mean asking the same question again just to read it back.
|
|
333
|
+
const id = resp?.conversation_id;
|
|
334
|
+
if (id && (resp?.blocks || []).length) {
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log(dim(` continue: unbound chat -m "..." -c ${sanitizeForTerminal(String(id))}`));
|
|
337
|
+
}
|
|
338
|
+
if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
|
|
250
339
|
}
|
|
251
340
|
|
|
252
341
|
function printBanner() {
|
|
@@ -281,19 +370,19 @@ function handleSlash(line, state) {
|
|
|
281
370
|
return 'continue';
|
|
282
371
|
case '/clear':
|
|
283
372
|
state.history.length = 0;
|
|
284
|
-
state.
|
|
285
|
-
state.
|
|
373
|
+
state.conversationId = null;
|
|
374
|
+
state.lastPayload = null;
|
|
286
375
|
output.success('Conversation cleared.');
|
|
287
376
|
return 'continue';
|
|
288
377
|
case '/json':
|
|
289
|
-
if (state.
|
|
378
|
+
if (state.lastPayload) console.log(JSON.stringify(state.lastPayload, null, 2));
|
|
290
379
|
else console.log(dim('(no result yet)'));
|
|
291
380
|
return 'continue';
|
|
292
381
|
case '/export': {
|
|
293
|
-
if (!state.
|
|
382
|
+
if (!state.lastPayload) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
|
|
294
383
|
if (!argStr) { output.error('Usage: /export <path>'); return 'continue'; }
|
|
295
384
|
try {
|
|
296
|
-
const resolved = safeWriteFile(argStr, JSON.stringify(state.
|
|
385
|
+
const resolved = safeWriteFile(argStr, JSON.stringify(state.lastPayload, null, 2) + '\n');
|
|
297
386
|
output.success(`Wrote ${resolved}`);
|
|
298
387
|
} catch (err) {
|
|
299
388
|
output.error(sanitizeForTerminal(err.message));
|
|
@@ -306,8 +395,8 @@ function handleSlash(line, state) {
|
|
|
306
395
|
}
|
|
307
396
|
}
|
|
308
397
|
|
|
309
|
-
async function runRepl(
|
|
310
|
-
const state = { history: [],
|
|
398
|
+
async function runRepl() {
|
|
399
|
+
const state = { history: [], conversationId: null, lastPayload: null };
|
|
311
400
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
312
401
|
let closed = false;
|
|
313
402
|
const setClosed = () => { closed = true; };
|
|
@@ -315,20 +404,13 @@ async function runRepl({ noSuggestions }) {
|
|
|
315
404
|
rl.on('close', setClosed);
|
|
316
405
|
|
|
317
406
|
printBanner();
|
|
318
|
-
let pending = null;
|
|
319
407
|
|
|
320
408
|
while (!closed) {
|
|
321
409
|
let msg;
|
|
322
|
-
|
|
323
|
-
msg =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
} else {
|
|
327
|
-
try {
|
|
328
|
-
msg = await rl.question(`${bold('chat>')} `);
|
|
329
|
-
} catch {
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
410
|
+
try {
|
|
411
|
+
msg = await rl.question(`${bold('chat>')} `);
|
|
412
|
+
} catch {
|
|
413
|
+
break;
|
|
332
414
|
}
|
|
333
415
|
if (closed) break;
|
|
334
416
|
msg = (msg || '').trim();
|
|
@@ -347,12 +429,12 @@ async function runRepl({ noSuggestions }) {
|
|
|
347
429
|
// Build the request body BEFORE pushing the current turn into history —
|
|
348
430
|
// otherwise `message` and the last entry of `conversation_history` would
|
|
349
431
|
// both contain the same user text, making the backend process it twice.
|
|
350
|
-
const body = buildBody(msg, state.history, state.
|
|
432
|
+
const body = buildBody(msg, state.history, state.conversationId);
|
|
351
433
|
|
|
352
434
|
const spin = output.spinner('Thinking...');
|
|
353
435
|
let resp;
|
|
354
436
|
try {
|
|
355
|
-
resp = await api.post(ENDPOINT, { body });
|
|
437
|
+
resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
356
438
|
spin.stop();
|
|
357
439
|
} catch (err) {
|
|
358
440
|
spin.fail(friendlyChatError(err));
|
|
@@ -360,44 +442,32 @@ async function runRepl({ noSuggestions }) {
|
|
|
360
442
|
continue;
|
|
361
443
|
}
|
|
362
444
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
445
|
+
// No answer means no turn: recording the question would send the assistant
|
|
446
|
+
// something it never replied to. The id stays so the server's rows group.
|
|
447
|
+
if (resp?.conversation_id) state.conversationId = resp.conversation_id;
|
|
448
|
+
if (resp?.success === false || !(resp?.blocks || []).length) {
|
|
449
|
+
renderResponse(resp);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
366
452
|
|
|
367
|
-
const assistantTurn = normalizeAssistantTurn(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
453
|
+
const assistantTurn = normalizeAssistantTurn({
|
|
454
|
+
role: 'assistant',
|
|
455
|
+
content: flattenBlocks(resp?.blocks),
|
|
456
|
+
timestamp: nowIso(),
|
|
457
|
+
});
|
|
458
|
+
// The pair goes in together or not at all. A reply that flattens to nothing
|
|
459
|
+
// would leave the question in history with no answer beside it, and the next
|
|
460
|
+
// request would send the model two user turns in a row.
|
|
461
|
+
if (!assistantTurn?.content) {
|
|
462
|
+
renderResponse(resp);
|
|
463
|
+
continue;
|
|
372
464
|
}
|
|
465
|
+
pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
|
|
466
|
+
pushHistory(state.history, assistantTurn);
|
|
467
|
+
state.lastPayload = toJsonPayload(resp);
|
|
373
468
|
|
|
374
469
|
renderResponse(resp);
|
|
375
470
|
|
|
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
471
|
}
|
|
402
472
|
|
|
403
473
|
rl.close();
|
|
@@ -411,64 +481,72 @@ function register(program) {
|
|
|
411
481
|
.option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
|
|
412
482
|
.option('--json', 'Write the raw JSON response to stdout. Use with -m.')
|
|
413
483
|
.option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
|
|
414
|
-
.option('--
|
|
484
|
+
.option('-c, --continue <conversation_id>', 'Continue the conversation with this id. Use with -m.')
|
|
415
485
|
.addHelpText('after', `
|
|
416
486
|
EXAMPLES
|
|
417
487
|
Start an interactive session:
|
|
418
488
|
$ unbound chat
|
|
419
489
|
|
|
420
|
-
Ask a single question and render
|
|
490
|
+
Ask a single question and render it in the terminal:
|
|
421
491
|
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
422
492
|
|
|
423
|
-
Write the
|
|
493
|
+
Write the JSON transcript to stdout (pipe to jq, a script, or a coding agent):
|
|
424
494
|
$ 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 '.
|
|
495
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq -r '.blocks[] | select(.type=="text") | .text'
|
|
496
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[] | select(.type=="chart") | .rows'
|
|
427
497
|
|
|
428
|
-
|
|
498
|
+
Continue a conversation across separate commands. Every one-shot reply prints
|
|
499
|
+
the id to continue with; --json carries it as conversation_id:
|
|
500
|
+
$ unbound chat -m "cost by provider last 7 days"
|
|
501
|
+
... continue: unbound chat -m "..." -c <conversation_id>
|
|
502
|
+
$ unbound chat -m "now just the top 2" -c <conversation_id>
|
|
503
|
+
|
|
504
|
+
Scripted, taking the id from the JSON:
|
|
505
|
+
$ id=$(unbound chat -m "cost by provider last 7 days" --json | jq -r .conversation_id)
|
|
506
|
+
$ unbound chat -m "now just the top 2" -c "$id"
|
|
507
|
+
|
|
508
|
+
Write it to a file:
|
|
429
509
|
$ unbound chat -m "cost by provider last 30 days" -o cost.json
|
|
430
510
|
|
|
431
511
|
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 '.
|
|
512
|
+
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
|
|
433
513
|
|
|
434
514
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
table chart_option.dataset.source (first row is the header)
|
|
463
|
-
kpi chart_option.series[0].data[0] (a single number; no axes)
|
|
515
|
+
{ success, error, conversation_id, log_id, truncated, resumed, blocks[] }
|
|
516
|
+
|
|
517
|
+
A turn is an ordered transcript. The agent answers one thing at a time, so a
|
|
518
|
+
single question can return several charts with prose between them, and blocks[]
|
|
519
|
+
preserves that order.
|
|
520
|
+
|
|
521
|
+
blocks[i] is one of:
|
|
522
|
+
|
|
523
|
+
{ type: "text", text }
|
|
524
|
+
What the agent wrote at this point in the answer.
|
|
525
|
+
|
|
526
|
+
{ type: "chart", title, view_spec, chart_type, row_count, columns, rows }
|
|
527
|
+
columns string[] left-to-right column names
|
|
528
|
+
rows [{ <column>: value, ... }] the data, already decoded
|
|
529
|
+
chart_type "bar" | "line" | "area" | "stacked_bar" | "pie" | "donut"
|
|
530
|
+
| "scatter" | "heatmap" | "kpi" | "table"
|
|
531
|
+
decode_failed present and true only when the chart data could not be read,
|
|
532
|
+
so empty rows from a broken payload are not mistaken for an
|
|
533
|
+
empty answer
|
|
534
|
+
view_spec the query that produced it, for reuse or refinement
|
|
535
|
+
|
|
536
|
+
error carries the reason when success is false, and is null otherwise.
|
|
537
|
+
|
|
538
|
+
truncated: true means the agent ran out of budget partway. The blocks present
|
|
539
|
+
are real and complete; ask a follow-up for the rest.
|
|
540
|
+
|
|
541
|
+
log_id identifies the stored turn, for feedback and for support to trace.
|
|
464
542
|
|
|
465
543
|
EXIT CODES
|
|
466
|
-
0 success
|
|
467
|
-
1
|
|
544
|
+
0 success, including a turn that ends by asking you a question
|
|
545
|
+
1 no blocks returned, auth/role/network failure, a message over 4000
|
|
546
|
+
characters, or a write error
|
|
468
547
|
|
|
469
548
|
INTERACTIVE SESSION
|
|
470
549
|
$ unbound chat start the session
|
|
471
|
-
$ unbound chat --no-suggestions skip the follow-up picker
|
|
472
550
|
|
|
473
551
|
Commands (type inside the session):
|
|
474
552
|
/help show these commands
|
|
@@ -480,8 +558,17 @@ INTERACTIVE SESSION
|
|
|
480
558
|
|
|
481
559
|
NOTES
|
|
482
560
|
* Requires Admin or Manager role in your organization. Run 'unbound status' to check.
|
|
483
|
-
*
|
|
484
|
-
|
|
561
|
+
* A question the assistant asks back is a normal reply, not an error: the turn
|
|
562
|
+
exits 0, and you answer it in the next one with -c.
|
|
563
|
+
* Each -m invocation is its own process and sends no history. -c carries the
|
|
564
|
+
conversation instead; the turns are read back from the server, and this
|
|
565
|
+
command still writes no history of its own to disk.
|
|
566
|
+
* resumed is false when -c named a conversation that was not found. The question
|
|
567
|
+
is still answered, just without the earlier context.
|
|
568
|
+
* A single turn can answer several things at once and will say what it could not
|
|
569
|
+
do. Splitting a broad request across turns with -c tends to give sharper
|
|
570
|
+
answers though. Messages are capped at 4000 characters.
|
|
571
|
+
* With --json the transcript owns stdout; everything else goes to stderr.
|
|
485
572
|
* -o writes a new file with mode 0600 and refuses to overwrite existing files.
|
|
486
573
|
`)
|
|
487
574
|
.action(async (opts) => {
|
|
@@ -493,7 +580,8 @@ NOTES
|
|
|
493
580
|
return;
|
|
494
581
|
}
|
|
495
582
|
|
|
496
|
-
const oneShotFlagsGiven =
|
|
583
|
+
const oneShotFlagsGiven =
|
|
584
|
+
opts.message !== undefined || !!opts.json || !!opts.output || !!opts.continue;
|
|
497
585
|
if (oneShotFlagsGiven) {
|
|
498
586
|
const trimmed = typeof opts.message === 'string' ? opts.message.trim() : '';
|
|
499
587
|
if (!trimmed) {
|
|
@@ -506,8 +594,18 @@ NOTES
|
|
|
506
594
|
process.exitCode = 1;
|
|
507
595
|
return;
|
|
508
596
|
}
|
|
597
|
+
if (opts.continue !== undefined && !UUID_RE.test(String(opts.continue).trim())) {
|
|
598
|
+
output.error('`-c/--continue` needs the conversation_id from a previous reply.');
|
|
599
|
+
process.exitCode = 1;
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
509
602
|
try {
|
|
510
|
-
await runOneShot({
|
|
603
|
+
await runOneShot({
|
|
604
|
+
message: trimmed,
|
|
605
|
+
json: !!opts.json,
|
|
606
|
+
outputPath: opts.output,
|
|
607
|
+
conversationId: opts.continue ? String(opts.continue).trim() : null,
|
|
608
|
+
});
|
|
511
609
|
} catch (err) {
|
|
512
610
|
output.error(friendlyChatError(err));
|
|
513
611
|
process.exitCode = 1;
|
|
@@ -516,7 +614,7 @@ NOTES
|
|
|
516
614
|
}
|
|
517
615
|
|
|
518
616
|
try {
|
|
519
|
-
await runRepl(
|
|
617
|
+
await runRepl();
|
|
520
618
|
} catch (err) {
|
|
521
619
|
output.error(friendlyChatError(err));
|
|
522
620
|
process.exitCode = 1;
|
|
@@ -527,5 +625,8 @@ NOTES
|
|
|
527
625
|
module.exports = {
|
|
528
626
|
register,
|
|
529
627
|
// Exposed for unit tests:
|
|
530
|
-
_internals: {
|
|
628
|
+
_internals: {
|
|
629
|
+
safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
|
|
630
|
+
toJsonPayload, flattenBlocks, buildBody,
|
|
631
|
+
},
|
|
531
632
|
};
|
|
@@ -203,3 +203,48 @@ 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
|
+
});
|
|
230
|
+
|
|
231
|
+
test('reconstructRows: a KPI decodes from the graphic text the builder puts it in', () => {
|
|
232
|
+
// KPI_TEMPLATE ships series: [] and writes the formatted number into a graphic
|
|
233
|
+
// element, so every branch keyed on series/dataset/xAxis missed it and a
|
|
234
|
+
// single-number answer rendered as "(no rows to render)".
|
|
235
|
+
const option = {
|
|
236
|
+
series: [],
|
|
237
|
+
graphic: [{ type: 'text', style: { text: '$372.81', fontSize: 48 } }],
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const { columns, rows } = chart.reconstructRows(option, { chart_type: 'kpi', y: ['total_cost'] });
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(columns, ['total_cost']);
|
|
243
|
+
assert.deepEqual(rows, [{ total_cost: '$372.81' }]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('reconstructRows: an empty graphic text is not mistaken for a value', () => {
|
|
247
|
+
const option = { series: [], graphic: [{ type: 'text', style: { text: ' ' } }] };
|
|
248
|
+
|
|
249
|
+
assert.deepEqual(chart.reconstructRows(option, { chart_type: 'kpi' }), { columns: [], rows: [] });
|
|
250
|
+
});
|
|
@@ -142,3 +142,133 @@ 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
|
+
});
|
|
259
|
+
|
|
260
|
+
test('buildBody: a conversation id travels, and its absence is not a null field', () => {
|
|
261
|
+
const resumed = buildBody('now just the top 2', [], '00000000-0000-4000-8000-000000000000');
|
|
262
|
+
assert.equal(resumed.conversation_id, '00000000-0000-4000-8000-000000000000');
|
|
263
|
+
// One-shot sends no history — the server reads the turns back from the id alone.
|
|
264
|
+
assert.deepEqual(resumed.conversation_history, []);
|
|
265
|
+
|
|
266
|
+
assert.equal('conversation_id' in buildBody('first question', [], null), false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('toJsonPayload: resumed is only true when the server says it resumed', () => {
|
|
270
|
+
assert.equal(toJsonPayload({ blocks: [], resumed: true }).resumed, true);
|
|
271
|
+
// A mistyped id is well-formed, so silence here would read as success.
|
|
272
|
+
assert.equal(toJsonPayload({ blocks: [] }).resumed, false);
|
|
273
|
+
assert.equal(toJsonPayload({ blocks: [], resumed: 'yes' }).resumed, false);
|
|
274
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { Command } = require('commander');
|
|
7
|
+
|
|
8
|
+
// `--json -o file` printed "Wrote <path>" to stdout, right after the transcript.
|
|
9
|
+
// jq parses the whole stream, so it emitted the right answer AND exited 5 — a
|
|
10
|
+
// failure that looks like a success until something checks $?.
|
|
11
|
+
|
|
12
|
+
function freshChat({ post }) {
|
|
13
|
+
for (const m of ['../src/commands/chat', '../src/api', '../src/auth', '../src/output', '../src/chartRender']) {
|
|
14
|
+
delete require.cache[require.resolve(m)];
|
|
15
|
+
}
|
|
16
|
+
const api = require('../src/api');
|
|
17
|
+
api.post = post;
|
|
18
|
+
const auth = require('../src/auth');
|
|
19
|
+
auth.ensureLoggedIn = async () => {};
|
|
20
|
+
return require('../src/commands/chat');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const REPLY = {
|
|
24
|
+
success: true,
|
|
25
|
+
conversation_id: 'c-1',
|
|
26
|
+
log_id: 'l-1',
|
|
27
|
+
blocks: [{ type: 'text', text: 'Here you go.' }],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function runChat(args) {
|
|
31
|
+
const chat = freshChat({ post: async () => REPLY });
|
|
32
|
+
const out = [];
|
|
33
|
+
const err = [];
|
|
34
|
+
const realOut = process.stdout.write.bind(process.stdout);
|
|
35
|
+
const realErr = process.stderr.write.bind(process.stderr);
|
|
36
|
+
process.stdout.write = (chunk) => { out.push(String(chunk)); return true; };
|
|
37
|
+
process.stderr.write = (chunk) => { err.push(String(chunk)); return true; };
|
|
38
|
+
try {
|
|
39
|
+
const program = new Command();
|
|
40
|
+
program.exitOverride();
|
|
41
|
+
chat.register(program);
|
|
42
|
+
await program.parseAsync(['node', 'unbound', 'chat', ...args]);
|
|
43
|
+
} finally {
|
|
44
|
+
process.stdout.write = realOut;
|
|
45
|
+
process.stderr.write = realErr;
|
|
46
|
+
}
|
|
47
|
+
return { stdout: out.join(''), stderr: err.join('') };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tmpPath() {
|
|
51
|
+
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-chat-')), 'out.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('chat: --json with -o keeps stdout parseable as JSON', async () => {
|
|
55
|
+
const target = tmpPath();
|
|
56
|
+
const { stdout, stderr } = await runChat(['-m', 'anything', '--json', '-o', target]);
|
|
57
|
+
|
|
58
|
+
// The whole stream, not just a prefix — this is what jq consumes.
|
|
59
|
+
const parsed = JSON.parse(stdout);
|
|
60
|
+
assert.equal(parsed.blocks.length, 1);
|
|
61
|
+
assert.equal(parsed.conversation_id, 'c-1');
|
|
62
|
+
|
|
63
|
+
// The confirmation still happens, just not where a parser will trip on it.
|
|
64
|
+
assert.match(stderr, /Wrote /);
|
|
65
|
+
assert.ok(!stdout.includes('Wrote '), 'stdout must carry the transcript and nothing else');
|
|
66
|
+
|
|
67
|
+
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).blocks.length, 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('chat: -o without --json still confirms on stdout for a human', async () => {
|
|
71
|
+
const target = tmpPath();
|
|
72
|
+
const { stdout } = await runChat(['-m', 'anything', '-o', target]);
|
|
73
|
+
|
|
74
|
+
assert.match(stdout, /Wrote /);
|
|
75
|
+
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).blocks.length, 1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('chat: --json puts the transcript on stdout and everything else on stderr', async () => {
|
|
79
|
+
const { stdout, stderr } = await runChat(['-m', 'anything', '--json']);
|
|
80
|
+
|
|
81
|
+
// stdout is what a reader pipes to jq, so it has to parse whole.
|
|
82
|
+
assert.equal(JSON.parse(stdout).blocks.length, 1);
|
|
83
|
+
assert.ok(!stdout.includes('continue with'), 'the hint must not reach stdout');
|
|
84
|
+
assert.match(stderr, /continue with: -c c-1/);
|
|
85
|
+
});
|