fix(harness): preserve adapter text output bytes (#797)

This commit is contained in:
jason.woltje committed 2026-11-06 03:01:12 -06:00
1 parent 3e754c96a0
commit 478df1cacd
4 files changed
+51 -19

No files matched your search

+3 -3
View File
@@ -51,7 +51,7 @@ export function modelEventStream({ harness, runDir }) {
const last = event.messages?.at(-1);
if (last?.role === 'assistant') {
failed = ['error', 'aborted'].includes(last.stopReason);
answer = (last.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n');
answer = (last.content ?? []).filter(part => part.type === 'text').map(part => part.text + '\n').join('');
}
}
} else {
@@ -60,7 +60,7 @@ export function modelEventStream({ harness, runDir }) {
const block = event.type === 'stream_event' && event.event?.type === 'content_block_start' && event.event.content_block;
if (block?.type === 'tool_use' && typeof block.id === 'string' && block.id.length && typeof block.name === 'string' && block.name.length) mark('output');
if (block && [["text", block.text], ["thinking", block.thinking]].some(([type, value]) => block.type === type && typeof value === 'string' && value.length)) mark('output');
if (event.type === 'result') { failed = event.is_error === true || event.subtype !== 'success'; answer = event.result ?? ''; }
if (event.type === 'result') { failed = event.is_error === true || event.subtype !== 'success'; answer = event.result ?? ''; if (typeof answer === 'string' && !answer.endsWith('\n')) answer += '\n'; }
}
};
return {
@@ -93,7 +93,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
child.on('error', () => { bad = true; });
child.on('close', (code, signal) => {
if (bad || signal || code !== 0) { process.exitCode = code || 1; return; }
try { const answer = stream.finish(); if (answer) process.stdout.write(answer + '\n'); }
try { process.stdout.write(stream.finish()); }
catch { process.stderr.write('model event stream failed\n'); process.exitCode = 1; }
});
}
+3 -1
View File
@@ -392,7 +392,9 @@ function runTurn({ session, request, n, env, onChild }) {
const markOutput = firstOutputMarker(session.runDir);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (s) => {
markOutput(s);
// Empty text blocks can render only newlines; they are not model output.
// Preserve the stdout fallback for content emitted by a managed adapter.
if (!session.proxyFirstTurn || !['pi', 'claude-code'].includes(session.harness) || /[^\n]/.test(s)) markOutput(s);
if (out.length < 4 * BODY_MAX) out += s;
});
return new Promise((resolve) => {
+32 -15
View File
@@ -29,7 +29,7 @@ for (const harness of ['pi', 'claude']) {
send(delta);
assert.equal(fs.readFileSync(file, 'utf8'), 'output\n');
send(harness === 'pi' ? {type:'agent_end',messages:[{role:'assistant',stopReason:'stop',content:[{type:'text',text:'final answer'}]}]} : {type:'result',subtype:'success',result:'final answer'});
assert.equal(stream.finish(), 'final answer');
assert.equal(stream.finish(), 'final answer\n');
});
test(`${harness} does not mark empty deltas, pings, or startup events`, t => {
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-'));
@@ -51,20 +51,37 @@ test('monitor forwards only the answer and keeps model events private', async t
const child = spawn(process.execPath, [monitor, 'pi', root, process.execPath, '-e', producer], { stdio:['ignore','pipe','pipe'] });
let stdout='', stderr=''; child.stdout.on('data', c => stdout += c); child.stderr.on('data', c => stderr += c);
const code = await new Promise(resolve => child.on('close', resolve));
assert.equal(code, 0, stderr); assert.equal(stdout.trim(), 'answer');
assert.equal(code, 0, stderr); assert.equal(stdout, 'answer\n');
assert.equal(fs.readFileSync(join(root, 'proxy-first-output'), 'utf8'), 'output\n');
});
test('an empty final answer does not create synthetic stdout or a marker', async t => {
const { spawn } = await import('node:child_process');
const { fileURLToPath } = await import('node:url');
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-empty-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const monitor = fileURLToPath(new URL('../src/first-output.mjs', import.meta.url));
const producer = `console.log(JSON.stringify({type:'result',subtype:'success',result:''}))`;
const child = spawn(process.execPath, [monitor, 'claude', root, process.execPath, '-e', producer], { stdio:['ignore','pipe','pipe'] });
let stdout=''; child.stdout.on('data', c => stdout += c);
const code = await new Promise(resolve => child.on('close', resolve));
assert.equal(code, 0); assert.equal(stdout, '');
assert.equal(fs.existsSync(join(root, 'proxy-first-output')), false);
});
// Expected bytes come from the adapters' base text modes: Pi prints each text
// block with a newline; Claude terminates its result only if needed.
for (const [harness, parts, expected] of [
['pi', ['Plain answer.'], 'Plain answer.\n'],
['pi', ['Line one\n雪 👩🏽‍💻 café\nLine three'], 'Line one\n雪 👩🏽‍💻 café\nLine three\n'],
['pi', ['Line one\n雪 👩🏽‍💻 café\nLine three\n'], 'Line one\n雪 👩🏽‍💻 café\nLine three\n\n'],
['pi', [], ''], ['pi', [''], '\n'],
['pi', [null], ''], ['pi', ['First block.', 'Second block.\nUnicode 雪'], 'First block.\nSecond block.\nUnicode 雪\n'],
['claude', ['Plain answer.'], 'Plain answer.\n'],
['claude', ['Line one\n雪 👩🏽‍💻 café\nLine three'], 'Line one\n雪 👩🏽‍💻 café\nLine three\n'],
['claude', ['Line one\n雪 👩🏽‍💻 café\nLine three\n'], 'Line one\n雪 👩🏽‍💻 café\nLine three\n'],
['claude', [], '\n'], ['claude', [null], '\n'],
['claude', ['First block.', 'Second block.\nUnicode 雪'], 'Second block.\nUnicode 雪\n'],
]) {
test(`${harness} preserves base stdout bytes for ${JSON.stringify(parts)}`, async t => {
const { spawn } = await import('node:child_process');
const { fileURLToPath } = await import('node:url');
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-parity-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const monitor = fileURLToPath(new URL('../src/first-output.mjs', import.meta.url));
const event = harness === 'pi'
? {type:'agent_end',messages:[{role:'assistant',stopReason:'stop',content:parts.map(text => text === null ? {type:'toolCall',id:'t',name:'read'} : {type:'text',text})}]}
: {type:'result',subtype:'success',result:parts.filter(x => x !== null).at(-1) ?? ''};
const child = spawn(process.execPath, [monitor, harness, root, process.execPath, '-e', `console.log(${JSON.stringify(JSON.stringify(event))})`], {stdio:['ignore','pipe','pipe']});
const chunks=[]; let stderr=''; child.stdout.on('data', c => chunks.push(c)); child.stderr.on('data', c => stderr += c);
const code = await new Promise(resolve => child.on('close', resolve));
assert.equal(code, 0, stderr); assert.deepEqual(Buffer.concat(chunks), Buffer.from(expected));
assert.equal(fs.existsSync(join(root, 'proxy-first-output')), false);
});
}
+13
View File
@@ -228,6 +228,19 @@ test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releas
assert.ok(!existsSync(join(ctx.runDir, "tools.sock")));
});
test("managed empty answer formatting does not mark model output", async t => {
const ctx = await setup(t, {session: {proxyFirstTurn: true, turnEnd: "exit"}});
writeFileSync(join(ctx.runDir, "proxy-first-turn-enabled"), "enabled\n");
writeFileSync(join(ctx.dir, "adapter.sh"), "printf '\\n\\n'\n");
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const sent = ctx.call(ctx.pm, "message.send", {to:"coder", body:"empty", class:"REQUEST"});
await until(() => ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
assert.equal(existsSync(join(ctx.runDir, "proxy-first-output")), false);
});
// Row 109 (#1603): each turn reads the state when it starts. The fake adapter echoes the request,
// so a reply shows the block the turn was given.
const ask = async (ctx, body = "what now") => {