Skip to content

Commit 8daa85e

Browse files
committed
fix: a background command's registry sees every chunk, not only what the card has room for
The registry that read_command_output serves (ring, byte count, port and readiness sniffing, preview) was fed from the chunk callback that feeds the live card, and that stops at the card's 100000-character cap: after that a server's 'listening on :4000' left port=null, ready=false and totalBytes frozen. It is fed by its own callback now, after the card's, so posts keep their order. DESKTOP-VISIBLE behaviour change (output past 100000 characters), in its own commit.
1 parent fca2978 commit 8daa85e

2 files changed

Lines changed: 104 additions & 2 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,15 @@ function commandResult(out, note, max) {
419419
// kills the whole process GROUP (server + children) — needed because a server is a child of the shell.
420420
// Returns the (capped) full output for the model.
421421
//
422-
// opts.folders / opts.commit are for a host whose shell is simulated (host.spawn); the desktop's spawn ignores them.
422+
// opts.onData(text, stream) sees EVERY chunk, unlike onChunk, which stops at the live view's cap — a
423+
// registry that feeds on the view stops updating when the view fills up. opts.folders / opts.commit are for
424+
// a host whose shell is simulated (host.spawn); the desktop's spawn ignores them.
423425
function runCommand(root, command, onChunk, onExit, onStart, timeoutMs, opts) {
424426
return new Promise((resolve) => {
425427
let out = '', streamed = 0, settled = false, timedOut = false, stopped = false;
426428
const STREAM_CAP = 100000; // max we push to the live terminal view
427429
const start = Date.now();
430+
const onData = opts && opts.onData;
428431
const cap = (s, stream) => {
429432
out += s;
430433
if (out.length > 262144) { out = out.slice(-262144); } // bound the model buffer's memory
@@ -435,6 +438,8 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs, opts) {
435438
streamed += piece.length;
436439
if (streamed >= STREAM_CAP) { onChunk('\n[output truncated in view]', 'stderr'); }
437440
}
441+
// After the view, so what the card shows and what the registry sees arrive in the order they always did.
442+
if (onData) { onData(s, stream); }
438443
};
439444
let child;
440445
// detached:true → the shell becomes its own process-group leader, so process.kill(-pid) kills the
@@ -595,6 +600,9 @@ async function runTool(tu, ctx) {
595600
if (entry && ctx.commandRuns) { ctx.commandRuns.set(runId, entry); }
596601
const onChunk = (chunk, stream) => {
597602
ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream });
603+
};
604+
// Fed with every chunk — see runCommand: the live view's cap must not stop the registry.
605+
const onData = (chunk) => {
598606
if (entry) {
599607
entry.ring = (entry.ring + chunk).slice(-100000); // bounded tail for read_command_output
600608
entry.totalBytes += chunk.length;
@@ -628,7 +636,7 @@ async function runTool(tu, ctx) {
628636
ctx.post({ type: 'termExit', id: runId, code: code, ms: ms, how: how });
629637
};
630638
const onStart = (child, stop) => { if (stops) { stops.set(runId, stop); } };
631-
const spawnOpts = shellSpawnOpts(ctx, sh);
639+
const spawnOpts = Object.assign({ onData }, shellSpawnOpts(ctx, sh));
632640
if (bg) {
633641
// Fire-and-forget: keep streaming + tracking, but return NOW so the agent loop isn't blocked.
634642
// No timeout — a background server is meant to run long (Stop / New Chat reap it).
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* A background command's registry keeps up with the command — run: node test/agentBackgroundRegistry.test.js
3+
*
4+
* The bug this locks down: the registry that read_command_output serves (its output ring, byte count, the
5+
* port and readiness sniffers, the preview trigger) was fed from inside the callback that draws the live
6+
* card — and runCommand stops calling that callback once the CARD is full (100 000 characters). So a dev
7+
* server that logged 100 KB before it printed "listening on :4000" was never seen to be listening: port
8+
* null, ready false, the byte count frozen, and the ring ending in the card's own "[output truncated in
9+
* view]". The model polled a command that looked stuck for ever.
10+
*
11+
* The registry now sees every chunk; the live card is capped exactly as before.
12+
*--------------------------------------------------------------------------------------------*/
13+
// @ts-check
14+
'use strict';
15+
16+
const assert = require('assert');
17+
const fs = require('fs');
18+
const os = require('os');
19+
const path = require('path');
20+
const Module = require('module');
21+
const { EventEmitter } = require('events');
22+
23+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-registry-'));
24+
const vscodeMock = { workspace: { workspaceFolders: [{ uri: { fsPath: root }, name: 'app' }] } };
25+
let child;
26+
const childProcessMock = Object.assign({}, require('child_process'), {
27+
spawn() { child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), kill() { return true; } }); return child; },
28+
});
29+
const origLoad = Module._load;
30+
// @ts-ignore
31+
Module._load = function (request, parent, isMain) {
32+
if (request === 'vscode') { return vscodeMock; }
33+
if (request === 'child_process') { return childProcessMock; }
34+
return origLoad.call(this, request, parent, isMain);
35+
};
36+
const providers = require('../providers/index');
37+
let script = [];
38+
const seen = [];
39+
providers.streamAgentTurn = async (o) => { seen.push(JSON.parse(JSON.stringify(o.messages))); const t = script.shift(); if (!t) { throw new Error('script exhausted'); } return t; };
40+
const { runAgent } = require('../agent');
41+
42+
(async () => {
43+
try {
44+
const posted = [];
45+
const runs = new Map();
46+
script = [
47+
{ stop_reason: 'tool_use', content: [{ type: 'tool_use', id: 'b1', name: 'run_command', input: { command: 'npm run dev', background: true, explanation: 'Start the dev server' } }] },
48+
{ stop_reason: 'end_turn', content: [{ type: 'text', text: 'Done: started.' }] },
49+
];
50+
const opened = [];
51+
await runAgent({
52+
messages: [{ role: 'user', content: 'go' }], maxSteps: 4, post: (m) => posted.push(m), dbg() {}, approve: async () => true,
53+
commandRuns: runs, commandStops: new Map(), signal: new AbortController().signal, openPreview: async (u) => { opened.push(u); },
54+
});
55+
// 114 000 characters of noise — more than the live card keeps — and only THEN the server says where it is.
56+
for (let i = 0; i < 114; i++) { child.stdout.emit('data', Buffer.from('y'.repeat(1000))); }
57+
child.stdout.emit('data', Buffer.from('\nServer listening on http://localhost:4000\n'));
58+
59+
const e = runs.get('b1');
60+
assert.strictEqual(e.port, '4000', 'the port the server announced after the card filled up');
61+
assert.strictEqual(e.ready, true);
62+
assert.strictEqual(e.totalBytes, 114000 + '\nServer listening on http://localhost:4000\n'.length, 'the byte count did not freeze at the card\'s cap');
63+
assert.ok(e.ring.endsWith('Server listening on http://localhost:4000\n'), 'the ring ends with what the server said last');
64+
assert.ok(!e.ring.includes('[output truncated in view]'), 'the card\'s own marker is not command output');
65+
assert.deepStrictEqual(opened, ['http://localhost:4000'], 'and the preview was offered');
66+
67+
// …while the live card is capped exactly as it was.
68+
const viewed = posted.filter((m) => m.type === 'termOutput').map((m) => m.chunk).join('');
69+
assert.ok(viewed.length <= 100000 + '\n[output truncated in view]'.length, 'the card must stay capped: ' + viewed.length);
70+
assert.strictEqual(viewed.split('[output truncated in view]').length - 1, 1, 'the marker is said once');
71+
assert.ok(!viewed.includes('listening'), 'what came after the cap is not drawn');
72+
assert.ok(posted.some((m) => m.type === 'bgTask' && m.port === '4000'), 'the tray learned the port');
73+
74+
// read_command_output serves the registry, so the model can see it.
75+
script = [
76+
{ stop_reason: 'tool_use', content: [{ type: 'tool_use', id: 'r1', name: 'read_command_output', input: { id: 'b1', lines: 5 } }] },
77+
{ stop_reason: 'end_turn', content: [{ type: 'text', text: 'Done: it is up.' }] },
78+
];
79+
seen.length = 0;
80+
await runAgent({
81+
messages: [{ role: 'user', content: 'check it' }], maxSteps: 4, post() {}, dbg() {}, approve: async () => true,
82+
commandRuns: runs, commandStops: new Map(), signal: new AbortController().signal,
83+
});
84+
const last = seen[seen.length - 1];
85+
const r = last[last.length - 1].content.find((c) => c.tool_use_id === 'r1').content;
86+
assert.match(r, /^\[running on :4000\] npm run dev\n/);
87+
assert.match(r, /Server listening on http:\/\/localhost:4000/);
88+
console.log(' ok - the registry sees what the live card no longer shows, and the card is still capped');
89+
console.log('\nagentBackgroundRegistry: 1 test passed.');
90+
} finally {
91+
Module._load = origLoad;
92+
fs.rmSync(root, { recursive: true, force: true });
93+
}
94+
})().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)