Files
coreci-chat/packages/llm-mock/tests/server.test.ts
T
CIAgent 0c15d3d0b2 docs(milestone): complete M2 — MCP Layer & Day 1 Adapters (v0.2)
M2 delivers the read-only MCP capability broker gateway and four Day-1
infrastructure adapters (Proxmox, SSH/Linux, GitHub, Gitea). 13 REQs (015-027)
all pass. 656 tests green. M1 non-regression verified.

MCP spec 2025-06-18 conformance verified (PROTOCOL.md + 7 tests).
Defense-in-depth SSH (broker layer 1 + Relay Agent layer 2 + no-shell exec).
Two-track LLM smoke (Track A mock-path P0 gate passes).
CI: Gitea Actions (.gitea/workflows/ci.yml) with Postgres 16 + RLS verification.

Phases shipped:
  P0  pre-execution          v0.1.0
  P1  Wave F — MCP gateway   v0.1.1
  P2  Wave G — Proxmox       v0.1.2
  P3  Wave H — SSH/Linux     v0.1.3
  P4  Wave I — Git adapters   v0.1.4
  P5  Wave J — SSE+smoke+UI  v0.1.5
  P6  Final — review+ship    v0.1.6 ← milestone release

---ci---
phase: 6
milestone: v0.2
status: complete
phase_role: final
milestone_complete: true
requirements:
  covered: [REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027]
  partial: []
---/ci---
2026-08-25 06:14:21 +00:00

177 lines
7.4 KiB
TypeScript

