unbound-cli 1.11.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.11.1",
3
+ "version": "1.12.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) {
@@ -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,64 @@ function safeWriteFile(target, data) {
79
85
  return resolved;
80
86
  }
81
87
 
82
- function buildBody(message, history, recent) {
83
- return {
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
- 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();
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
- function renderSuggestionsForHuman(suggestions) {
130
- if (!suggestions?.length) return;
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
- 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
- }
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
- if (intent === 'invalid') {
153
- output.error(message || 'The request could not be processed.');
154
- return;
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 (intent === 'conversational' || !resp.result) {
158
- if (message) console.log(cyan('\u25cf ') + message);
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 serialized = JSON.stringify(resp, null, 2) + '\n';
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 (resp?.intent === 'invalid') process.exitCode = 1;
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
- renderSuggestionsForHuman(resp?.suggestions);
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.recent.length = 0;
285
- state.lastResult = null;
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.lastResult) console.log(JSON.stringify(state.lastResult, null, 2));
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.lastResult) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
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.lastResult, null, 2) + '\n');
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({ noSuggestions }) {
310
- const state = { history: [], recent: [], lastResult: null };
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
- 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
- }
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.recent);
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
- // 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() });
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(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;
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 a chart in the terminal:
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 raw JSON response to stdout (pipe to jq, a script, or another tool):
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 '.intent'
426
- $ unbound chat -m "cost by provider last 30 days" --json | jq '.result.chart_option'
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 the raw JSON response to a file:
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 '.intent'
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
- { 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)
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 intent=="invalid", auth/role/network failure, or a write error
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({ noSuggestions: opts.suggestions === false });
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: { safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError },
557
+ _internals: {
558
+ safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
559
+ toJsonPayload, flattenBlocks, buildBody,
560
+ },
531
561
  };
@@ -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
+ });