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 +1 -1
- package/src/api.js +121 -0
- package/src/commands/chat.js +140 -31
- package/test/chat-json-stdout.test.js +101 -12
- package/test/chat-stream.test.js +255 -0
package/package.json
CHANGED
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,
|
package/src/commands/chat.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
334
|
-
if (
|
|
430
|
+
const command = (resp?.blocks || []).length ? continueCommand(resp?.conversation_id) : null;
|
|
431
|
+
if (command) {
|
|
335
432
|
console.log('');
|
|
336
|
-
console.log(dim(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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.
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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({
|
|
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,
|
|
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
|
|
84
|
-
|
|
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
|
+
});
|