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 +1 -1
- package/src/api.js +121 -0
- package/src/chartRender.js +11 -0
- package/src/commands/chat.js +211 -31
- package/test/chart-render.test.js +21 -0
- package/test/chat-internals.test.js +16 -0
- package/test/chat-json-stdout.test.js +174 -0
- 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/chartRender.js
CHANGED
|
@@ -162,6 +162,17 @@ function reconstructRows(chart_option, chart_meta) {
|
|
|
162
162
|
return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(flat.data[0]) }] };
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// 5) KPI as the builder actually emits it: series is [] and the number lives,
|
|
166
|
+
// already formatted, in a graphic text element. Nothing above can see it, so a
|
|
167
|
+
// single-number answer decoded to nothing and read as an empty result.
|
|
168
|
+
const graphicText = asArray(chart_option.graphic)
|
|
169
|
+
.map((g) => g?.style?.text)
|
|
170
|
+
.find((t) => typeof t === 'string' && t.trim());
|
|
171
|
+
if (graphicText) {
|
|
172
|
+
const valueKey = yKeys[0] || 'value';
|
|
173
|
+
return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(graphicText.trim()) }] };
|
|
174
|
+
}
|
|
175
|
+
|
|
165
176
|
return { columns: [], rows: [] };
|
|
166
177
|
}
|
|
167
178
|
|
package/src/commands/chat.js
CHANGED
|
@@ -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;
|
|
@@ -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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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, [],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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" | "
|
|
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,
|
|
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
|
-
*
|
|
511
|
-
|
|
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 =
|
|
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({
|
|
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
|
+
});
|