From 078408daf9f776d8b490dbac5cf2af17b3faf91c Mon Sep 17 00:00:00 2001 From: cuteNBD <206630735+bingdongni@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:29:03 +0800 Subject: [PATCH] fix(experiment): preserve complete agent output --- src/experiment/agentRunner.js | 11 +++++--- test/experimentAgentRunner.test.js | 41 +++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/experiment/agentRunner.js b/src/experiment/agentRunner.js index b6c3a1b6..ccaeff0a 100644 --- a/src/experiment/agentRunner.js +++ b/src/experiment/agentRunner.js @@ -127,7 +127,8 @@ function mapAgentResult(json, ctx) { } /** - * Run one task prompt through a headless agent CLI. + * Run one task prompt through a headless agent CLI, then parse its complete + * UTF-8 output after the output streams close. * * @param {string} prompt * @param {object} [opts] { command, extraArgs, timeoutMs, cwd } @@ -177,8 +178,10 @@ function runAgentTask(prompt, opts) { return; } + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); child.stdout.on('data', (d) => { - stdout += d.toString('utf8'); + stdout += d; if (stdout.length > MAX_STDOUT_BYTES) { // A well-behaved `--output-format json` envelope is tiny; this much // stdout means a runaway/malformed CLI — kill it rather than OOM. @@ -187,7 +190,7 @@ function runAgentTask(prompt, opts) { } }); child.stderr.on('data', (d) => { - stderr += d.toString('utf8'); + stderr += d; if (stderr.length > MAX_STDOUT_BYTES) stderr = stderr.slice(-RESULT_CAP); }); @@ -198,7 +201,7 @@ function runAgentTask(prompt, opts) { done(makeFailure(msg, command, { durationMs: Date.now() - startedAt })); }); - child.on('exit', (code, signal) => { + child.on('close', (code, signal) => { const durationMs = Date.now() - startedAt; // A signal-terminated child reports code=null, and Number(null) is 0 — // num(null, -1) would read it as a CLEAN exit and let a killed arm diff --git a/test/experimentAgentRunner.test.js b/test/experimentAgentRunner.test.js index cb6d38be..d678bc9f 100644 --- a/test/experimentAgentRunner.test.js +++ b/test/experimentAgentRunner.test.js @@ -29,6 +29,24 @@ if (mode === 'kill') { // Flush the clean envelope FIRST, then die by signal. process.stdout.write(envelope + '\\n', () => { process.kill(process.pid, 'SIGKILL'); }); setInterval(() => {}, 1000); // keep alive until the kill lands +} else if (mode === 'split-utf8') { + const output = Buffer.from(JSON.stringify({ result: '中文𠀀', is_error: false }) + '\\n'); + const boundary = output.indexOf(Buffer.from('中文𠀀')) + 1; + process.stdout.write(output.subarray(0, boundary), () => { + setTimeout(() => process.stdout.write(output.subarray(boundary)), 50); + }); +} else if (mode === 'split-stderr') { + const output = Buffer.from('失败𠀀'); + process.stderr.write(output.subarray(0, 1), () => { + setTimeout(() => process.stderr.write(output.subarray(1)), 50); + }); +} else if (mode === 'late-output') { + const script = 'setTimeout(() => process.stdout.write(' + JSON.stringify(envelope + '\\n') + '), 100)'; + const child = require('child_process').spawn(process.execPath, ['-e', script], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + child.unref(); + process.exit(0); } else { process.stdout.write(envelope + '\\n'); } @@ -51,7 +69,7 @@ function fakeCli(mode) { return { command: process.execPath, extraArgs: [cliPath, mode], timeoutMs: 15000 }; } -describe('runAgentTask — exit-code semantics', () => { +describe('runAgentTask', () => { it('a signal-terminated arm is NEVER ok, even when stdout already holds a clean JSON envelope', async () => { // Regression: child 'exit' reports code=null for a signal death, and // Number(null) === 0 — the old num(code, -1) read that as a clean exit, @@ -68,4 +86,25 @@ describe('runAgentTask — exit-code semantics', () => { assert.equal(res.exitCode, 0); assert.equal(res.error, null); }); + + it('preserves UTF-8 characters split across output chunks', async () => { + const res = await runAgentTask('noop', fakeCli('split-utf8')); + assert.equal(res.ok, true); + assert.equal(res.resultText, '中文𠀀'); + }); + + it('preserves UTF-8 characters split across stderr chunks', async () => { + const res = await runAgentTask('noop', fakeCli('split-stderr')); + assert.equal(res.ok, false); + assert.equal(res.error, 'agent_output_parse_failed'); + assert.equal(res.resultText, '失败𠀀'); + }); + + it('waits for output streams after the agent process exits', { + skip: process.platform === 'win32' && 'inherited pipe behavior differs on Windows', + }, async () => { + const res = await runAgentTask('noop', fakeCli('late-output')); + assert.equal(res.ok, true, JSON.stringify(res)); + assert.equal(res.resultText, 'done'); + }); });