unbound-cli 1.13.0 → 1.14.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.13.0",
3
+ "version": "1.14.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
@@ -114,6 +114,126 @@ function request(method, path, { body, query, apiKey, baseUrl, timeoutMs } = {})
114
114
  });
115
115
  }
116
116
 
117
+ // One SSE frame -> {event, data}. Data lines are joined per the spec: the server
118
+ // sends single-line JSON, but a frame split across lines must not lose its tail.
119
+ function parseSseFrame(frame) {
120
+ let event = 'message';
121
+ const data = [];
122
+ for (const line of frame.split('\n')) {
123
+ if (line.startsWith('event:')) event = line.slice(6).trim();
124
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
125
+ }
126
+ if (!data.length) return null;
127
+ return { event, data: JSON.parse(data.join('\n')) };
128
+ }
129
+
130
+ /**
131
+ * POST that reads a text/event-stream reply, calling onEvent(name, data) per
132
+ * frame as it arrives.
133
+ *
134
+ * timeoutMs bounds SILENCE, not the whole response — Node's socket timeout
135
+ * resets on every byte. That is the property a long stream needs.
136
+ *
137
+ * Resolves null once the stream ends. A server that answers with JSON instead
138
+ * resolves with that parsed body, so callers work against either.
139
+ */
140
+ function postStream(path, { body, timeoutMs, onEvent } = {}) {
141
+ const key = config.getApiKey();
142
+ if (!key) {
143
+ return Promise.reject(new Error('Not logged in. Run `unbound login` first.'));
144
+ }
145
+
146
+ const url = new URL(path, config.getBaseUrl());
147
+ const payload = JSON.stringify(body || {});
148
+ const headers = {
149
+ 'Authorization': `Bearer ${key}`,
150
+ 'User-Agent': USER_AGENT,
151
+ 'Accept': 'text/event-stream',
152
+ 'Content-Type': 'application/json',
153
+ 'Content-Length': Buffer.byteLength(payload),
154
+ };
155
+ const transport = url.protocol === 'https:' ? https : http;
156
+
157
+ return new Promise((resolve, reject) => {
158
+ let settled = false;
159
+ const finish = (fn, value) => { if (!settled) { settled = true; fn(value); } };
160
+
161
+ const req = transport.request(url, { method: 'POST', headers }, (res) => {
162
+ const ok = res.statusCode >= 200 && res.statusCode < 300;
163
+ const isStream = String(res.headers['content-type'] || '').includes('text/event-stream');
164
+
165
+ if (!ok || !isStream) {
166
+ const chunks = [];
167
+ res.on('data', (chunk) => chunks.push(chunk));
168
+ res.on('end', () => {
169
+ const raw = Buffer.concat(chunks).toString();
170
+ let parsed;
171
+ try { parsed = JSON.parse(raw); } catch { parsed = raw; }
172
+ if (ok) finish(resolve, parsed);
173
+ else finish(reject, new ApiError(res.statusCode, parsed));
174
+ });
175
+ return;
176
+ }
177
+
178
+ res.setEncoding('utf8');
179
+ let buffer = '';
180
+ let heldCr = false;
181
+
182
+ // SSE line endings may be CR, LF or CRLF, and only LF is searched for
183
+ // below — so normalise all three rather than stripping CR, which would
184
+ // collapse a CR-only stream into one unbroken line and dispatch nothing.
185
+ // A CRLF split across two chunks would read as two endings and cut a frame
186
+ // in half, so a trailing CR waits for the chunk after it.
187
+ const feed = (chunk, last) => {
188
+ let text = (heldCr ? '\r' : '') + chunk;
189
+ heldCr = !last && text.endsWith('\r');
190
+ if (heldCr) text = text.slice(0, -1);
191
+ buffer += text.replace(/\r\n?/g, '\n');
192
+
193
+ let boundary = buffer.indexOf('\n\n');
194
+ while (boundary !== -1) {
195
+ const frame = buffer.slice(0, boundary);
196
+ buffer = buffer.slice(boundary + 2);
197
+ let parsed;
198
+ try {
199
+ parsed = parseSseFrame(frame);
200
+ } catch (err) {
201
+ req.destroy();
202
+ finish(reject, new Error(
203
+ `Unreadable reply from ${url.host}: ${String(err.message).slice(0, 200)}`));
204
+ return false;
205
+ }
206
+ if (parsed && onEvent) onEvent(parsed.event, parsed.data);
207
+ boundary = buffer.indexOf('\n\n');
208
+ }
209
+ return true;
210
+ };
211
+
212
+ res.on('data', (chunk) => feed(chunk, false));
213
+ res.on('end', () => {
214
+ // A held CR is the last byte of the stream, so nothing follows it to say
215
+ // whether it was CRLF. Release it, or a CR-terminated final frame is
216
+ // never dispatched and the whole turn reads as a dropped connection.
217
+ if (feed('', true)) finish(resolve, null);
218
+ });
219
+ // Headers are already out by now, so a proxy or peer giving up can only
220
+ // cut the socket — it cannot turn into a status code.
221
+ res.on('error', (err) => finish(reject, new Error(
222
+ `The connection to ${url.host} dropped before the reply finished (${err.code || err.message}).`)));
223
+ });
224
+
225
+ const effectiveTimeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
226
+ req.setTimeout(effectiveTimeout, () => {
227
+ req.destroy();
228
+ finish(reject, new Error(`${url.host} went quiet for ${Math.round(effectiveTimeout / 1000)}s. Please try again.`));
229
+ });
230
+
231
+ req.on('error', (err) => finish(reject, new Error(`Network error: ${err.message}`)));
232
+ req.write(payload);
233
+ req.end();
234
+ });
235
+ }
236
+
117
237
  // Plain unauthenticated GET to an arbitrary URL. Returns the raw response body
