unbound-cli 1.12.0 → 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/chartRender.js +11 -0
- package/src/commands/chat.js +83 -12
- package/test/chart-render.test.js +21 -0
- package/test/chat-internals.test.js +16 -0
- package/test/chat-json-stdout.test.js +85 -0
package/package.json
CHANGED
package/src/chartRender.js
CHANGED
|
@@ -162,6 +162,17 @@ function reconstructRows(chart_option, chart_meta) {
|
|
|
162
162
|
return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(flat.data[0]) }] };
|
|
163
163
|
}
|
|
164
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
|
+
|
|
165
176
|
return { columns: [], rows: [] };
|
|
166
177
|
}
|
|
167
178
|
|
package/src/commands/chat.js
CHANGED
|
@@ -85,6 +85,8 @@ function safeWriteFile(target, data) {
|
|
|
85
85
|
return resolved;
|
|
86
86
|
}
|
|
87
87
|
|
|
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
|
+
|
|
88
90
|
function buildBody(message, history, conversationId) {
|
|
89
91
|
const body = {
|
|
90
92
|
message,
|
|
@@ -119,6 +121,10 @@ function toJsonPayload(resp) {
|
|
|
119
121
|
log_id: resp?.log_id ?? null,
|
|
120
122
|
// Ran out of budget mid-answer; the blocks already here are real.
|
|
121
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,
|
|
122
128
|
blocks: blocks.map((b) => {
|
|
123
129
|
if (b.type === 'text') return { type: 'text', text: b.text };
|
|
124
130
|
const result = b.result || {};
|
|
@@ -257,8 +263,17 @@ function renderResponse(resp) {
|
|
|
257
263
|
}
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
|
|
261
|
-
|
|
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);
|
|
262
277
|
|
|
263
278
|
if (json || outputPath) {
|
|
264
279
|
let resp;
|
|
@@ -270,6 +285,12 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
270
285
|
return;
|
|
271
286
|
}
|
|
272
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
|
+
}
|
|
273
294
|
const serialized = JSON.stringify(payload, null, 2) + '\n';
|
|
274
295
|
// Write to stdout first (when requested) so a downstream `-o` write failure
|
|
275
296
|
// doesn't eat the response when both flags are combined.
|
|
@@ -279,7 +300,11 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
279
300
|
if (outputPath) {
|
|
280
301
|
try {
|
|
281
302
|
const resolved = safeWriteFile(outputPath, serialized);
|
|
282
|
-
|
|
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}`);
|
|
283
308
|
} catch (err) {
|
|
284
309
|
output.error(sanitizeForTerminal(err.message));
|
|
285
310
|
process.exitCode = 1;
|
|
@@ -301,7 +326,15 @@ async function runOneShot({ message, json, outputPath }) {
|
|
|
301
326
|
return;
|
|
302
327
|
}
|
|
303
328
|
|
|
329
|
+
warnIfNotResumed(conversationId, toJsonPayload(resp));
|
|
304
330
|
renderResponse(resp);
|
|
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
|
+
}
|
|
305
338
|
if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
|
|
306
339
|
}
|
|
307
340
|
|
|
@@ -448,6 +481,7 @@ function register(program) {
|
|
|
448
481
|
.option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
|
|
449
482
|
.option('--json', 'Write the raw JSON response to stdout. Use with -m.')
|
|
450
483
|
.option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
|
|
484
|
+
.option('-c, --continue <conversation_id>', 'Continue the conversation with this id. Use with -m.')
|
|
451
485
|
.addHelpText('after', `
|
|
452
486
|
EXAMPLES
|
|
453
487
|
Start an interactive session:
|
|
@@ -458,9 +492,19 @@ EXAMPLES
|
|
|
458
492
|
|
|
459
493
|
Write the JSON transcript to stdout (pipe to jq, a script, or a coding agent):
|
|
460
494
|
$ unbound chat -m "cost by provider last 30 days" --json
|
|
461
|
-
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[].text'
|
|
495
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq -r '.blocks[] | select(.type=="text") | .text'
|
|
462
496
|
$ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[] | select(.type=="chart") | .rows'
|
|
463
497
|
|
|
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
|
+
|
|
464
508
|
Write it to a file:
|
|
465
509
|
$ unbound chat -m "cost by provider last 30 days" -o cost.json
|
|
466
510
|
|
|
@@ -468,7 +512,7 @@ EXAMPLES
|
|
|
468
512
|
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
|
|
469
513
|
|
|
470
514
|
OUTPUT FORMAT (--json and -o)
|
|
471
|
-
{ success, conversation_id, log_id, truncated, blocks[] }
|
|
515
|
+
{ success, error, conversation_id, log_id, truncated, resumed, blocks[] }
|
|
472
516
|
|
|
473
517
|
A turn is an ordered transcript. The agent answers one thing at a time, so a
|
|
474
518
|
single question can return several charts with prose between them, and blocks[]
|
|
@@ -482,17 +526,24 @@ OUTPUT FORMAT (--json and -o)
|
|
|
482
526
|
{ type: "chart", title, view_spec, chart_type, row_count, columns, rows }
|
|
483
527
|
columns string[] left-to-right column names
|
|
484
528
|
rows [{ <column>: value, ... }] the data, already decoded
|
|
485
|
-
chart_type "bar" | "line" | "
|
|
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
|
|
486
534
|
view_spec the query that produced it, for reuse or refinement
|
|
487
535
|
|
|
536
|
+
error carries the reason when success is false, and is null otherwise.
|
|
537
|
+
|
|
488
538
|
truncated: true means the agent ran out of budget partway. The blocks present
|
|
489
539
|
are real and complete; ask a follow-up for the rest.
|
|
490
540
|
|
|
491
541
|
log_id identifies the stored turn, for feedback and for support to trace.
|
|
492
542
|
|
|
493
543
|
EXIT CODES
|
|
494
|
-
0 success
|
|
495
|
-
1 no blocks returned, auth/role/network failure,
|
|
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
|
|
496
547
|
|
|
497
548
|
INTERACTIVE SESSION
|
|
498
549
|
$ unbound chat start the session
|
|
@@ -507,8 +558,17 @@ INTERACTIVE SESSION
|
|
|
507
558
|
|
|
508
559
|
NOTES
|
|
509
560
|
* Requires Admin or Manager role in your organization. Run 'unbound status' to check.
|
|
510
|
-
*
|
|
511
|
-
|
|
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.
|
|
512
572
|
* -o writes a new file with mode 0600 and refuses to overwrite existing files.
|
|
513
573
|
`)
|
|
514
574
|
.action(async (opts) => {
|
|
@@ -520,7 +580,8 @@ NOTES
|
|
|
520
580
|
return;
|
|
521
581
|
}
|
|
522
582
|
|
|
523
|
-
const oneShotFlagsGiven =
|
|
583
|
+
const oneShotFlagsGiven =
|
|
584
|
+
opts.message !== undefined || !!opts.json || !!opts.output || !!opts.continue;
|
|
524
585
|
if (oneShotFlagsGiven) {
|
|
525
586
|
const trimmed = typeof opts.message === 'string' ? opts.message.trim() : '';
|
|
526
587
|
if (!trimmed) {
|
|
@@ -533,8 +594,18 @@ NOTES
|
|
|
533
594
|
process.exitCode = 1;
|
|
534
595
|
return;
|
|
535
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
|
+
}
|
|
536
602
|
try {
|
|
537
|
-
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
|
+
});
|
|
538
609
|
} catch (err) {
|
|
539
610
|
output.error(friendlyChatError(err));
|
|
540
611
|
process.exitCode = 1;
|
|
@@ -227,3 +227,24 @@ test('reconstructRows: table rows may be arrays aligned to columns', () => {
|
|
|
227
227
|
test('reconstructRows: a malformed table falls through instead of throwing', () => {
|
|
228
228
|
assert.deepEqual(chart.reconstructRows({ table: { columns: 'nope' } }, {}), { columns: [], rows: [] });
|
|
229
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
|
+
});
|
|
@@ -256,3 +256,19 @@ test('toJsonPayload: an empty chart and an unreadable one are not the same block
|
|
|
256
256
|
// A genuinely empty answer carries no such marker.
|
|
257
257
|
assert.equal('decode_failed' in payload.blocks[1], false);
|
|
258
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
|
+
});
|