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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- if (body) {
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) => {
@@ -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
 
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * `unbound chat` — interactive session or one-shot wrapper around
3
- * POST /api/v1/query_builder/chat/. Conversation history lives in memory
4
- * only; nothing is written to disk. In one-shot mode (-m), `--json` writes
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/chat/';
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
- function buildBody(message, history, recent) {
83
- return {
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
- function pushRecent(recent, query, result) {
91
- recent.push({
92
- query,
93
- view_spec: result.view_spec,
94
- result_summary: {
95
- row_count: result.row_count,
96
- chart_type: result.chart_meta?.chart_type,
97
- },
98
- });
99
- while (recent.length > RECENT_RESULTS_CAP) recent.shift();
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
- function renderSuggestionsForHuman(suggestions) {
130
- if (!suggestions?.length) return;
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
- output.info('Suggested follow-ups (rerun `unbound chat -m "<your question>"` with one of these):');
133
- suggestions.forEach((s, i) => {
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
- * 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 || '');
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 (intent === 'conversational' || !resp.result) {
158
- if (message) console.log(cyan('\u25cf ') + message);
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
- async function runOneShot({ message, json, outputPath }) {
205
- const body = buildBody(message, [], []);
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 serialized = JSON.stringify(resp, null, 2) + '\n';
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
- output.success(`Wrote ${resolved}`);
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 (resp?.intent === 'invalid') process.exitCode = 1;
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
- renderSuggestionsForHuman(resp?.suggestions);
249
- if (resp?.intent === 'invalid') process.exitCode = 1;
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.recent.length = 0;
285
- state.lastResult = null;
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.lastResult) console.log(JSON.stringify(state.lastResult, null, 2));
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.lastResult) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
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.lastResult, null, 2) + '\n');
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({ noSuggestions }) {
310
- const state = { history: [], recent: [], lastResult: null };
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
- if (pending) {
323
- msg = pending;
324
- pending = null;
325
- console.log(`${bold('chat>')} ${dim('(picked) ') + sanitizeForTerminal(msg)}`);
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.recent);
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
- // Record the user turn only after a successful round-trip so history stays
364
- // consistent with the server view.
365
- pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
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(resp?.turn);
368
- if (assistantTurn) pushHistory(state.history, assistantTurn);
369
- if (resp?.result) {
370
- pushRecent(state.recent, msg, resp.result);
371
- state.lastResult = resp.result;
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('--no-suggestions', 'Skip the follow-up picker after each turn (interactive session only).')
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 a chart in the terminal:
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 raw JSON response to stdout (pipe to jq, a script, or another tool):
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 '.intent'
426
- $ unbound chat -m "cost by provider last 30 days" --json | jq '.result.chart_option'
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
- Write the raw JSON response to a file:
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 '.intent'
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
- { message, intent, result?, suggestions?, turn }
436
-
437
- intent: "sql_visualise" | "conversational" | "invalid"
438
- sql_visualise — a chart was generated; 'result' is populated.
439
- conversational — the model wants clarification; pick a suggestions[i].query and re-run.
440
- invalid — the prompt could not be answered; exit code is 1.
441
-
442
- result (only when intent == "sql_visualise"):
443
- columns string[] left-to-right column names
444
- row_count number total rows in the answer
445
- chart_meta { chart_type, x, y } chart_type is one of:
446
- "bar" | "line" | "donut" | "pie"
447
- | "kpi" | "scatter" | "table"
448
- chart_option ECharts config with the data embedded.
449
-
450
- suggestions (may be present with any intent):
451
- [{ label, query, description }]
452
- Each entry's 'query' is the exact text for a follow-up '-m "<query>"' call.
453
-
454
- turn: { role, content, timestamp } — the assistant's turn record.
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)
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 intent=="invalid", auth/role/network failure, or a write error
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
- * Conversation history is held in memory only; nothing is ever written to disk.
484
- * Each -m invocation sends no history; keep each prompt self-contained.
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 = opts.message !== undefined || !!opts.json || !!opts.output;
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({ message: trimmed, json: !!opts.json, outputPath: opts.output });
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({ noSuggestions: opts.suggestions === false });
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: { safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError },
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
+ });