070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
205 lines
6.9 KiB
JavaScript
205 lines
6.9 KiB
JavaScript
// OD-faithful ACP (agent-client-protocol) renderer.
|
|
//
|
|
// Unlike the streaming formats (opencode/codex/claude/gemini/cursor-agent),
|
|
// ACP is a real JSON-RPC server over stdio. The mock has to:
|
|
//
|
|
// 1. Listen on stdin for newline-delimited JSON-RPC messages from OD.
|
|
// 2. Respond to `initialize` (id=1) with the protocol version.
|
|
// 3. Respond to `session/new` (id=2) with a synthetic sessionId.
|
|
// 4. Optionally respond to `session/set_model` (id=3) with {}.
|
|
// 5. When `session/prompt` (id=N) arrives, push a series of
|
|
// `session/update` NOTIFICATIONS carrying agent_message_chunk
|
|
// content from the recording, then respond to the prompt request
|
|
// with a final usage stats result.
|
|
// 6. Stay alive until OD closes stdin or the request completes.
|
|
//
|
|
// Verified against the spec implementation at
|
|
// apps/daemon/src/acp.ts (attachAcpSession + ACP_PROTOCOL_VERSION).
|
|
//
|
|
// Tool events from the recording are dropped — OD's ACP parser only
|
|
// recognizes thought_chunk + message_chunk. Tool-use surfaces in the
|
|
// real ACP agents (Hermes/Kimi/Kilo/Kiro/Vibe/Devin) come through
|
|
// MCP or other side channels, not the stdio protocol.
|
|
|
|
import { writeFile } from 'node:fs/promises';
|
|
|
|
const PROTOCOL_VERSION = 1;
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
|
|
function writeRpcResult(out, id, result) {
|
|
out.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
}
|
|
|
|
function writeNotification(out, method, params) {
|
|
out.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
}
|
|
|
|
/** Split a long text into approximately N-char chunks at word boundaries
|
|
* so OD's UI sees a streamed message rather than one giant burst. */
|
|
function chunkText(text, targetChunkSize = 400) {
|
|
if (text.length <= targetChunkSize) return [text];
|
|
const chunks = [];
|
|
let pos = 0;
|
|
while (pos < text.length) {
|
|
let end = Math.min(text.length, pos + targetChunkSize);
|
|
if (end < text.length) {
|
|
// Round to the next whitespace so we don't split mid-word
|
|
const ws = text.indexOf(' ', end);
|
|
if (ws !== -1 && ws - end < 200) end = ws + 1;
|
|
}
|
|
chunks.push(text.slice(pos, end));
|
|
pos = end;
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Run the ACP server on stdin/stdout. Returns a promise that resolves
|
|
* when the prompt round-trip completes.
|
|
*/
|
|
export async function runAcpServer(events, opts = {}) {
|
|
const out = opts.emit ?? process.stdout;
|
|
const writeFn = typeof out === 'function' ? out : s => out.write(s);
|
|
const writeLine = obj => writeFn(JSON.stringify(obj) + '\n');
|
|
|
|
const meta = events.find(e => e.type === 'meta');
|
|
const sessionId = opts.sessionId ?? `mock-acp-${Date.now()}`;
|
|
const reportEvent = events.find(e => e.type === 'report');
|
|
const reportText = reportEvent?.content ?? '';
|
|
|
|
// Collect any thought_chunks from tool_call inputs that look like
|
|
// "I should..." style reasoning. For our recordings, the recording
|
|
// doesn't separate thinking from tool calls — so we just stream the
|
|
// final report as message chunks. Future enhancement could split out
|
|
// thinking-style text.
|
|
void meta;
|
|
|
|
// Buffer for incoming JSON-RPC lines.
|
|
let buf = '';
|
|
let aborted = false;
|
|
|
|
// Track which request we owe a response to.
|
|
let lastPromptId = null;
|
|
|
|
// Chunks generator — used after session/prompt arrives.
|
|
const startPromptResponse = async (id) => {
|
|
lastPromptId = id;
|
|
const chunks = chunkText(reportText, 400);
|
|
for (const chunk of chunks) {
|
|
if (aborted) break;
|
|
if (!opts.noDelay) await sleep(50);
|
|
writeLine({
|
|
jsonrpc: '2.0',
|
|
method: 'session/update',
|
|
params: {
|
|
sessionId,
|
|
update: {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { type: 'text', text: chunk },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
// Final prompt response carries usage stats. ACP parser reads:
|
|
// formatUsage({inputTokens, outputTokens, cachedReadTokens, totalTokens})
|
|
writeRpcResult(out, id, {
|
|
stopReason: 'end_of_turn',
|
|
usage: {
|
|
inputTokens: 0,
|
|
outputTokens: meta?.total_tokens ?? 0,
|
|
cachedReadTokens: 0,
|
|
totalTokens: meta?.total_tokens ?? 0,
|
|
},
|
|
});
|
|
|
|
if (opts.reportFile) await writeFile(opts.reportFile, reportText).catch(() => {});
|
|
};
|
|
|
|
// Dispatch incoming RPC messages.
|
|
const handleRpc = async (obj) => {
|
|
if (!obj || typeof obj !== 'object') return;
|
|
const { id, method } = obj;
|
|
if (method === 'initialize') {
|
|
writeRpcResult(out, id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: {
|
|
loadSession: false,
|
|
// Tool calls aren't supported via stdio in this mock —
|
|
// matches the actual ACP agents on OD's side.
|
|
tools: false,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (method === 'session/new') {
|
|
writeRpcResult(out, id, {
|
|
sessionId,
|
|
configOptions: [
|
|
{
|
|
configId: 'model',
|
|
category: 'model',
|
|
currentValue: meta?.model ?? 'default',
|
|
values: [meta?.model ?? 'default'],
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
if (method === 'session/set_model' || method === 'session/set_config_option') {
|
|
writeRpcResult(out, id, {});
|
|
return;
|
|
}
|
|
if (method === 'session/prompt') {
|
|
// Fire-and-forget the chunk streaming; the prompt request gets
|
|
// responded to once the last chunk has been streamed.
|
|
void startPromptResponse(id);
|
|
return;
|
|
}
|
|
if (method === 'session/cancel') {
|
|
aborted = true;
|
|
// OD doesn't expect a response to cancel notifications.
|
|
return;
|
|
}
|
|
// Any other method: respond with an empty result to keep the parser happy.
|
|
if (id !== undefined && id !== null) {
|
|
writeRpcResult(out, id, {});
|
|
}
|
|
};
|
|
|
|
// Promise that resolves when the prompt response has been written.
|
|
return new Promise((resolve) => {
|
|
process.stdin.setEncoding('utf-8');
|
|
process.stdin.on('data', async (data) => {
|
|
buf += data;
|
|
let nl;
|
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
const line = buf.slice(0, nl).trim();
|
|
buf = buf.slice(nl + 1);
|
|
if (!line) continue;
|
|
let obj;
|
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
await handleRpc(obj);
|
|
}
|
|
});
|
|
process.stdin.on('end', () => {
|
|
// OD closed stdin after our prompt response — graceful shutdown.
|
|
resolve();
|
|
});
|
|
process.stdin.on('error', () => resolve());
|
|
|
|
const onSigint = () => { aborted = true; resolve(); };
|
|
process.once('SIGINT', onSigint);
|
|
process.once('SIGTERM', onSigint);
|
|
|
|
// Safety timeout — if OD never sends `initialize` within 30s,
|
|
// bail out so we don't hang in CI. .unref() lets the process exit
|
|
// promptly once the prompt round-trip completes normally.
|
|
setTimeout(() => {
|
|
if (lastPromptId === null) {
|
|
process.stderr.write('[mock-acp] no prompt received within 30s; exiting\n');
|
|
resolve();
|
|
}
|
|
}, 30_000).unref();
|
|
});
|
|
}
|