unbound-cli 1.12.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.12.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,
@@ -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
 
@@ -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;
@@ -85,6 +87,8 @@ function safeWriteFile(target, data) {
85
87
  return resolved;
86
88
  }
87
89
 
90
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
91
+
88
92
  function buildBody(message, history, conversationId) {
89
93
  const body = {
90
94
  message,
@@ -116,9 +120,17 @@ function toJsonPayload(resp) {
116
120
  // Without it a --json consumer gets exit 1 and no reason.
117
121
  error: resp?.error ?? null,
118
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,
119
127
  log_id: resp?.log_id ?? null,
120
128
  // Ran out of budget mid-answer; the blocks already here are real.
121
129
  truncated: resp?.truncated === true,
130
+ // False when -c named a conversation the server found nothing for. A mistyped
131
+ // id is still well-formed, so without this the turn reads as a success that
132
+ // quietly lost the thread.
133
+ resumed: resp?.resumed === true,
122
134
  blocks: blocks.map((b) => {
123
135
  if (b.type === 'text') return { type: 'text', text: b.text };
124
136
  const result = b.result || {};
@@ -169,9 +181,58 @@ function friendlyChatError(err) {
169
181
  if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
170
182
  if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
171
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.';
172
185
  return sanitizeForTerminal(err?.message || 'Request failed.');
173
186
  }
174
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
+
175
236
  /** Print one chart: its title, a one-line summary, then a body or a table. */
176
237
  function renderChartBlock(block) {
177
238
  const result = block.result || {};
@@ -227,6 +288,28 @@ function renderChartBlock(block) {
227
288
  }
228
289
  }
229
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
+
230
313
  /**
231
314
  * Print the turn in the order the agent produced it — prose where it wrote
232
315
  * prose, a chart where it ran one. A turn can hold several of each.
@@ -243,27 +326,45 @@ function renderResponse(resp) {
243
326
  return;
244
327
  }
245
328
 
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
- }
329
+ for (const block of blocks) renderBlock(block);
330
+ renderTail(resp);
331
+ }
253
332
 
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.');
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}`;
347
+ }
348
+
349
+ function warnIfNotResumed(conversationId, payload) {
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) {
353
+ process.stderr.write(
354
+ `Nothing to continue for ${conversationId} — answering without it. `
355
+ + 'Check the conversation_id from the previous reply.\n',
356
+ );
257
357
  }
258
358
  }
259
359
 
260
- async function runOneShot({ message, json, outputPath }) {
261
- const body = buildBody(message, [], null);
360
+ async function runOneShot({ message, json, outputPath, conversationId }) {
361
+ const body = buildBody(message, [], conversationId);
262
362
 
263
363
  if (json || outputPath) {
264
364
  let resp;
265
365
  try {
266
- 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);
267
368
  } catch (err) {
268
369
  output.error(friendlyChatError(err));
269
370
  process.exitCode = 1;
@@ -279,21 +380,41 @@ async function runOneShot({ message, json, outputPath }) {
279
380
  if (outputPath) {
280
381
  try {
281
382
  const resolved = safeWriteFile(outputPath, serialized);
282
- output.success(`Wrote ${resolved}`);
383
+ // With --json the transcript owns stdout. jq parses the whole stream, so a
384
+ // confirmation printed after it makes the output unparseable and exits 5 —
385
+ // while still emitting the right answer, which is worse than failing outright.
386
+ if (json) process.stderr.write(`Wrote ${resolved}\n`);
387
+ else output.success(`Wrote ${resolved}`);
283
388
  } catch (err) {
284
389
  output.error(sanitizeForTerminal(err.message));
285
390
  process.exitCode = 1;
286
391
  return;
287
392
  }
288
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
+ }
289
405
  if (!payload.success || !payload.blocks.length) process.exitCode = 1;
290
406
  return;
291
407
  }
292
408
 
293
409
  const spin = output.spinner('Thinking...');
294
410
  let resp;
411
+ let printed = 0;
295
412
  try {
296
- 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
+ });
297
418
  spin.stop();
298
419
  } catch (err) {
299
420
  spin.fail(friendlyChatError(err));
@@ -301,7 +422,16 @@ async function runOneShot({ message, json, outputPath }) {
301
422
  return;
302
423
  }
303
424
 
304
- renderResponse(resp);
425
+ warnIfNotResumed(conversationId, toJsonPayload(resp));
426
+ // A server that answered without streaming printed nothing along the way.
427
+ if (printed) renderTail(resp); else renderResponse(resp);
428
+ // Without this the id only exists in --json, so continuing a conversation from
429
+ // the terminal would mean asking the same question again just to read it back.
430
+ const command = (resp?.blocks || []).length ? continueCommand(resp?.conversation_id) : null;
431
+ if (command) {
432
+ console.log('');
433
+ console.log(dim(continueHint(command)));
434
+ }
305
435
  if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
306
436
  }
307
437
 
@@ -400,20 +530,27 @@ async function runRepl() {
400
530
 
401
531
  const spin = output.spinner('Thinking...');
402
532
  let resp;
533
+ let printed = 0;
403
534
  try {
404
- 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
+ });
405
540
  spin.stop();
406
541
  } catch (err) {
407
542
  spin.fail(friendlyChatError(err));
408
543
  if (err?.statusCode === 401 || err?.statusCode === 403) break;
409
544
  continue;
410
545
  }
546
+ // A server that answered without streaming printed nothing along the way.
547
+ const render = () => { if (printed) renderTail(resp); else renderResponse(resp); };
411
548
 
412
549
  // No answer means no turn: recording the question would send the assistant
413
550
  // something it never replied to. The id stays so the server's rows group.
414
551
  if (resp?.conversation_id) state.conversationId = resp.conversation_id;
415
552
  if (resp?.success === false || !(resp?.blocks || []).length) {
416
- renderResponse(resp);
553
+ render();
417
554
  continue;
418
555
  }
419
556
 
@@ -426,14 +563,14 @@ async function runRepl() {
426
563
  // would leave the question in history with no answer beside it, and the next
427
564
  // request would send the model two user turns in a row.
428
565
  if (!assistantTurn?.content) {
429
- renderResponse(resp);
566
+ render();
430
567
  continue;
431
568
  }
432
569
  pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
433
570
  pushHistory(state.history, assistantTurn);
434
571
  state.lastPayload = toJsonPayload(resp);
435
572
 
436
- renderResponse(resp);
573
+ render();
437
574
 
438
575
  }
439
576
 
@@ -448,6 +585,7 @@ function register(program) {
448
585
  .option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
449
586
  .option('--json', 'Write the raw JSON response to stdout. Use with -m.')
450
587
  .option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
588
+ .option('-c, --continue <conversation_id>', 'Continue the conversation with this id. Use with -m.')
451
589
  .addHelpText('after', `
452
590
  EXAMPLES
453
591
  Start an interactive session:
@@ -458,9 +596,19 @@ EXAMPLES
458
596
 
459
597
  Write the JSON transcript to stdout (pipe to jq, a script, or a coding agent):
460
598
  $ unbound chat -m "cost by provider last 30 days" --json
461
- $ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[].text'
599
+ $ unbound chat -m "cost by provider last 30 days" --json | jq -r '.blocks[] | select(.type=="text") | .text'
462
600
  $ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[] | select(.type=="chart") | .rows'
463
601
 
602
+ Continue a conversation across separate commands. Every one-shot reply prints
603
+ the id to continue with; --json carries it as conversation_id:
604
+ $ unbound chat -m "cost by provider last 7 days"
605
+ ... continue: unbound chat -m "..." -c <conversation_id>
606
+ $ unbound chat -m "now just the top 2" -c <conversation_id>
607
+
608
+ Scripted, taking the id from the JSON:
609
+ $ id=$(unbound chat -m "cost by provider last 7 days" --json | jq -r .conversation_id)
610
+ $ unbound chat -m "now just the top 2" -c "$id"
611
+
464
612
  Write it to a file:
465
613
  $ unbound chat -m "cost by provider last 30 days" -o cost.json
466
614
 
@@ -468,7 +616,8 @@ EXAMPLES
468
616
  $ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
469
617
 
470
618
  OUTPUT FORMAT (--json and -o)
471
- { success, conversation_id, log_id, truncated, blocks[] }
619
+ { success, error, conversation_id, continue_command, log_id, truncated,
620
+ resumed, blocks[] }
472
621
 
473
622
  A turn is an ordered transcript. The agent answers one thing at a time, so a
474
623
  single question can return several charts with prose between them, and blocks[]
@@ -482,17 +631,28 @@ OUTPUT FORMAT (--json and -o)
482
631
  { type: "chart", title, view_spec, chart_type, row_count, columns, rows }
483
632
  columns string[] left-to-right column names
484
633
  rows [{ <column>: value, ... }] the data, already decoded
485
- chart_type "bar" | "line" | "donut" | "pie" | "kpi" | "scatter" | "table"
634
+ chart_type "bar" | "line" | "area" | "stacked_bar" | "pie" | "donut"
635
+ | "scatter" | "heatmap" | "kpi" | "table"
636
+ decode_failed present and true only when the chart data could not be read,
637
+ so empty rows from a broken payload are not mistaken for an
638
+ empty answer
486
639
  view_spec the query that produced it, for reuse or refinement
487
640
 
641
+ error carries the reason when success is false, and is null otherwise.
642
+
488
643
  truncated: true means the agent ran out of budget partway. The blocks present
489
644
  are real and complete; ask a follow-up for the rest.
490
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
+
491
650
  log_id identifies the stored turn, for feedback and for support to trace.
492
651
 
493
652
  EXIT CODES
494
- 0 success
495
- 1 no blocks returned, auth/role/network failure, or a write error
653
+ 0 success, including a turn that ends by asking you a question
654
+ 1 no blocks returned, auth/role/network failure, a message over 4000
655
+ characters, or a write error
496
656
 
497
657
  INTERACTIVE SESSION
498
658
  $ unbound chat start the session
@@ -507,8 +667,17 @@ INTERACTIVE SESSION
507
667
 
508
668
  NOTES
509
669
  * Requires Admin or Manager role in your organization. Run 'unbound status' to check.
510
- * Conversation history is held in memory only; nothing is ever written to disk.
511
- * Each -m invocation sends no history; keep each prompt self-contained.
670
+ * A question the assistant asks back is a normal reply, not an error: the turn
671
+ exits 0, and you answer it in the next one with -c.
672
+ * Each -m invocation is its own process and sends no history. -c carries the
673
+ conversation instead; the turns are read back from the server, and this
674
+ command still writes no history of its own to disk.
675
+ * resumed is false when -c named a conversation that was not found. The question
676
+ is still answered, just without the earlier context.
677
+ * A single turn can answer several things at once and will say what it could not
678
+ do. Splitting a broad request across turns with -c tends to give sharper
679
+ answers though. Messages are capped at 4000 characters.
680
+ * With --json the transcript owns stdout; everything else goes to stderr.
512
681
  * -o writes a new file with mode 0600 and refuses to overwrite existing files.
513
682
  `)
514
683
  .action(async (opts) => {
@@ -520,7 +689,8 @@ NOTES
520
689
  return;
521
690
  }
522
691
 
523
- const oneShotFlagsGiven = opts.message !== undefined || !!opts.json || !!opts.output;
692
+ const oneShotFlagsGiven =
693
+ opts.message !== undefined || !!opts.json || !!opts.output || !!opts.continue;
524
694
  if (oneShotFlagsGiven) {
525
695
  const trimmed = typeof opts.message === 'string' ? opts.message.trim() : '';
526
696
  if (!trimmed) {
@@ -533,8 +703,18 @@ NOTES
533
703
  process.exitCode = 1;
534
704
  return;
535
705
  }
706
+ if (opts.continue !== undefined && !UUID_RE.test(String(opts.continue).trim())) {
707
+ output.error('`-c/--continue` needs the conversation_id from a previous reply.');
708
+ process.exitCode = 1;
709
+ return;
710
+ }
536
711
  try {
537
- await runOneShot({ message: trimmed, json: !!opts.json, outputPath: opts.output });
712
+ await runOneShot({
713
+ message: trimmed,
714
+ json: !!opts.json,
715
+ outputPath: opts.output,
716
+ conversationId: opts.continue ? String(opts.continue).trim() : null,
717
+ });
538
718
  } catch (err) {
539
719
  output.error(friendlyChatError(err));
540
720
  process.exitCode = 1;
@@ -556,6 +736,6 @@ module.exports = {
556
736
  // Exposed for unit tests:
557
737
  _internals: {
558
738
  safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
559
- toJsonPayload, flattenBlocks, buildBody,
739
+ toJsonPayload, flattenBlocks, buildBody, converse,
560
740
  },
561
741
  };
@@ -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,174 @@
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({ postStream }) {
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.postStream = postStream;
18
+ const auth = require('../src/auth');
19
+ auth.ensureLoggedIn = async () => {};
20
+ return require('../src/commands/chat');
21
+ }
22
+
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
+ }
35
+
36
+ async function runChat(args) {
37
+ const chat = freshChat({ postStream: streamer() });
38
+ const out = [];
39
+ const err = [];
40
+ const realOut = process.stdout.write.bind(process.stdout);
41
+ const realErr = process.stderr.write.bind(process.stderr);
42
+ process.stdout.write = (chunk) => { out.push(String(chunk)); return true; };
43
+ process.stderr.write = (chunk) => { err.push(String(chunk)); return true; };
44
+ try {
45
+ const program = new Command();
46
+ program.exitOverride();
47
+ chat.register(program);
48
+ await program.parseAsync(['node', 'unbound', 'chat', ...args]);
49
+ } finally {
50
+ process.stdout.write = realOut;
51
+ process.stderr.write = realErr;
52
+ }
53
+ return { stdout: out.join(''), stderr: err.join('') };
54
+ }
55
+
56
+ function tmpPath() {
57
+ return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-chat-')), 'out.json');
58
+ }
59
+
60
+ test('chat: --json with -o keeps stdout parseable as JSON', async () => {
61
+ const target = tmpPath();
62
+ const { stdout, stderr } = await runChat(['-m', 'anything', '--json', '-o', target]);
63
+
64
+ // The whole stream, not just a prefix — this is what jq consumes.
65
+ const parsed = JSON.parse(stdout);
66
+ assert.equal(parsed.blocks.length, 1);
67
+ assert.equal(parsed.conversation_id, CID);
68
+
69
+ // The confirmation still happens, just not where a parser will trip on it.
70
+ assert.match(stderr, /Wrote /);
71
+ assert.ok(!stdout.includes('Wrote '), 'stdout must carry the transcript and nothing else');
72
+
73
+ assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).blocks.length, 1);
74
+ });
75
+
76
+ test('chat: -o without --json still confirms on stdout for a human', async () => {
77
+ const target = tmpPath();
78
+ const { stdout } = await runChat(['-m', 'anything', '-o', target]);
79
+
80
+ assert.match(stdout, /Wrote /);
81
+ assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).blocks.length, 1);
82
+ });
83
+
84
+ test('chat: --json puts the transcript on stdout and everything else on stderr', async () => {
85
+ const { stdout, stderr } = await runChat(['-m', 'anything', '--json']);
86
+
87
+ // stdout is what a reader pipes to jq, so it has to parse whole.
88
+ assert.equal(JSON.parse(stdout).blocks.length, 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}`);
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
+ });