149 lines
5.9 KiB
JavaScript
149 lines
5.9 KiB
JavaScript
/**
|
|
* Stand-in for the claude CLI in tests. Reads the stream-json input from
|
|
* stdin, then replays the scenario file named by FAKE_CLAUDE_SCENARIO line by
|
|
* line. Lines with a "fake" key are directives instead of output:
|
|
* {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call
|
|
* {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code
|
|
* {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result
|
|
* {"fake":"start-call",...} start the same request without waiting for its result
|
|
* {"fake":"cancel-call","id":"toolu_..."} cancel an individual pending MCP request
|
|
* {"fake":"checkpoint"} notify the parent after previous sends finish
|
|
* {"fake":"wait-for-message"} pause until the test releases the child over IPC
|
|
* {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation
|
|
* {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | "
|
|
* FAKE_CLAUDE_STDIN, when set, receives the stdin it read.
|
|
*/
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { on } from "node:events";
|
|
import { createInterface } from "node:readline";
|
|
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
|
|
const parentMessages = on(process, "message");
|
|
const HOLD_INTERVAL_MS = 60_000;
|
|
const BRIDGE_SERVER_NAME = "learnharness";
|
|
|
|
function writeLine(line) {
|
|
return new Promise((resolve) => process.stdout.write(`${line}\n`, resolve));
|
|
}
|
|
|
|
function streamEvent(event) {
|
|
return JSON.stringify({ type: "stream_event", event });
|
|
}
|
|
|
|
let lastSend = Promise.resolve();
|
|
|
|
async function connectBridge(mcpConfig) {
|
|
const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME];
|
|
const client = new Client({ name: "fake-claude", version: "1.0.0" });
|
|
|
|
const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } });
|
|
const send = transport.send.bind(transport);
|
|
transport.send = (...args) => (lastSend = send(...args));
|
|
await client.connect(transport);
|
|
return client;
|
|
}
|
|
|
|
async function writeReply(text) {
|
|
await writeLine(streamEvent({ type: "message_start", message: { usage: {} } }));
|
|
await writeLine(streamEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }));
|
|
await writeLine(streamEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }));
|
|
await writeLine(streamEvent({ type: "content_block_stop", index: 0 }));
|
|
await writeLine(streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} }));
|
|
await writeLine(streamEvent({ type: "message_stop" }));
|
|
}
|
|
|
|
const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8");
|
|
|
|
let input = "";
|
|
let receiveUser;
|
|
const userReceived = new Promise((resolve) => { receiveUser = resolve; });
|
|
createInterface({ input: process.stdin }).on("line", async (line) => {
|
|
input += `${line}\n`;
|
|
const message = JSON.parse(line);
|
|
if (message.type !== "control_request") {
|
|
if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input);
|
|
receiveUser(); return;
|
|
}
|
|
if (message.request.subtype === "initialize") {
|
|
if (process.env.FAKE_CLAUDE_INIT === "ignore") return;
|
|
await writeLine(JSON.stringify({ type: "control_response", response: {
|
|
request_id: message.request_id, subtype: process.env.FAKE_CLAUDE_INIT === "error" ? "error" : "success",
|
|
response: {}, error: "unsupported initialize",
|
|
} }));
|
|
} else if (message.request.subtype === "get_context_usage") {
|
|
await writeLine(JSON.stringify({ type: "control_response", response: {
|
|
request_id: message.request_id, subtype: "success",
|
|
response: { maxTokens: process.env.FAKE_CLAUDE_WINDOW ? JSON.parse(process.env.FAKE_CLAUDE_WINDOW) : null },
|
|
} }));
|
|
}
|
|
});
|
|
await userReceived;
|
|
|
|
let bridge = null;
|
|
const results = [];
|
|
const pendingResults = [];
|
|
const controllers = new Map();
|
|
const cancelled = new Set();
|
|
const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean);
|
|
for (const line of lines) {
|
|
const directive = JSON.parse(line);
|
|
|
|
if (directive.fake === "cancel-call") {
|
|
cancelled.add(directive.id);
|
|
controllers.get(directive.id)?.abort("cancelled by fake CLI");
|
|
await lastSend;
|
|
continue;
|
|
}
|
|
if (directive.fake === "checkpoint") {
|
|
await lastSend;
|
|
process.send?.({ fake: "checkpoint" });
|
|
continue;
|
|
}
|
|
if (directive.fake === "wait-for-message") {
|
|
await parentMessages.next();
|
|
continue;
|
|
}
|
|
if (directive.fake === "hold") {
|
|
setInterval(() => {}, HOLD_INTERVAL_MS);
|
|
break;
|
|
}
|
|
|
|
if (directive.fake === "exit") {
|
|
process.stderr.write(directive.stderr ?? "");
|
|
process.exit(directive.code);
|
|
}
|
|
|
|
if (directive.fake === "ignore-term") {
|
|
process.on("SIGTERM", () => {});
|
|
continue;
|
|
}
|
|
if (directive.fake === "call" || directive.fake === "start-call") {
|
|
bridge ??= await connectBridge(mcpConfig);
|
|
const controller = new AbortController();
|
|
controllers.set(directive.id, controller);
|
|
const request = bridge.callTool({
|
|
name: directive.name,
|
|
arguments: directive.arguments,
|
|
...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}),
|
|
}, undefined, { signal: controller.signal });
|
|
if (directive.fake === "start-call") {
|
|
pendingResults.push({ id: directive.id, request });
|
|
void request.catch(() => {});
|
|
await lastSend;
|
|
} else {
|
|
const result = await request;
|
|
results.push(result.content.map((block) => block.text).join(""));
|
|
}
|
|
continue;
|
|
}
|
|
if (directive.fake === "reply") {
|
|
for (const result of await Promise.all(pendingResults.splice(0).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join(""));
|
|
await writeReply(results.join(" | "));
|
|
continue;
|
|
}
|
|
|
|
await writeLine(line);
|
|
}
|