0c15d3d0b2
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---
177 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
}); |