BUILD-LOG.md: append-only union, refactor entries first, then the branch's. docs/SESSIONS.md: append-only union, refactor entries first, then the branch's. packages/bus/src/runtime.mjs: keep the organization usage timer and refactor's halted/invalid startup-recovery gate. packages/harness/src/first-output.mjs: keep the branch's harness-neutral markers; refactor's empty-length rejection moves into the Pi monitor (packages/plugins/harness/pi/model-stream.mjs). packages/harness/tests/runner.test.mjs: keep refactor's length-continuation cases and the branch's output-policy cases. packages/plugins/harness/pi/index.mjs: keep the documented GLM limits and the branch's transport-based bundle. Suites: 6356 pass, 32 fail; all 32 fail identically on both parents (node_modules symlink EISDIR in pinned-copy and stack copy/release tests, missing Python jsonschema in two conversation tests).
129 lines
9.8 KiB
JavaScript
129 lines
9.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import * as output from '../../plugins/harness/pi/model-stream.mjs';
|
|
|
|
for (const harness of ['pi']) {
|
|
test(`${harness} counts a named tool call even when it has no input delta`, t => {
|
|
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-tool-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const stream = output.modelEventStream({ harness, runDir: root });
|
|
const event = harness === 'pi'
|
|
? {type:'message_update',assistantMessageEvent:{type:'toolcall_start',id:'tool_1',toolName:'read'}}
|
|
: {type:'stream_event',event:{type:'content_block_start',content_block:{type:'tool_use',id:'tool_1',name:'read',input:{}}}};
|
|
stream.push(JSON.stringify(event)+'\n');
|
|
assert.equal(fs.readFileSync(join(root, 'proxy-first-output'), 'utf8'), 'output\n');
|
|
});
|
|
test(`${harness} marks real deltas, ignores startup events, and returns only the final answer`, t => {
|
|
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
assert.equal(typeof output.modelEventStream, 'function', 'shared event monitor exists');
|
|
const stream = output.modelEventStream({ harness, runDir: root });
|
|
const send = event => stream.push(JSON.stringify(event) + '\n');
|
|
const file = join(root, 'proxy-first-output');
|
|
send(harness === 'pi' ? {type:'message_update', assistantMessageEvent:{type:'start'}} : {type:'system',subtype:'init'});
|
|
assert.equal(fs.existsSync(file), false);
|
|
const delta = harness === 'pi' ? {type:'message_update', assistantMessageEvent:{type:'thinking_delta',delta:'secret thinking'}} : {type:'stream_event',event:{type:'content_block_delta',delta:{type:'thinking_delta',thinking:'secret thinking'}}};
|
|
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\n');
|
|
});
|
|
test(`${harness} does not mark empty deltas, pings, or startup events`, t => {
|
|
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
assert.equal(typeof output.modelEventStream, 'function', 'shared event monitor exists');
|
|
const stream = output.modelEventStream({ harness, runDir: root });
|
|
for (const e of [{type:'system'}, {type:'tool_result', content:'local tool output'}, {type:'message_end', message:{role:'toolResult', content:'local tool output'}}, {type:'stream_event',event:{type:'ping'}}, {type:'message_update',assistantMessageEvent:{type:'text_delta',delta:''}}, {type:'stream_event',event:{type:'content_block_delta',delta:{type:'text_delta',text:''}}}, {type:'stream_event',event:{type:'content_block_delta',delta:{type:'text_delta',text:{}}}}]) stream.push(JSON.stringify(e)+'\n');
|
|
assert.equal(fs.existsSync(join(root, 'proxy-first-output')), false);
|
|
});
|
|
}
|
|
|
|
test('monitor forwards only the answer and keeps model events private', async t => {
|
|
const { spawn } = await import('node:child_process');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const monitor = fileURLToPath(new URL('../../plugins/harness/pi/model-stream.mjs', import.meta.url));
|
|
const producer = `console.log(JSON.stringify({type:'message_update',assistantMessageEvent:{type:'text_delta',delta:'private'}}));console.log(JSON.stringify({type:'agent_end',messages:[{role:'assistant',stopReason:'stop',content:[{type:'text',text:'answer'}]}]}))`;
|
|
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, 'answer\n');
|
|
assert.equal(fs.readFileSync(join(root, 'proxy-first-output'), 'utf8'), 'output\n');
|
|
});
|
|
|
|
// 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('../../plugins/harness/pi/model-stream.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 helper = fileURLToPath(new URL('../../../adapters/claude/stream.mjs', import.meta.url));
|
|
const args = harness === 'pi' ? [monitor, harness, root, process.execPath, '-e', `console.log(${JSON.stringify(JSON.stringify(event))})`] : [helper, process.execPath, '-e', `console.log(${JSON.stringify(JSON.stringify(event))})`];
|
|
const child = spawn(process.execPath, args, {env:{...process.env, MOSAIC_MODEL_ACTIVITY_DIR:root}, stdio:['ignore','pipe','pipe','pipe','ignore','pipe']});
|
|
child.stdio[3].resume(); child.stdio[5].resume();
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Claude activity is owned by its actual adapter stream helper, not the Pi monitor.
|
|
for (const [name, events, marked] of [
|
|
['named tool call', [{type:'stream_event',event:{type:'content_block_start',content_block:{type:'tool_use',id:'tool_1',name:'read',input:{}}}}], true],
|
|
['real thinking delta', [{type:'system',subtype:'init'}, {type:'stream_event',event:{type:'content_block_delta',delta:{type:'thinking_delta',thinking:'private'}}}], true],
|
|
['local and synthetic events', [{type:'system',subtype:'init'}, {type:'system',subtype:'hook_response'}, {type:'user',message:{role:'user',content:[{type:'tool_result',content:'private'}]}}, {type:'assistant',error:'api_error',message:{role:'assistant',model:'<synthetic>',content:[{type:'text',text:'error'}]}}, {type:'result',subtype:'error',is_error:true,result:'error'}], false],
|
|
]) {
|
|
test(`claude stream helper: ${name}`, async t => {
|
|
const { spawn } = await import('node:child_process');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const root = fs.mkdtempSync(join(tmpdir(), 'stream797-claude-'));
|
|
t.after(() => fs.rmSync(root, {recursive:true,force:true}));
|
|
const helper = fileURLToPath(new URL('../../../adapters/claude/stream.mjs', import.meta.url));
|
|
const outputEvents = marked ? [...events, {type:'result',subtype:'success',result:'final answer'}] : events;
|
|
const source = `for (const e of ${JSON.stringify(outputEvents)}) console.log(JSON.stringify(e))`;
|
|
const child = spawn(process.execPath, [helper, process.execPath, '-e', source], {env:{...process.env,MOSAIC_MODEL_ACTIVITY_DIR:root},stdio:['ignore','pipe','pipe','pipe','ignore','pipe']});
|
|
const chunks=[]; child.stdout.on('data', c => chunks.push(c)); child.stdio[3].resume(); child.stdio[5].resume();
|
|
const code = await new Promise(resolve => child.on('close', resolve));
|
|
assert.equal(code, marked ? 0 : 2);
|
|
assert.equal(fs.existsSync(join(root,'proxy-first-output')), marked);
|
|
if (marked) { assert.equal(fs.readFileSync(join(root,'proxy-first-output'),'utf8'), 'output\n'); assert.equal(Buffer.concat(chunks).toString(), 'final answer\n'); }
|
|
});
|
|
}
|
|
|
|
test('Pi rejects an empty length stop, but accepts text or a tool call', t => {
|
|
const root = fs.mkdtempSync(join(tmpdir(), 'length-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
for (const [content, fails] of [
|
|
[[{type:'thinking', thinking:'private'}, {type:'text', text:' '}], true],
|
|
[[{type:'text', text:'answer'}], false],
|
|
[[{type:'toolCall', id:'t', name:'read'}], false],
|
|
]) {
|
|
const stream = output.modelEventStream({ harness:'pi', runDir:root });
|
|
stream.push(JSON.stringify({type:'agent_end',messages:[{role:'assistant',stopReason:'length',content}]}) + '\n');
|
|
if (fails) assert.throws(() => stream.finish(), /model stream did not complete/);
|
|
else assert.doesNotThrow(() => stream.finish());
|
|
}
|
|
});
|