replica-omnisciente/shared/acp/conformance/interop-driver.mjs

122 lines
4.6 KiB
JavaScript

#!/usr/bin/env node
// interop-driver.mjs — independent ACP client (Node stdlib only) that spawns an
// agent over stdio and runs initialize → session/new → session/prompt, collecting
// session/update notifications and the final stopReason.
//
// Purpose: prove cross-implementation wire compatibility. This client is NEITHER
// of our two stacks (not aurelio-backend's AcpClient.ts, not tilth-acp-client);
// it is a minimal third implementation. If the Rust agent (tilth-acp-agent) can
// complete a real prompt turn against it over live stdio, the protocol surface is
// genuinely compatible — not just corpus-compatible.
//
// Usage: node interop-driver.mjs -- <agent command...>
// e.g. node interop-driver.mjs -- /path/to/tilth-acp-agent
// Env: INTEROP_PROMPT (default "tilth_search"), INTEROP_CWD (default process.cwd())
// Exit: 0 on a completed turn with stopReason=end_turn and a non-empty streamed
// chunk; 1 otherwise (prints a JSON summary to stderr on failure).
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const argv = process.argv.slice(2);
const sep = argv.indexOf('--');
const agentArgv = sep >= 0 ? argv.slice(sep + 1) : argv;
if (agentArgv.length === 0) {
console.error('usage: node interop-driver.mjs -- <agent command...>');
process.exit(2);
}
const [cmd, ...cmdArgs] = agentArgv;
const PROMPT = process.env.INTEROP_PROMPT || 'tilth_search';
const CWD = process.env.INTEROP_CWD || process.cwd();
const child = spawn(cmd, cmdArgs, { stdio: ['pipe', 'pipe', 'inherit'] });
let nextId = 1;
const pending = new Map(); // id -> {resolve, reject}
const updates = [];
let buf = '';
let stopReason = null;
let sawChunk = false;
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buf += chunk;
for (;;) {
const nl = buf.indexOf('\n');
if (nl < 0) break;
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch (e) { continue; }
handle(msg);
}
});
function handle(msg) {
if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id);
if (p) { pending.delete(msg.id); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); }
return;
}
// notification: session/update
if (msg.method === 'session/update' && msg.params) {
updates.push(msg.params);
const u = msg.params.update;
if (u && (u.sessionUpdate === 'agent_message_chunk' || u.sessionUpdate === 'agentMessageChunk')) {
const text = u.content && u.content.text;
if (typeof text === 'string' && text.length > 0) sawChunk = true;
}
}
}
function request(method, params) {
const id = nextId++;
const line = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
child.stdin.write(line, (err) => { if (err) { pending.delete(id); reject(err); } });
});
}
function fail(reason, extra) {
console.error(JSON.stringify({ ok: false, reason, stopReason, sawChunk, updates: updates.length, ...extra }));
try { child.kill('SIGKILL'); } catch {}
process.exit(1);
}
// Hard wall-clock guard so a hung agent can't wedge CI.
const WALL = 25_000;
const killer = sleep(WALL).then(() => fail('wall-clock timeout', { timeoutMs: WALL }));
try {
const init = await request('initialize', { protocolVersion: 1, clientCapabilities: {} });
if (!init || init.protocolVersion !== 1) fail('bad initialize', { init });
const sess = await request('session/new', { cwd: CWD, mcpServers: [] });
const sessionId = sess && sess.sessionId;
if (!sessionId) fail('no sessionId', { sess });
const prompt = await request('session/prompt', {
sessionId,
prompt: [{ type: 'text', text: PROMPT }],
});
stopReason = prompt && (prompt.stopReason ?? prompt.stop_reason);
// Give any trailing notifications a moment to land before we read sawChunk.
await sleep(150);
if (stopReason !== 'end_turn' && stopReason !== 'endTurn') {
fail('unexpected stopReason', { prompt });
}
if (!sawChunk) fail('no non-empty agent_message_chunk streamed', { prompt, updates });
console.log(JSON.stringify({ ok: true, stopReason: 'end_turn', sawChunk, updates: updates.length, sessionId }));
try { child.stdin.end(); } catch {}
await sleep(150);
try { child.kill('SIGTERM'); } catch {}
process.exit(0);
} catch (e) {
fail('exception', { error: String(e && e.message || e) });
} finally {
// Ensure the wall-clock guard doesn't keep the loop alive after we exit.
await Promise.race([killer.catch(() => {})]);
}