Skip to content

Commit 1a47179

Browse files
committed
fix(levelcode-ai): #107 review — a long command's result starts at its real start and counts what it left out
The bug: runCommand keeps only the last 262144 characters of a command's output in memory, and commandResult then took its "start" from that buffer and counted the omitted characters against it. Above 256 KiB the start the model saw came from the middle of the output, and the gap was a lie: a 2 MB `cat` was reported as "[~254000 characters of output omitted]". The desktop and the browser shell share this code, so both were affected. The fix: runCommand also keeps the first 8000 characters of the output and counts every character it was given. When the buffer has dropped the beginning, commandResult is handed that real start and the true total, so the start shown is the start and kept + omitted is the whole output. Output up to 256 KiB goes through exactly as before (nothing extra is passed). Test: agentCommandResult.test.js "OUTPUT OVER 256 KiB: the start is the command's real start, and the omitted count is everything that is not shown" drives the real runAgent -> run_command -> runCommand chain with about 2.2 MB of output. It fails on the unfixed code (the result started at line 61809) and passes now; the other agentCommandResult, agentRunCommand (untouched) and shellSeam tests still pass.
1 parent 6820015 commit 1a47179

2 files changed

Lines changed: 43 additions & 7 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -421,19 +421,27 @@ function isBinaryFile(abs) { return host.isBinary(abs); }
421421
* Output that fits is returned exactly as it always was. Output that does not keeps its START and its END with
422422
* the gap said out loud, and the status line (`[exit 1]`, `[timed out]`, `[stopped by user]`) is never what is cut:
423423
* it used to be, because it sits at the end and the whole thing was sliced from the front.
424-
* @param {string} out
424+
* @param {string} out the output — or, when runCommand kept only its end (OUT_KEPT), that end
425425
* @param {string} note
426426
* @param {number} [max]
427+
* @param {{ start: string, total: number }} [whole] when `out` is only the end: the output's real start (at least a
428+
* quarter of `max` of it) and how many characters there were in all, so the start shown is the start and the count
429+
* of what was left out is the true one
427430
*/
428-
function commandResult(out, note, max) {
431+
function commandResult(out, note, max, whole) {
429432
max = max || 8000;
430-
if (out.length + note.length <= max) { return out + note; }
433+
const total = whole ? whole.total : out.length;
434+
if (total + note.length <= max) { return out + note; }
431435
const gapText = (n) => '\n… [' + n + ' characters of output omitted] …\n';
432-
const room = max - note.length - gapText(out.length).length;
436+
const room = max - note.length - gapText(total).length;
433437
const head = Math.max(0, Math.floor(room / 4)); // a quarter for the start, the rest for the end — where the failure is
434438
const tail = Math.max(0, room - head);
435-
return out.slice(0, head) + gapText(out.length - head - tail) + out.slice(out.length - tail) + note;
439+
return (whole ? whole.start : out).slice(0, head) + gapText(total - head - tail) + out.slice(out.length - tail) + note;
436440
}
441+
/** What runCommand keeps of a command's output in memory: its last OUT_KEPT characters and, apart from them, its first
442+
* START_KEPT (more than commandResult ever shows of a start). */
443+
const OUT_KEPT = 262144;
444+
const START_KEPT = 8000;
437445

438446
// Run a shell command, STREAMING its output: onChunk(text, 'stdout'|'stderr') fires as it runs (live
439447
// terminal in the chat), onExit(code, ms, how) at the end. onStart(child, stop) exposes a stop() that
@@ -446,12 +454,15 @@ function commandResult(out, note, max) {
446454
function runCommand(root, command, onChunk, onExit, onStart, timeoutMs, opts) {
447455
return new Promise((resolve) => {
448456
let out = '', streamed = 0, settled = false, timedOut = false, stopped = false;
457+
let head = '', total = 0; // the output's first START_KEPT characters, and its length: `out` keeps only the end
449458
const STREAM_CAP = 100000; // max we push to the live terminal view
450459
const start = Date.now();
451460
const onData = opts && opts.onData;
452461
const cap = (s, stream) => {
453462
out += s;
454-
if (out.length > 262144) { out = out.slice(-262144); } // bound the model buffer's memory
463+
total += s.length;
464+
if (head.length < START_KEPT) { head += s.slice(0, START_KEPT - head.length); }
465+
if (out.length > OUT_KEPT) { out = out.slice(-OUT_KEPT); } // bound the model buffer's memory
455466
if (onChunk && streamed < STREAM_CAP) {
456467
const room = STREAM_CAP - streamed;
457468
const piece = s.length > room ? s.slice(0, room) : s;
@@ -481,7 +492,7 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs, opts) {
481492
const exit = stopped ? 130 : (timedOut ? 124 : (code == null ? -1 : code));
482493
if (onExit) { onExit(exit, ms, how); }
483494
const note = stopped ? '\n[stopped by user]' : timedOut ? '\n[timed out]' : (exit !== 0 ? '\n[exit ' + exit + ']' : '');
484-
resolve(commandResult(out, note) || '(no output)');
495+
resolve(commandResult(out, note, undefined, total > out.length ? { start: head, total } : undefined) || '(no output)');
485496
};
486497
child.on('close', (code) => finish(code));
487498
child.on('error', (e) => { cap('\n' + ((e && e.message) || e), 'stderr'); finish(1); });

‎extensions/levelcode-ai/test/agentCommandResult.test.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,31 @@ const line = (i) => 'line ' + String(i).padStart(4, '0') + ' ' + 'x'.repeat(20)
8585
assert.strictEqual(r.length - '\n[exit 1]'.length - (r.match(/\n… \[\d+ characters of output omitted\] …\n/)[0].length) + omitted, total, 'nothing is lost silently: kept + omitted = everything');
8686
});
8787

88+
await test('OUTPUT OVER 256 KiB: the start is the command\'s real start, and the omitted count is everything that is not shown', async () => {
89+
// runCommand keeps only the last 256 KiB of the output in memory; a 2 MB `cat` used to come back with a "start"
90+
// from the middle of the file and "[~254000 characters of output omitted]" for about two million.
91+
let total = 0;
92+
const r = await result((c) => {
93+
const first = 'BEGIN of the output\n';
94+
c.stdout.emit('data', Buffer.from(first)); total += first.length;
95+
for (let k = 0; k < 70; k++) {
96+
let chunk = '';
97+
for (let i = 0; i < 1000; i++) { chunk += line(k * 1000 + i); }
98+
c.stdout.emit('data', Buffer.from(chunk)); total += chunk.length;
99+
}
100+
const last = 'FAIL at the very end\n';
101+
c.stderr.emit('data', Buffer.from(last)); total += last.length;
102+
c.emit('close', 1);
103+
});
104+
assert.ok(total > 2000000, 'about 2 MB: ' + total);
105+
assert.ok(r.length <= 8000, 'still bounded: ' + r.length);
106+
assert.ok(r.startsWith('BEGIN of the output\nline 0000 '), 'the start is the real start: ' + JSON.stringify(r.slice(0, 60)));
107+
assert.ok(r.endsWith('FAIL at the very end\n\n[exit 1]'), 'the end and the status: ' + JSON.stringify(r.slice(-40)));
108+
const gap = /\n… \[(\d+) characters of output omitted\] …\n/.exec(r);
109+
assert.ok(gap, 'the gap is said out loud');
110+
assert.strictEqual(r.length - '\n[exit 1]'.length - gap[0].length + Number(gap[1]), total, 'kept + omitted = everything the command wrote');
111+
});
112+
88113
await test('a timeout and a stop are still said, however much came before', async () => {
89114
const noisy = (c) => { for (let i = 0; i < 1000; i++) { c.stdout.emit('data', Buffer.from(line(i))); } };
90115
const t = await result((c) => { noisy(c); }, { commandTimeout: 30 });

0 commit comments

Comments
 (0)