Files
Reactive-Resume/tests/e2e/fixtures/ai-stub.ts
T
Amruth Pillai 3ef8eaeb26 test(e2e): drive the assistant and Improve against a scripted provider
A local OpenAI-compatible stub plays a short conversation: read the document, propose an edit, ask a question when asked, and stream slowly enough to stop. The spec connects it in place, accepts an edit and checks it was saved, answers a question, stops a reply, improves a line, asks from the command palette and follows the old /agent links. The E2E workflow allows the loopback base URL the stub needs.
2026-09-29 00:11:21 +02:00

178 lines
6.1 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { createServer } from "node:http";
/**
* A scripted OpenAI-compatible provider for the assistant specs. It answers the connection test, and plays a small
* conversation: read the document, propose an edit to its first passage, then say what changed. A message asking
* for "a question" makes it ask one first; "slowly" streams a long reply that can be stopped. Improve gets the line
* back with "Led" in front.
*/
type ChatMessage = { role: string; content?: unknown; tool_call_id?: string; tool_calls?: unknown[] };
type ChatRequest = { messages: ChatMessage[]; tools?: Array<{ function: { name: string } }>; stream?: boolean };
const text = (content: unknown) =>
typeof content === "string"
? content
: Array.isArray(content)
? content.map((part) => (part as { text?: string }).text ?? "").join("")
: "";
function chunk(response: ServerResponse, delta: Record<string, unknown>, finish: string | null = null) {
const body = {
id: "chatcmpl-stub",
object: "chat.completion.chunk",
created: 0,
model: "stub",
choices: [{ index: 0, delta, finish_reason: finish }],
};
response.write(`data: ${JSON.stringify(body)}\n\n`);
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function streamText(response: ServerResponse, words: string[], delayMs = 0) {
chunk(response, { role: "assistant", content: "" });
for (const word of words) {
if (response.destroyed) return;
chunk(response, { content: word });
if (delayMs) await wait(delayMs);
}
chunk(response, {}, "stop");
response.end("data: [DONE]\n\n");
}
function streamToolCall(response: ServerResponse, name: string, args: unknown) {
chunk(response, {
role: "assistant",
content: null,
tool_calls: [
{
index: 0,
id: `call_${name}_${Date.now()}`,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
],
});
chunk(response, {}, "tool_calls");
response.end("data: [DONE]\n\n");
}
function reply(request: ChatRequest, response: ServerResponse) {
const messages = request.messages;
const tools = new Set((request.tools ?? []).map((tool) => tool.function.name));
const lastUser = [...messages].reverse().find((message) => message.role === "user");
const said = text(lastUser?.content).toLowerCase();
const sinceUser = messages.slice(messages.lastIndexOf(lastUser as ChatMessage) + 1);
const lastTool = [...sinceUser].reverse().find((message) => message.role === "tool");
const readTool = tools.has("read_letter") ? "read_letter" : "read_resume";
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
if (said.includes("slowly"))
return streamText(
response,
Array.from({ length: 60 }, (_, index) => `word${index} `),
150,
);
if (!tools.has("propose_edits")) return streamText(response, ["You ", "left ", "the ", "document ", "out."]);
const asked = sinceUser.some((message) =>
(message.tool_calls as Array<{ function?: { name?: string } }> | undefined)?.some(
(call) => call.function?.name === "ask_user_question",
),
);
if (said.includes("question") && !asked)
return streamToolCall(response, "ask_user_question", {
question: "The posting mentions accessibility. Have you done accessibility work?",
choices: ["Yes, I have", "No, skip it"],
});
// The tool that produced the last result: read first, then propose, then say what changed.
const calls = sinceUser.flatMap(
(message) => (message.tool_calls as Array<{ id: string; function: { name: string } }> | undefined) ?? [],
);
const lastToolName = calls.find((call) => call.id === lastTool?.tool_call_id)?.function.name;
if (!lastTool || lastToolName === "ask_user_question") return streamToolCall(response, readTool, {});
let result: { data?: { passages?: Array<{ id: string; text: string }> } } = {};
try {
result = JSON.parse(text(lastTool.content) || "{}");
} catch {
// Not a read result.
}
const passage =
lastToolName === readTool ? result.data?.passages?.find((item) => item.text && item.text !== "(empty)") : undefined;
if (passage)
return streamToolCall(response, "propose_edits", {
title: "Tighten the first passage",
edits: [
{ passageId: passage.id, text: `${passage.text} (tightened by the stub)`, why: "Leads with the outcome." },
],
});
return streamText(response, [
"I ",
"tightened ",
"one ",
"passage. ",
"Accept ",
"it ",
"if ",
"it ",
"reads ",
"right.",
]);
}
function handle(request: IncomingMessage, response: ServerResponse) {
let body = "";
request.on("data", (data) => {
body += data;
});
request.on("end", () => {
if (!request.url?.endsWith("/chat/completions")) {
response.writeHead(404).end();
return;
}
const parsed = JSON.parse(body || "{}") as ChatRequest;
// The connection test asks for a single character, without streaming; Improve asks for one line as JSON.
if (!parsed.stream) {
const improving = text(parsed.messages[0]?.content).includes("You improve one line");
const line = text(parsed.messages.at(-1)?.content).match(/<<<LINE_START>>>\n([\s\S]*?)\n<<<LINE_END>>>/)?.[1];
const content = improving
? JSON.stringify({
text: `Led ${line ?? ""}`.trim(),
why: "Uses a verb that shows ownership.",
addsFacts: false,
})
: "1";
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
id: "chatcmpl-stub",
object: "chat.completion",
created: 0,
model: "stub",
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
);
return;
}
void reply(parsed, response);
});
}
export async function startAiStub(port = 0) {
const server = createServer(handle);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
const { port: bound } = server.address() as AddressInfo;
return {
baseURL: `http://127.0.0.1:${bound}/v1`,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}