fix(harness): preserve adapter text output bytes (#797)
This commit is contained in:
1 parent
3e754c96a0
commit
478df1cacd
4 files changed
+51
-19
No files matched your search
@@ -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; }
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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") => {
|
||||
|
||||
Reference in new issue
Block a user