Headless Koa v3 server exposing REST + SSE around the brain-dump skill: - AgentBackend adapter seam with two implementations: Claude Agent SDK (skill loading, session resume via sdk_session_id) and a hand-rolled OpenAI-compatible tool-calling loop (vault-scoped file tools, SKILL.md injected into the system prompt, history persisted in SQLite) - session CRUD + one-turn-at-a-time SSE streaming (text deltas, file_write events, turn_complete), per-session lock, abort on disconnect - node:sqlite storage (sessions, messages, turn_events) with migrations - vault allow-list + symlink-aware path-escape prevention (incl. dangling symlink defense), read-only git status/diff endpoints, configurable CORS - deterministic mock chat-completions server (scripts/mock-openai.ts) for end-to-end testing without a real model Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
/**
|
|
* Deterministic mock of an OpenAI-compatible /v1/chat/completions endpoint that
|
|
* speaks streaming SSE, for end-to-end testing the openai-compat backend without
|
|
* a real model. tsx-runnable:
|
|
*
|
|
* PORT=11500 tsx scripts/mock-openai.ts
|
|
*
|
|
* Scripting (decided per request from the message history it receives):
|
|
* - If the latest user message contains "ESCAPE", the first assistant turn
|
|
* emits a write_file tool_call with path "../escape.md" (vault-escape probe).
|
|
* - Otherwise the first assistant turn streams some text and a write_file
|
|
* tool_call for projects/mock/mock.md (arguments split across fragments, and
|
|
* id/name only on the first fragment, to exercise streamed-tool_call parsing).
|
|
* - Once the history's last message is a tool result, it streams a final text
|
|
* and finishes with finish_reason "stop".
|
|
*
|
|
* It logs a compact summary of every request's message roles to stderr so an
|
|
* e2e harness can assert that a second turn replays prior history.
|
|
*/
|
|
import http from "node:http";
|
|
|
|
const PORT = Number(process.env.PORT ?? 11500);
|
|
|
|
interface InMsg {
|
|
role: string;
|
|
content: string | null;
|
|
tool_calls?: unknown[];
|
|
tool_call_id?: string;
|
|
}
|
|
|
|
function sseChunk(obj: unknown): string {
|
|
return `data: ${JSON.stringify(obj)}\n\n`;
|
|
}
|
|
|
|
function baseDelta(delta: unknown, finish: string | null = null) {
|
|
return {
|
|
id: "chatcmpl-mock",
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: "mock-model",
|
|
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
};
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if (req.method !== "POST" || !req.url?.includes("/chat/completions")) {
|
|
res.writeHead(404).end("not found");
|
|
return;
|
|
}
|
|
let body = "";
|
|
req.on("data", (c) => (body += c));
|
|
req.on("end", () => {
|
|
let messages: InMsg[] = [];
|
|
try {
|
|
messages = (JSON.parse(body).messages ?? []) as InMsg[];
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
const roles = messages.map((m) => m.role).join(",");
|
|
const userMsgs = messages.filter((m) => m.role === "user").length;
|
|
process.stderr.write(`[mock] request roles=[${roles}] userMessages=${userMsgs}\n`);
|
|
|
|
const last = messages[messages.length - 1];
|
|
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
const wantEscape = typeof lastUser?.content === "string" && lastUser.content.includes("ESCAPE");
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
|
|
if (last?.role === "tool") {
|
|
// Second iteration: finish the turn.
|
|
res.write(sseChunk(baseDelta({ role: "assistant", content: "" })));
|
|
res.write(sseChunk(baseDelta({ content: "Done — I captured that into the vault. " })));
|
|
res.write(sseChunk(baseDelta({ content: "What else should we add? (1) goals? (2) stack?" })));
|
|
res.write(sseChunk(baseDelta({}, "stop")));
|
|
res.write("data: [DONE]\n\n");
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// First iteration: stream text + a write_file tool_call across fragments.
|
|
const targetPath = wantEscape ? "../escape.md" : "projects/mock/mock.md";
|
|
const fileContent = "---\ntags:\n - mock\n - mock/moc\n---\n\n# Mock\n\nCaptured by the mock backend.\n\n## TODO / Ideas\n\n<!-- TODO: confirm details -->\n";
|
|
const argsFull = JSON.stringify({ path: targetPath, content: fileContent });
|
|
const mid = Math.floor(argsFull.length / 2);
|
|
|
|
res.write(sseChunk(baseDelta({ role: "assistant", content: "" })));
|
|
res.write(sseChunk(baseDelta({ content: "Got it. Let me start the note. " })));
|
|
// tool_call fragment 1: id + name + first half of arguments.
|
|
res.write(
|
|
sseChunk(
|
|
baseDelta({
|
|
tool_calls: [
|
|
{ index: 0, id: "call_mock_1", type: "function", function: { name: "write_file", arguments: argsFull.slice(0, mid) } },
|
|
],
|
|
})
|
|
)
|
|
);
|
|
// tool_call fragment 2: remaining arguments only (no id/name).
|
|
res.write(
|
|
sseChunk(
|
|
baseDelta({
|
|
tool_calls: [{ index: 0, function: { arguments: argsFull.slice(mid) } }],
|
|
})
|
|
)
|
|
);
|
|
res.write(sseChunk(baseDelta({}, "tool_calls")));
|
|
res.write("data: [DONE]\n\n");
|
|
res.end();
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
process.stderr.write(`[mock] listening on http://localhost:${PORT}\n`);
|
|
});
|