118
238
  // as a UTF-8 string. Callers that want JSON must do JSON.parse() themselves.
119
239
  // Used by `unbound oacb` to fetch baseline JSONs and hook scripts from GitHub.
@@ -168,6 +288,7 @@ module.exports = {
168
288
  ApiError,
169
289
  get: (path, opts) => request('GET', path, opts),
170
290
  post: (path, opts) => request('POST', path, opts),
291
+ postStream,
171
292
  put: (path, opts) => request('PUT', path, opts),
172
293
  del: (path, opts) => request('DELETE', path, opts),
173
294
  getRaw,
@@ -22,7 +22,9 @@ const chart = require('../chartRender');
22
22
  const { sanitizeForTerminal } = chart;
23
23
 
24
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.
25
+ // Silence, not total time — the turn streams, so this bounds the gap between
26
+ // frames. A step is one model call plus its queries, capped server-side well
27
+ // under this.
26
28
  const REQUEST_TIMEOUT_MS = 300000;
27
29
  const HISTORY_CAP = 20;
28
30
  const MAX_MESSAGE_LEN = 4000;
@@ -118,6 +120,10 @@ function toJsonPayload(resp) {
118
120
  // Without it a --json consumer gets exit 1 and no reason.
119
121
  error: resp?.error ?? null,
120
122
  conversation_id: resp?.conversation_id ?? null,
123
+ // The command to run next, spelled out. A model reading this transcript can
124
+ // act on it without being told how to assemble one from conversation_id.
125
+ // Null when the turn produced nothing, because there is nothing to continue.
126
+ continue_command: blocks.length ? continueCommand(resp?.conversation_id) : null,
121
127
  log_id: resp?.log_id ?? null,
122
128
  // Ran out of budget mid-answer; the blocks already here are real.
123
129
  truncated: resp?.truncated === true,
@@ -175,9 +181,58 @@ function friendlyChatError(err) {
175
181
  if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
176
182
  if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
177
183
  if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
184
+ if (err?.statusCode === 504) return 'The turn took too long and the connection was closed. Try narrowing the question.';
178
185
  return sanitizeForTerminal(err?.message || 'Request failed.');
179
186
  }
180
187
 
188
+ /**
189
+ * Run one turn, handing each block to `onBlock` as it lands.
190
+ *
191
+ * Streamed because the proxy in front of the API drops a connection that goes
192
+ * 60s without a byte, and a turn can run for minutes — so this is what keeps a
193
+ * long request alive, not just what makes it render sooner.
194
+ *
195
+ * Returns the shape the non-streaming endpoint returns, so callers below do not
196
+ * care which one answered.
197
+ */
198
+ async function converse(body, onBlock) {
199
+ const blocks = [];
200
+ let tail = null;
201
+ let failure = null;
202
+
203
+ let unstreamed;
204
+ try {
205
+ unstreamed = await api.postStream(ENDPOINT, {
206
+ body: { ...body, stream: true },
207
+ timeoutMs: REQUEST_TIMEOUT_MS,
208
+ onEvent: (event, data) => {
209
+ if (event === 'block' && data) {
210
+ blocks.push(data);
211
+ if (onBlock) onBlock(data);
212
+ } else if (event === 'done') {
213
+ tail = data;
214
+ } else if (event === 'error') {
215
+ failure = data;
216
+ }
217
+ },
218
+ });
219
+ } catch (err) {
220
+ // The socket can still fail while tearing down after the closing frame. The
221
+ // turn reported itself finished, and in the terminal its charts are already
222
+ // printed — discarding it here would contradict what is on screen.
223
+ if (!tail && !failure) throw err;
224
+ }
225
+
226
+ if (unstreamed) return unstreamed;
227
+ // Blocks are kept on a mid-stream failure: they were built, and in the terminal
228
+ // they have already been printed.
229
+ if (failure) return { success: false, error: failure.error, blocks };
230
+ if (!tail) {
231
+ throw new Error('The connection dropped before the reply finished. Ask again, or narrow the question.');
232
+ }
233
+ return { success: true, blocks, ...tail };
234
+ }
235
+
181
236
  /** Print one chart: its title, a one-line summary, then a body or a table. */
182
237
  function renderChartBlock(block) {
183
238
  const result = block.result || {};
@@ -233,6 +288,28 @@ function renderChartBlock(block) {
233
288
  }
234
289
  }
235
290
 
291
+ function renderBlock(block) {
292
+ if (block?.type === 'text') {
293
+ if (block.text) console.log(cyan('\u25cf ') + sanitizeForTerminal(block.text));
294
+ } else if (block?.type === 'chart') {
295
+ renderChartBlock(block);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * What belongs after the blocks. Called on its own when they were printed as
301
+ * they streamed, so this is all that is still owed.
302
+ */
303
+ function renderTail(resp) {
304
+ if (resp?.success === false) {
305
+ output.error(sanitizeForTerminal(resp?.error || 'The request could not be processed.'));
306
+ }
307
+ if (resp?.truncated) {
308
+ console.log('');
309
+ output.warn('That is as far as it got in one turn. Ask for the rest to continue.');
310
+ }
311
+ }
312
+
236
313
  /**
237
314
  * Print the turn in the order the agent produced it — prose where it wrote
238
315
  * prose, a chart where it ran one. A turn can hold several of each.
@@ -249,22 +326,30 @@ function renderResponse(resp) {
249
326
  return;
250
327
  }
251
328
 
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
- }
329
+ for (const block of blocks) renderBlock(block);
330
+ renderTail(resp);
331
+ }
259
332
 
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
- }
333
+ // Null unless the id is a canonical UUID. This string is meant to be run — by a
334
+ // person pasting it or by a model acting on the payload — and it is assembled
335
+ // from a response field, which is not a trusted source of command text. The same
336
+ // shape is already required of `-c`; requiring it in both directions is what
337
+ // keeps a hostile or broken backend from getting a shell metacharacter in here.
338
+ function continueCommand(conversationId) {
339
+ const id = String(conversationId ?? '');
340
+ return UUID_RE.test(id) ? `unbound chat -m "..." -c ${id}` : null;
341
+ }
342
+
343
+ // The same line either way, so what a --json run prints is what a plain run
344
+ // prints — a command to run, not an id to assemble one from.
345
+ function continueHint(command) {
346
+ return ` continue: ${command}`;
264
347
  }
265
348
 
266
349
  function warnIfNotResumed(conversationId, payload) {
267
- if (conversationId && !payload.resumed) {
350
+ // Only on a turn that ran. A failed turn carries no `resumed` at all, so saying
351
+ // the conversation was missing sends them after a problem they do not have.
352
+ if (conversationId && payload.success && !payload.resumed) {
268
353
  process.stderr.write(
269
354
  `Nothing to continue for ${conversationId} — answering without it. `
270
355
  + 'Check the conversation_id from the previous reply.\n',
@@ -278,19 +363,14 @@ async function runOneShot({ message, json, outputPath, conversationId }) {
278
363
  if (json || outputPath) {
279
364
  let resp;
280
365
  try {
281
- resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
366
+ // No per-block callback: stdout carries the transcript and nothing else.
367
+ resp = await converse(body);
282
368
  } catch (err) {
283
369
  output.error(friendlyChatError(err));
284
370
  process.exitCode = 1;
285
371
  return;
286
372
  }
287
373
  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
374
  const serialized = JSON.stringify(payload, null, 2) + '\n';
295
375
  // Write to stdout first (when requested) so a downstream `-o` write failure
296
376
  // doesn't eat the response when both flags are combined.
@@ -311,14 +391,30 @@ async function runOneShot({ message, json, outputPath, conversationId }) {
311
391
  return;
312
392
  }
313
393
  }
394
+ // Last, after the transcript has finished printing. A turn can be hundreds of
395
+ // kilobytes of JSON, so anything written before it has scrolled out of reach
396
+ // by the time the command ends — which is where someone is looking for it.
397
+ warnIfNotResumed(conversationId, payload);
398
+ // stderr, and only to a terminal: stdout is the transcript and a reader piping
399
+ // it to jq must not find this in the stream, while a captured stderr is a log
400
+ // rather than something a person is reading. A script takes the command off
401
+ // `continue_command` in the payload instead.
402
+ if (payload.continue_command && process.stderr.isTTY) {
403
+ process.stderr.write(continueHint(payload.continue_command) + '\n');
404
+ }
314
405
  if (!payload.success || !payload.blocks.length) process.exitCode = 1;
315
406
  return;
316
407
  }
317
408
 
318
409
  const spin = output.spinner('Thinking...');
319
410
  let resp;
411
+ let printed = 0;
320
412
  try {
321
- resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
413
+ resp = await converse(body, (block) => {
414
+ if (!printed) spin.stop();
415
+ printed += 1;
416
+ renderBlock(block);
417
+ });
322
418
  spin.stop();
323
419
  } catch (err) {
324
420
  spin.fail(friendlyChatError(err));
@@ -327,13 +423,14 @@ async function runOneShot({ message, json, outputPath, conversationId }) {
327
423
  }
328
424
 
329
425
  warnIfNotResumed(conversationId, toJsonPayload(resp));
330
- renderResponse(resp);
426
+ // A server that answered without streaming printed nothing along the way.
427
+ if (printed) renderTail(resp); else renderResponse(resp);
331
428
  // Without this the id only exists in --json, so continuing a conversation from
332
429
  // 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) {
430
+ const command = (resp?.blocks || []).length ? continueCommand(resp?.conversation_id) : null;
431
+ if (command) {
335
432
  console.log('');
336
- console.log(dim(` continue: unbound chat -m "..." -c ${sanitizeForTerminal(String(id))}`));
433
+ console.log(dim(continueHint(command)));
337
434
  }
338
435
  if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
339
436
  }
@@ -433,20 +530,27 @@ async function runRepl() {
433
530
 
434
531
  const spin = output.spinner('Thinking...');
435
532
  let resp;
533
+ let printed = 0;
436
534
  try {
437
- resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
535
+ resp = await converse(body, (block) => {
536
+ if (!printed) spin.stop();
537
+ printed += 1;
538
+ renderBlock(block);
539
+ });
438
540
  spin.stop();
439
541
  } catch (err) {
440
542
  spin.fail(friendlyChatError(err));
441
543
  if (err?.statusCode === 401 || err?.statusCode === 403) break;
442
544
  continue;
443
545
  }
546
+ // A server that answered without streaming printed nothing along the way.
547
+ const render = () => { if (printed) renderTail(resp); else renderResponse(resp); };
444
548
 
445
549
  // No answer means no turn: recording the question would send the assistant
446
550
  // something it never replied to. The id stays so the server's rows group.
447
551
  if (resp?.conversation_id) state.conversationId = resp.conversation_id;
448
552
  if (resp?.success === false || !(resp?.blocks || []).length) {
449
- renderResponse(resp);
553
+ render();
450
554
  continue;
451
555
  }
452
556
 
@@ -459,14 +563,14 @@ async function runRepl() {
459
563
  // would leave the question in history with no answer beside it, and the next
460
564
  // request would send the model two user turns in a row.
461
565
  if (!assistantTurn?.content) {
462
- renderResponse(resp);
566
+ render();
463
567
  continue;
464
568
  }
465
569
  pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
466
570
  pushHistory(state.history, assistantTurn);
467
571
  state.lastPayload = toJsonPayload(resp);
468
572
 
469
- renderResponse(resp);
573
+ render();
470
574
 
471
575
  }
472
576
 
@@ -512,7 +616,8 @@ EXAMPLES
512
616
  $ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
513
617
 
514
618
  OUTPUT FORMAT (--json and -o)
515
- { success, error, conversation_id, log_id, truncated, resumed, blocks[] }
619
+ { success, error, conversation_id, continue_command, log_id, truncated,
620
+ resumed, blocks[] }
516
621
 
517
622
  A turn is an ordered transcript. The agent answers one thing at a time, so a
518
623
  single question can return several charts with prose between them, and blocks[]
@@ -538,6 +643,10 @@ OUTPUT FORMAT (--json and -o)
538
643
  truncated: true means the agent ran out of budget partway. The blocks present
539
644
  are real and complete; ask a follow-up for the rest.
540
645
 
646
+ continue_command is the next command spelled out, for a reader that should act
647
+ on it rather than assemble one from conversation_id. Null when the turn
648
+ produced nothing.
649
+
541
650
  log_id identifies the stored turn, for feedback and for support to trace.
542
651
 
543
652
  EXIT CODES
@@ -627,6 +736,6 @@ module.exports = {
627
736
  // Exposed for unit tests:
628
737
  _internals: {
629
738
  safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
630
- toJsonPayload, flattenBlocks, buildBody,
739
+ toJsonPayload, flattenBlocks, buildBody, converse,
631
740
  },
632
741
  };
@@ -9,26 +9,32 @@ const { Command } = require('commander');
9
9
  // jq parses the whole stream, so it emitted the right answer AND exited 5 — a
10
10
  // failure that looks like a success until something checks $?.
11
11
 
12
- function freshChat({ post }) {
12
+ function freshChat({ postStream }) {
13
13
  for (const m of ['../src/commands/chat', '../src/api', '../src/auth', '../src/output', '../src/chartRender']) {
14
14
  delete require.cache[require.resolve(m)];
15
15
  }
16
16
  const api = require('../src/api');
17
- api.post = post;
17
+ api.postStream = postStream;
18
18
  const auth = require('../src/auth');
19
19
  auth.ensureLoggedIn = async () => {};
20
20
  return require('../src/commands/chat');
21
21
  }
22
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
- };
23
+ const CID = '11111111-2222-4333-8444-555555555555';
24
+ const BLOCKS = [{ type: 'text', text: 'Here you go.' }];
25
+ const DONE = { conversation_id: CID, log_id: 'l-1' };
26
+
27
+ // Replay a turn the way the server sends it: a frame per block, then `done`.
28
+ function streamer(blocks = BLOCKS, done = DONE) {
29
+ return async (_path, { onEvent }) => {
30
+ for (const block of blocks) onEvent('block', block);
31
+ onEvent('done', done);
32
+ return null;
33
+ };
34
+ }
29
35
 
30
36
  async function runChat(args) {
31
- const chat = freshChat({ post: async () => REPLY });
37
+ const chat = freshChat({ postStream: streamer() });
32
38
  const out = [];
33
39
  const err = [];
34
40
  const realOut = process.stdout.write.bind(process.stdout);
@@ -58,7 +64,7 @@ test('chat: --json with -o keeps stdout parseable as JSON', async () => {
58
64
  // The whole stream, not just a prefix — this is what jq consumes.
59
65
  const parsed = JSON.parse(stdout);
60
66
  assert.equal(parsed.blocks.length, 1);
61
- assert.equal(parsed.conversation_id, 'c-1');
67
+ assert.equal(parsed.conversation_id, CID);
62
68
 
63
69
  // The confirmation still happens, just not where a parser will trip on it.
64
70
  assert.match(stderr, /Wrote /);
@@ -80,6 +86,89 @@ test('chat: --json puts the transcript on stdout and everything else on stderr',
80
86
 
81
87
  // stdout is what a reader pipes to jq, so it has to parse whole.
82
88
  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/);
89
+ assert.ok(!stdout.includes('continue:'), 'the hint must not reach stdout');
90
+ // Captured here, so not a terminal — the hint is for a person, and a script
91
+ // takes the id off conversation_id instead.
92
+ assert.ok(!stderr.includes('continue:'), 'the hint is TTY-only');
93
+ });
94
+
95
+ test('chat: --json prints the continue hint when stderr is a terminal', async () => {
96
+ const realIsTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
97
+ Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
98
+ try {
99
+ const { stdout, stderr } = await runChat(['-m', 'anything', '--json']);
100
+ assert.equal(JSON.parse(stdout).blocks.length, 1);
101
+ assert.ok(stderr.includes(`continue: unbound chat -m "..." -c ${CID}`));
102
+ } finally {
103
+ if (realIsTTY) Object.defineProperty(process.stderr, 'isTTY', realIsTTY);
104
+ else delete process.stderr.isTTY;
105
+ }
106
+ });
107
+
108
+ test('chat: --json carries the next command, not just the id', async () => {
109
+ const { stdout } = await runChat(['-m', 'anything', '--json']);
110
+ const payload = JSON.parse(stdout);
111
+
112
+ assert.equal(payload.continue_command, `unbound chat -m "..." -c ${CID}`);
113
+ assert.equal(payload.conversation_id, CID);
114
+ });
115
+
116
+ test('chat: a turn with no blocks has nothing to continue', async () => {
117
+ const chat = freshChat({ postStream: streamer([], { conversation_id: CID }) });
118
+ const payload = chat._internals.toJsonPayload({ success: true, conversation_id: CID, blocks: [] });
119
+
120
+ assert.equal(payload.continue_command, null);
121
+ });
122
+
123
+ test('chat: a conversation_id that is not a uuid yields no command to run', async () => {
124
+ const chat = freshChat({ postStream: streamer() });
125
+ const hostile = chat._internals.toJsonPayload({
126
+ success: true,
127
+ conversation_id: 'c-1; rm -rf ~',
128
+ blocks: [{ type: 'text', text: 'hi' }],
129
+ });
130
+
131
+ // The field exists to be executed, so it is null rather than a command
132
+ // carrying whatever the response happened to contain.
133
+ assert.equal(hostile.continue_command, null);
134
+ assert.equal(hostile.conversation_id, 'c-1; rm -rf ~');
135
+
136
+ const real = chat._internals.toJsonPayload({
137
+ success: true,
138
+ conversation_id: '1555aaca-769c-4b8c-84c5-049397392195',
139
+ blocks: [{ type: 'text', text: 'hi' }],
140
+ });
141
+ assert.equal(real.continue_command,
142
+ 'unbound chat -m "..." -c 1555aaca-769c-4b8c-84c5-049397392195');
143
+ });
144
+
145
+ test('chat: the continue hint comes after the transcript, not before it', async () => {
146
+ // A real turn is hundreds of kilobytes. Printed first, the hint scrolls out of
147
+ // reach before the command ends, which is the same as not printing it.
148
+ const realIsTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
149
+ Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
150
+
151
+ const chat = freshChat({ postStream: streamer() });
152
+ const order = [];
153
+ const realOut = process.stdout.write.bind(process.stdout);
154
+ const realErr = process.stderr.write.bind(process.stderr);
155
+ process.stdout.write = (chunk) => { order.push(['stdout', String(chunk)]); return true; };
156
+ process.stderr.write = (chunk) => { order.push(['stderr', String(chunk)]); return true; };
157
+ try {
158
+ const program = new Command();
159
+ program.exitOverride();
160
+ chat.register(program);
161
+ await program.parseAsync(['node', 'unbound', 'chat', '-m', 'anything', '--json']);
162
+ } finally {
163
+ process.stdout.write = realOut;
164
+ process.stderr.write = realErr;
165
+ if (realIsTTY) Object.defineProperty(process.stderr, 'isTTY', realIsTTY);
166
+ else delete process.stderr.isTTY;
167
+ }
168
+
169
+ const transcript = order.findIndex(([s, text]) => s === 'stdout' && text.includes('"blocks"'));
170
+ const hint = order.findIndex(([, text]) => text.includes('continue:'));
171
+ assert.ok(transcript !== -1, 'the transcript was written');
172
+ assert.ok(hint !== -1, 'the hint was written');
173
+ assert.ok(hint > transcript, `hint at ${hint} must come after the transcript at ${transcript}`);
85
174
  });
@@ -0,0 +1,255 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+
5
+ // A turn can run for minutes, and the proxy in front of the API closes a
6
+ // connection that goes 60s without a byte. A buffered POST sends nothing until
7
+ // the turn ends, so every long question died at 60s with the answer already
8
+ // built server-side. These cover the streamed path that replaces it.
9
+
10
+ function freshModules() {
11
+ for (const m of ['../src/commands/chat', '../src/api', '../src/config', '../src/auth', '../src/output', '../src/chartRender']) {
12
+ delete require.cache[require.resolve(m)];
13
+ }
14
+ }
15
+
16
+ // Serve `handler` on a loopback port and point the api module at it.
17
+ async function withServer(handler, run) {
18
+ const server = http.createServer(handler);
19
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
20
+ const port = server.address().port;
21
+ freshModules();
22
+ const config = require('../src/config');
23
+ config.getBaseUrl = () => `http://127.0.0.1:${port}`;
24
+ config.getApiKey = () => 'test-key';
25
+ const api = require('../src/api');
26
+ try {
27
+ return await run(api);
28
+ } finally {
29
+ server.close();
30
+ }
31
+ }
32
+
33
+ function sse(event, data) {
34
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
35
+ }
36
+
37
+ test('postStream: reassembles frames split across chunk boundaries', async () => {
38
+ const frames = sse('block', { type: 'text', text: 'one' })
39
+ + sse('block', { type: 'chart', title: 'two' })
40
+ + sse('done', { conversation_id: 'c-1' });
41
+
42
+ // Cut mid-JSON, then between the two newlines that terminate a frame — the
43
+ // two ways a real socket splits a 400 KB chart block. The awaits are what
44
+ // force separate 'data' events; without them Node coalesces the writes.
45
+ const midJson = 20;
46
+ const midSeparator = frames.indexOf('\n\n') + 1;
47
+ assert.ok(midJson < midSeparator);
48
+
49
+ const events = await withServer(async (req, res) => {
50
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
51
+ for (const part of [frames.slice(0, midJson), frames.slice(midJson, midSeparator), frames.slice(midSeparator)]) {
52
+ res.write(part);
53
+ await new Promise((r) => setTimeout(r, 5));
54
+ }
55
+ res.end();
56
+ }, async (api) => {
57
+ const seen = [];
58
+ const fallback = await api.postStream('/x', {
59
+ body: {}, onEvent: (event, data) => seen.push([event, data]),
60
+ });
61
+ assert.equal(fallback, null, 'a streamed reply resolves null');
62
+ return seen;
63
+ });
64
+
65
+ assert.deepEqual(events.map((e) => e[0]), ['block', 'block', 'done']);
66
+ assert.equal(events[0][1].text, 'one');
67
+ assert.equal(events[1][1].title, 'two');
68
+ assert.equal(events[2][1].conversation_id, 'c-1');
69
+ });
70
+
71
+ test('postStream: a server that answers with JSON resolves that body instead', async () => {
72
+ const resp = await withServer((req, res) => {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ res.end(JSON.stringify({ success: true, blocks: [{ type: 'text', text: 'buffered' }] }));
75
+ }, async (api) => {
76
+ let called = 0;
77
+ const out = await api.postStream('/x', { body: {}, onEvent: () => { called += 1; } });
78
+ assert.equal(called, 0, 'no frames to hand back');
79
+ return out;
80
+ });
81
+
82
+ assert.equal(resp.blocks[0].text, 'buffered');
83
+ });
84
+
85
+ test('postStream: a socket cut mid-stream is reported, not read as a finished turn', async () => {
86
+ await withServer(async (req, res) => {
87
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
88
+ res.write(sse('block', { type: 'text', text: 'partial' }));
89
+ await new Promise((r) => setTimeout(r, 5));
90
+ // What a proxy does when it gives up: headers are out, so it can only cut.
91
+ res.socket.destroy();
92
+ }, async (api) => {
93
+ const seen = [];
94
+ await assert.rejects(
95
+ () => api.postStream('/x', { body: {}, onEvent: (e) => seen.push(e) }),
96
+ /dropped before the reply finished/,
97
+ );
98
+ assert.deepEqual(seen, ['block'], 'what arrived before the cut still counts');
99
+ });
100
+ });
101
+
102
+ test('postStream: a non-2xx reply rejects with the status', async () => {
103
+ await withServer((req, res) => {
104
+ res.writeHead(403, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ error: 'nope' }));
106
+ }, async (api) => {
107
+ await assert.rejects(
108
+ () => api.postStream('/x', { body: {} }),
109
+ (err) => err.statusCode === 403 && err.message === 'nope',
110
+ );
111
+ });
112
+ });
113
+
114
+ function chatWithStream(postStream) {
115
+ freshModules();
116
+ const api = require('../src/api');
117
+ api.postStream = postStream;
118
+ return require('../src/commands/chat')._internals.converse;
119
+ }
120
+
121
+ test('converse: assembles blocks and done into the non-streaming shape', async () => {
122
+ const converse = chatWithStream(async (_p, { onEvent }) => {
123
+ onEvent('block', { type: 'text', text: 'hi' });
124
+ onEvent('done', { conversation_id: 'c-1', log_id: 'l-1', truncated: false, resumed: true });
125
+ return null;
126
+ });
127
+
128
+ const resp = await converse({ message: 'q' });
129
+ assert.equal(resp.success, true);
130
+ assert.equal(resp.blocks.length, 1);
131
+ assert.equal(resp.conversation_id, 'c-1');
132
+ assert.equal(resp.resumed, true);
133
+ });
134
+
135
+ test('converse: sets stream on the request', async () => {
136
+ let sent = null;
137
+ const converse = chatWithStream(async (_p, { body, onEvent }) => {
138
+ sent = body;
139
+ onEvent('done', { conversation_id: 'c-1' });
140
+ return null;
141
+ });
142
+
143
+ await converse({ message: 'q', conversation_id: 'c-1' });
144
+ assert.equal(sent.stream, true);
145
+ assert.equal(sent.message, 'q');
146
+ assert.equal(sent.conversation_id, 'c-1');
147
+ });
148
+
149
+ test('converse: an error event keeps the blocks already streamed', async () => {
150
+ const converse = chatWithStream(async (_p, { onEvent }) => {
151
+ onEvent('block', { type: 'text', text: 'built this much' });
152
+ onEvent('error', { error: 'The assistant is unavailable right now.' });
153
+ return null;
154
+ });
155
+
156
+ const resp = await converse({ message: 'q' });
157
+ assert.equal(resp.success, false);
158
+ assert.equal(resp.error, 'The assistant is unavailable right now.');
159
+ // They are on screen already — reporting an empty turn would contradict that.
160
+ assert.equal(resp.blocks.length, 1);
161
+ });
162
+
163
+ test('converse: a stream that ends without done is reported, not passed off as complete', async () => {
164
+ const converse = chatWithStream(async (_p, { onEvent }) => {
165
+ onEvent('block', { type: 'text', text: 'partial' });
166
+ return null;
167
+ });
168
+
169
+ await assert.rejects(() => converse({ message: 'q' }), /connection dropped/);
170
+ });
171
+
172
+ test('converse: a server that did not stream is returned untouched', async () => {
173
+ const buffered = { success: true, blocks: [{ type: 'text', text: 'old server' }], conversation_id: 'c-9' };
174
+ const converse = chatWithStream(async () => buffered);
175
+
176
+ assert.deepEqual(await converse({ message: 'q' }), buffered);
177
+ });
178
+
179
+ test('postStream: CRLF-delimited frames are read, not silently skipped', async () => {
180
+ const frames = 'event: block\r\ndata: {"type":"text","text":"crlf"}\r\n\r\n'
181
+ + 'event: done\r\ndata: {"conversation_id":"c-1"}\r\n\r\n';
182
+
183
+ const events = await withServer((req, res) => {
184
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
185
+ res.end(frames);
186
+ }, async (api) => {
187
+ const seen = [];
188
+ await api.postStream('/x', { body: {}, onEvent: (e, d) => seen.push([e, d]) });
189
+ return seen;
190
+ });
191
+
192
+ assert.deepEqual(events.map((e) => e[0]), ['block', 'done']);
193
+ assert.equal(events[0][1].text, 'crlf');
194
+ });
195
+
196
+ test('converse: a socket that fails after the closing frame keeps the turn', async () => {
197
+ const converse = chatWithStream(async (_p, { onEvent }) => {
198
+ onEvent('block', { type: 'text', text: 'built' });
199
+ onEvent('done', { conversation_id: 'c-1', resumed: true });
200
+ // Teardown failure, after the turn already reported itself finished.
201
+ throw new Error('The connection to example dropped before the reply finished (ECONNRESET).');
202
+ });
203
+
204
+ const resp = await converse({ message: 'q' });
205
+ assert.equal(resp.success, true);
206
+ assert.equal(resp.blocks.length, 1);
207
+ assert.equal(resp.conversation_id, 'c-1');
208
+ });
209
+
210
+ test('converse: a socket that fails with no closing frame still reports the drop', async () => {
211
+ const converse = chatWithStream(async (_p, { onEvent }) => {
212
+ onEvent('block', { type: 'text', text: 'partial' });
213
+ throw new Error('The connection to example dropped before the reply finished (ECONNRESET).');
214
+ });
215
+
216
+ await assert.rejects(() => converse({ message: 'q' }), /dropped before the reply finished/);
217
+ });
218
+
219
+ test('postStream: CR-only frames are read, not collapsed into one line', async () => {
220
+ const frames = 'event: block\rdata: {"type":"text","text":"cr"}\r\r'
221
+ + 'event: done\rdata: {"conversation_id":"c-1"}\r\r';
222
+
223
+ const events = await withServer((req, res) => {
224
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
225
+ res.end(frames);
226
+ }, async (api) => {
227
+ const seen = [];
228
+ await api.postStream('/x', { body: {}, onEvent: (e, d) => seen.push([e, d]) });
229
+ return seen;
230
+ });
231
+
232
+ assert.deepEqual(events.map((e) => e[0]), ['block', 'done']);
233
+ assert.equal(events[0][1].text, 'cr');
234
+ });
235
+
236
+ test('postStream: a CRLF split across two chunks does not cut a frame in half', async () => {
237
+ const frame = 'event: block\r\ndata: {"type":"text","text":"split"}\r\n\r\n';
238
+ const cut = frame.indexOf('\r\n') + 1; // between the CR and the LF
239
+
240
+ const events = await withServer(async (req, res) => {
241
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
242
+ res.write(frame.slice(0, cut));
243
+ await new Promise((r) => setTimeout(r, 5));
244
+ res.end(frame.slice(cut));
245
+ }, async (api) => {
246
+ const seen = [];
247
+ await api.postStream('/x', { body: {}, onEvent: (e, d) => seen.push([e, d]) });
248
+ return seen;
249
+ });
250
+
251
+ // Naively normalising CR to LF turns the split ending into a frame boundary,
252
+ // and the block is silently dropped as a frame with no data.
253
+ assert.deepEqual(events.map((e) => e[0]), ['block']);
254
+ assert.equal(events[0][1].text, 'split');
255
+ });