dotfiles/pi/extensions/claude-bridge/tests/mock-claude.cjs

81 lines
4.9 KiB
JavaScript

// Offline CLI fixture. The regression runner installs it as `claude` in a temporary PATH.
const fs = require('node:fs');
const args = process.argv.slice(2);
const flag = (name) => args[args.indexOf(name) + 1];
const model = flag('--model');
const mcp = args.includes('--mcp-config') ? JSON.parse(fs.readFileSync(flag('--mcp-config'), 'utf8')).mcpServers.pi.url : null;
let count = 0, memory = '', buffer = '';
const emit = (message) => process.stdout.write(JSON.stringify(message) + '\n');
const event = (event) => emit({ type: 'stream_event', event });
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function answer(text) {
const id = `text-${++count}`;
event({ type: 'message_start', message: { id, usage: { input_tokens: 10, output_tokens: 0 } } });
event({ type: 'content_block_start', index: 0, content_block: { type: 'text' } });
event({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } });
event({ type: 'content_block_stop', index: 0 });
event({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } });
event({ type: 'message_stop' });
emit({ type: 'assistant', message: { id, content: [{ type: 'text', text }], usage: { input_tokens: 10, output_tokens: 5 } } });
// Expose the original bug: a new Pi prompt must not start before this result.
await delay(150);
emit({ type: 'result', subtype: 'success', result: text, usage: { input_tokens: 100, output_tokens: 50 } });
}
async function tool(name, input, nonStreaming = false, inlineInput = false) {
const id = `tool-${++count}`;
const block = { type: 'tool_use', id, name: `mcp__pi__${name}`, input };
const message = { id: `assistant-${id}`, content: [block], usage: { input_tokens: 10, output_tokens: 5 } };
if (nonStreaming) emit({ type: 'assistant', message });
else {
event({ type: 'message_start', message });
event({ type: 'content_block_start', index: 0, content_block: block });
if (!inlineInput) event({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: JSON.stringify(input) } });
event({ type: 'content_block_stop', index: 0 });
event({ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 5 } });
event({ type: 'message_stop' });
// The full assistant record can arrive after Pi opens its tool-result stream.
await delay(80);
emit({ type: 'assistant', message });
}
const response = await fetch(mcp, { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: input, _meta: { 'claudecode/toolUseId': id } } }) });
const result = (await response.json()).result;
await answer(result?.isError ? 'TOOL_ERROR_HANDLED' : 'TOOL_OK');
}
async function handle(record) {
const content = record.message.content;
const text = content.filter((block) => block.type === 'text').map((block) => block.text).join('\n');
const prompt = content.at(-1)?.text || '';
if (text.includes('MEMORY_73921')) memory = 'MEMORY_73921';
fs.appendFileSync(process.env.BRIDGE_TEST_LOG, JSON.stringify({ pid: process.pid, model, effort: args.includes('--effort') ? flag('--effort') : null, text, apiKey: Boolean(process.env.ANTHROPIC_API_KEY) }) + '\n');
if (prompt === 'MOCK_ABORT') {
event({ type: 'message_start', message: { id: 'abort', usage: {} } });
event({ type: 'content_block_start', index: 0, content_block: { type: 'text' } });
event({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ABORT_STARTED' } });
return;
}
if (prompt === 'MOCK_EXIT') return process.exit(7);
if (prompt === 'MOCK_INLINE_TOOL') return tool('read', { path: process.env.BRIDGE_TEST_FILE }, false, true);
if (prompt === 'MOCK_TOOL') return tool('read', { path: process.env.BRIDGE_TEST_FILE });
if (prompt === 'MOCK_FALLBACK_TOOL') return tool('read', { path: process.env.BRIDGE_TEST_FILE }, true);
if (prompt === 'MOCK_TOOL_ERROR') return tool('read', { path: process.env.BRIDGE_TEST_FILE + '.missing' });
if (prompt === 'MOCK_SLOW_TOOL') return tool('bridge_wait', {});
if (prompt === 'MOCK_MEMORY') return answer(memory || 'UNKNOWN');
if (prompt === 'MOCK_MODEL') return answer(model);
if (prompt === 'MOCK_CLI_ERROR') {
emit({ type: 'result', is_error: true, errors: ['MOCK_PROVIDER_FAILURE'] });
return;
}
return answer(prompt);
}
let queue = Promise.resolve();
process.stdin.on('data', (chunk) => {
buffer += chunk;
let newline;
while ((newline = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
if (line) queue = queue.then(() => handle(JSON.parse(line))).catch((error) => { console.error(error); process.exit(1); });
}
});
process.stdin.on('end', () => { queue.finally(() => process.exit(0)); });
// Exercise old-process cleanup after the replacement request has already started.
process.on('SIGTERM', () => setTimeout(() => process.exit(0), 250));