/**
* @coreci/llm-mock/tests/server.test.ts — OpenAI-compatible /v1/chat/completions
* endpoint + the 7-step LLM smoke flow (G-018).
*
* Asserts:
* - Step 1 (tool-call mode): prompt "List my GitHub repositories." →
* tool_calls:[{function:{name:"github.list_repos", arguments:"{}"}}],
* finish_reason:"tool_calls".
* - Step 6 (synthesis mode): full history with tool message → grounded
* "Your repos are: coreci-test-repo-1, coreci-test-repo-2",
* finish_reason:"stop".
* - Determinism: same request → same response (no randomness).
* - The HTTP server responds to /healthz and /v1/chat/completions.
*/
import { describe, it, expect, afterAll, beforeAll } from "vitest";
import { handleChatCompletion, startMockServer, stopMockServer } from "../src/server.js";
import type { Server } from "node:http";
import type { ChatMessage, ToolCall } from "../src/patterns.js";
describe("llm-mock server — handleChatCompletion (the 7-step flow)", () => {
describe("Step 1: tool-call mode (prompt → tool_calls)", () => {
const prompts = [
"List my GitHub repositories.",
"Show me my GitHub repositories",
"Get repositories",
"List my repos",
];
for (const prompt of prompts) {
it(`returns github.list_repos tool_call for "${prompt}"`, () => {
const messages: ChatMessage[] = [{ role: "user", content: prompt }];
const res = handleChatCompletion({ model: "m", messages, tools: [] });
expect(res.choices).toHaveLength(1);
const choice = res.choices[0];
expect(choice.finish_reason).toBe("tool_calls");
expect(choice.message.role).toBe("assistant");
expect(choice.message.content).toBeNull();
expect(choice.message.tool_calls).toBeDefined();
expect(choice.message.tool_calls).toHaveLength(1);
const tc: ToolCall = choice.message.tool_calls![0];
expect(tc.function.name).toBe("github.list_repos");
expect(tc.function.arguments).toBe("{}");
expect(tc.type).toBe("function");
});
}
});
describe("Step 6: synthesis mode (tool message → grounded response)", () => {
it("synthesizes repo names from a raw github-mock tool message", () => {
const toolContent = JSON.stringify([
{ name: "coreci-test-repo-1" },
{ name: "coreci-test-repo-2" },
]);
const messages: ChatMessage[] = [
{ role: "user", content: "List my GitHub repositories." },
{
role: "assistant",
content: null,
tool_calls: [{ id: "call_list_repos_1", type: "function", function: { name: "github.list_repos", arguments: "{}" } }],
},
{ role: "tool", tool_call_id: "call_list_repos_1", content: toolContent },
];
const res = handleChatCompletion({ model: "m", messages });
expect(res.choices[0].finish_reason).toBe("stop");
expect(res.choices[0].message.content).toBe("Your repos are: coreci-test-repo-1, coreci-test-repo-2");
expect(res.choices[0].message.tool_calls).toBeUndefined();
});
it("synthesizes from the normalized {repos:[...]} shape", () => {
const toolContent = JSON.stringify({ repos: [{ name: "a" }, { name: "b" }] });
const messages: ChatMessage[] = [
{ role: "user", content: "List my repos" },
{ role: "tool", tool_call_id: "c1", content: toolContent },
];
const res = handleChatCompletion({ model: "m", messages });
expect(res.choices[0].message.content).toBe("Your repos are: a, b");
});
});
describe("determinism", () => {
it("returns the SAME response id + model on every call", () => {
const messages: ChatMessage[] = [{ role: "user", content: "List my repos" }];
const a = handleChatCompletion({ model: "m", messages });
const b = handleChatCompletion({ model: "m", messages });
expect(a.id).toBe(b.id);
expect(a.model).toBe(b.model);
expect(a.choices).toEqual(b.choices);
});
});
describe("fallback (no pattern matched)", () => {
it("returns a benign fallback message, never an error", () => {
const messages: ChatMessage[] = [{ role: "user", content: "hello world" }];
const res = handleChatCompletion({ model: "m", messages });
expect(res.choices[0].finish_reason).toBe("stop");
expect(res.choices[0].message.content).toContain("mock LLM");
});
});
describe("OpenAI-compatible response shape", () => {
it("has object, created, model, choices[], usage", () => {
const messages: ChatMessage[] = [{ role: "user", content: "List my repos" }];
const res = handleChatCompletion({ model: "m", messages });
expect(res.object).toBe("chat.completion");
expect(typeof res.created).toBe("number");
expect(typeof res.model).toBe("string");
expect(Array.isArray(res.choices)).toBe(true);
expect(res.usage).toHaveProperty("total_tokens");
});
});
});
describe("llm-mock server — HTTP endpoints", () => {
let server: Server;
let port: number;
beforeAll(async () => {
server = await startMockServer(0); // 0 = OS-assigned port
const addr = server.address();
if (addr && typeof addr === "object") port = addr.port;
});
afterAll(async () => {
await stopMockServer(server);
});
it("GET /healthz returns {ok:true}", async () => {
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
it("POST /v1/chat/completions returns tool_calls for list_repos prompt", async () => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "m",
messages: [{ role: "user", content: "List my GitHub repositories." }],
tools: [{ type: "function", function: { name: "github.list_repos", parameters: {} } }],
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { choices: { message: { tool_calls?: ToolCall[]; finish_reason?: string } }[] };
expect(body.choices[0].finish_reason).toBe("tool_calls");
expect(body.choices[0].message.tool_calls![0].function.name).toBe("github.list_repos");
});
it("POST /v1/chat/completions synthesizes grounded response in synthesis mode", async () => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "m",
messages: [
{ role: "user", content: "List my repos" },
{ role: "tool", tool_call_id: "c1", content: '[{"name":"coreci-test-repo-1"},{"name":"coreci-test-repo-2"}]' },
],
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { choices: { message: { content: string }; finish_reason: string }[] };
expect(body.choices[0].finish_reason).toBe("stop");
expect(body.choices[0].message.content).toBe("Your repos are: coreci-test-repo-1, coreci-test-repo-2");
});
it("GET unknown path returns 404", async () => {
const res = await fetch(`http://127.0.0.1:${port}/nope`);
expect(res.status).toBe(404);
});
it("OPTIONS returns 204 (CORS preflight)", async () => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: "OPTIONS" });
expect(res.status).toBe(204);
});
});