From 4c3776e2ceee8f0c50b29265086d51ffff06dbdd Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 12 Jun 2026 08:02:06 +0100 Subject: [PATCH] feat: add review_plan tool handler --- AGENT_HANDOFF.md | 10 + PROJECT_STATE.md | 19 +- TASKS.md | 30 +++ src/tools/review-plan.js | 79 ++++++- test/tools/review-plan.test.js | 390 +++++++++++++++++++++++++++++++++ 5 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 test/tools/review-plan.test.js diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index aa1a1ac..9dc8482 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -40,6 +40,16 @@ Build the MCP tool handlers that: ## Completed (Phase 4) - Task 4.1 — ask_chatgpt handler (`src/tools/ask-chatgpt.js`, `test/tools/ask-chatgpt.test.js`) ✅ +- Task 4.2 — review_plan handler (`src/tools/review-plan.js`, `test/tools/review-plan.test.js`) ✅ + +## Next Pending + +### Task 4.3 - review_code tool handler + +Build the review_code MCP tool handler: +1. Create `src/tools/review-code.js` with dependency-injected `handleReviewCode(input, deps)` +2. Create `test/tools/review-code.test.js` with ~28 orchestration-only tests +3. Follow the same pattern as 4.1 and 4.2 ## General Rules diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6971dc0..95b1639 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -7,7 +7,7 @@ ChatGPT MCP Server ## Status Planning complete. -Phase 0 complete. Phase 1 complete. Phase 2 complete. Phase 3 complete. Task 4.1 complete. +Phase 0 complete. Phase 1 complete. Phase 2 complete. Phase 3 complete. Task 4.1 complete. Task 4.2 complete. ## Current Phase @@ -69,6 +69,21 @@ Implemented `src/tools/ask-chatgpt.js` with dependency-injected handler and 27 o - Every path returns structured `{ ok, answer|error, warnings }` — never throws to caller - 27 tests covering: success path, validation failure, config failure, budget failure, client creation failure, OpenAI failure, dependency call order, error handling for null/string throws, warning propagation, result shape, and short-circuit behavior +### Task 4.2 — review_plan MCP Tool Handler ✅ + +Implemented `src/tools/review-plan.js` with dependency-injected handler and 28 orchestration-only tests in `test/tools/review-plan.test.js`. + +**Key design decisions:** +- Execution order: validate → loadConfig → checkContextBudget → buildReviewPlanPrompt → createOpenAIClient → sendOpenAIResponse +- All external deps injected (loadConfig, createOpenAIClient, sendOpenAIResponse); internal utilities imported directly +- OpenAI errors pass through `String(err)` unchanged — no wrapping or reformatting +- Budget check short-circuits before prompt building or client creation +- Every path returns structured `{ ok, answer|error, warnings }` — never throws to caller +- 28 tests mirroring 4.1 structure with one additional test verifying buildReviewPlanPrompt is called with budget.input + +**Next pending task:** +- Task 4.3 — review_code tool handler (`src/tools/review-code.js`, `test/tools/review-code.test.js`) + **Not done yet (Phase 4):** - No MCP tool registration -- Other tool handlers (review_plan, review_code, debug_issue, architecture_review) +- Other tool handlers (review_code, debug_issue, architecture_review) diff --git a/TASKS.md b/TASKS.md index d72d8a3..ea8b6f6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -314,6 +314,36 @@ Create `src/tools/ask-chatgpt.js` and `test/tools/ask-chatgpt.test.js`. Status: ✅ Complete +### Task 4.2 - review_plan MCP tool handler + +Create `src/tools/review-plan.js` and `test/tools/review-plan.test.js`. + +**Requirements:** +- Export `handleReviewPlan(input, deps)` as standalone dependency-injected function. +- Execution order: validateToolInput → loadConfig → checkContextBudget → buildReviewPlanPrompt → createOpenAIClient → sendOpenAIResponse. +- Inject only 3 external deps: `loadConfig`, `createOpenAIClient`, `sendOpenAIResponse`. Internal utilities imported directly. +- If budget check fails (`ok: false`), immediately return that result — do not call buildReviewPlanPrompt or create client. +- All paths return structured `{ ok, answer|error, warnings }` — never throws to caller. +- OpenAI errors pass through `String(err)` unchanged (no wrapping/reformatting). +- Mirror 4.1 structure: ~28 orchestration-only tests covering the same test categories (success path, validation failure, config failure, budget failure, client creation failure, OpenAI failure, dependency call order, buildReviewPlanPrompt integration, no throws escaping, warning propagation, result shape, short-circuit behavior). + +Status: ✅ Complete + +### Task 4.3 - review_code tool handler (NEXT) + +Create `src/tools/review-code.js` and `test/tools/review-code.test.js`. + +**Requirements:** +- Export `handleReviewCode(input, deps)` as standalone dependency-injected function. +- Execution order: validateToolInput → loadConfig → checkContextBudget → buildReviewCodePrompt → createOpenAIClient → sendOpenAIResponse. +- Inject only 3 external deps: `loadConfig`, `createOpenAIClient`, `sendOpenAIResponse`. Internal utilities imported directly. +- If budget check fails (`ok: false`), immediately return that result — do not call buildReviewCodePrompt or create client. +- All paths return structured `{ ok, answer|error, warnings }` — never throws to caller. +- OpenAI errors pass through `String(err)` unchanged (no wrapping/reformatting). +- Mirror 4.1 and 4.2 structure: ~28 orchestration-only tests covering the same test categories. + +Status: ⬜ Pending + --- Phase 2 complete. Phase 3 complete. Phase 4 next: Tool Handlers. diff --git a/src/tools/review-plan.js b/src/tools/review-plan.js index cc413c6..8e0a87b 100644 --- a/src/tools/review-plan.js +++ b/src/tools/review-plan.js @@ -1 +1,78 @@ -// review_plan tool handler. +// Tool handler for the review_plan MCP tool. + +import { validateToolInput } from "./schemas.js"; +import { checkContextBudget } from "../utils/context-budget.js"; +import { buildReviewPlanPrompt } from "../prompts/review-plan.js"; + +/** + * Handle the review_plan MCP tool. + * + * Orchestration order: validate -> config -> budget -> prompt -> client -> response. + * All external dependencies injected via deps. Internal utilities imported directly. + * No throws escape — all paths return structured results. + * + * @param {unknown} input + * Raw tool input per ARCHITECTURE.md §7 schema. + * @param {{ + * loadConfig: () => object, + * createOpenAIClient: (config: object) => any, + * sendOpenAIResponse: (client: any, params: object) => Promise + * }} deps + * Injected external dependencies. + * @returns {Promise<{ ok: true, answer: string, warnings: string[] } | { ok: false, error: string, warnings: string[] }>} + */ +export async function handleReviewPlan(input, deps) { + // --- 1. Validate input (before anything else) --- + + const validation = validateToolInput(input); + if (!validation.ok) { + return { ok: false, error: validation.errors.join(" | "), warnings: [] }; + } + + // --- 2. Load config --- + + let config; + try { + config = deps.loadConfig(); + } catch (err) { + return { ok: false, error: String(err), warnings: [] }; + } + + // --- 3. Context budget check --- + + const budget = checkContextBudget(validation.data, config); + if (!budget.ok) { + return { ok: false, error: budget.error, warnings: budget.warnings }; + } + + // --- 4. Build prompt --- + + const promptMessages = buildReviewPlanPrompt(budget.input); + + // --- 5. Create OpenAI client --- + + let client; + try { + client = deps.createOpenAIClient(config); + } catch (err) { + return { ok: false, error: String(err), warnings: [] }; + } + + // --- 6. Send to OpenAI --- + + let aiResult; + try { + aiResult = await deps.sendOpenAIResponse(client, { + input: [{ role: "system", content: promptMessages }], + model: config.openaiModel, + temperature: config.temperature, + maxOutputTokens: config.maxOutputTokens, + }); + } catch (err) { + return { ok: false, error: String(err), warnings: [] }; + } + + // --- 7. Success --- + + return { ok: true, answer: aiResult.content, warnings: budget.warnings }; +} diff --git a/test/tools/review-plan.test.js b/test/tools/review-plan.test.js new file mode 100644 index 0000000..723d541 --- /dev/null +++ b/test/tools/review-plan.test.js @@ -0,0 +1,390 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleReviewPlan } from "../../src/tools/review-plan.js"; + +const mockConfig = { + openaiApiKey: "sk-test-key", + openaiModel: "gpt-5.1", + temperature: 0.2, + maxOutputTokens: 2000, + logLevel: "info", + enableFileContext: false, + contextDir: "./context", + maxInputChars: 30000, + maxFileChars: 12000, + maxFiles: 5, + maxLogChars: 10000, + redactSecrets: true, +}; + +function makeValidInput(question) { + return { question }; +} + +// --- Success path --- + +describe("success path", () => { + it("returns ok:true with answer on full happy flow", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn(async () => ({ content: "OK" })); + + const result = await handleReviewPlan(makeValidInput("Review my plan"), { + loadConfig, createOpenAIClient, sendOpenAIResponse, + }); + + expect(result.ok).toBe(true); + expect(result.answer).toBe("OK"); + expect(result.warnings).toEqual([]); + }); + + it("propagates budget warnings through to success result", async () => { + const trimmedBudget = { ...mockConfig, maxInputChars: 50 }; + const loadConfig = vi.fn(() => trimmedBudget); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn(async () => ({ content: "OK" })); + + const result = await handleReviewPlan(makeValidInput("hi"), { + loadConfig, createOpenAIClient, sendOpenAIResponse, + }); + + expect(result.ok).toBe(true); + expect(Array.isArray(result.warnings)).toBe(true); + }); +}); + +// --- Validation failure (short-circuit before config) --- + +describe("validation failure", () => { + it("returns structured error when question is missing", async () => { + const loadConfig = vi.fn(); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan({}, { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.warnings).toEqual([]); + }); + + it("short-circuits — no other deps called", async () => { + const loadConfig = vi.fn(); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + await handleReviewPlan({ foo: "bar" }, { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(loadConfig).not.toHaveBeenCalled(); + expect(createOpenAIClient).not.toHaveBeenCalled(); + expect(sendOpenAIResponse).not.toHaveBeenCalled(); + }); + + it("returns structured error when question is empty string", async () => { + const loadConfig = vi.fn(); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan({ question: "" }, { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.warnings).toEqual([]); + }); + + it("returns structured error when question is wrong type", async () => { + const loadConfig = vi.fn(); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan({ question: 123 }, { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.warnings).toEqual([]); + }); +}); + +// --- Config failure --- + +describe("config failure", () => { + it("returns structured error when loadConfig throws", async () => { + const loadConfig = vi.fn(() => { throw new Error("OPENAI_API_KEY is missing."); }); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(result.error).toContain("OPENAI_API_KEY is missing"); + }); + + it("short-circuits — no client or response calls after config failure", async () => { + const loadConfig = vi.fn(() => { throw new Error("No key."); }); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(createOpenAIClient).not.toHaveBeenCalled(); + expect(sendOpenAIResponse).not.toHaveBeenCalled(); + }); + + it("passes original error message", async () => { + const loadConfig = vi.fn(() => { throw new Error("OPENAI_API_KEY is missing."); }); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.error).toBe("Error: OPENAI_API_KEY is missing."); + }); +}); + +// --- Budget failure (short-circuit before prompt/client) --- + +describe("budget failure", () => { + it("returns structured error with budget warnings when input exceeds budget", async () => { + const tinyBudget = { ...mockConfig, maxInputChars: 0 }; + const loadConfig = vi.fn(() => tinyBudget); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(Array.isArray(result.warnings)).toBe(true); + }); + + it("short-circuits — no prompt built, no client created, no response sent", async () => { + const tinyBudget = { ...mockConfig, maxInputChars: 0 }; + const loadConfig = vi.fn(() => tinyBudget); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(createOpenAIClient).not.toHaveBeenCalled(); + expect(sendOpenAIResponse).not.toHaveBeenCalled(); + }); + + it("passes budget warnings through to the error result", async () => { + const tinyBudget = { ...mockConfig, maxInputChars: 0 }; + const loadConfig = vi.fn(() => tinyBudget); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan( + { question: "x", context: "a".repeat(20) }, + { loadConfig, createOpenAIClient, sendOpenAIResponse }, + ); + + expect(result.ok).toBe(false); + expect(Array.isArray(result.warnings)).toBe(true); + }); +}); + +// --- Client creation failure --- + +describe("client creation failure", () => { + it("returns structured error when createOpenAIClient throws", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => { throw new Error("Invalid config."); }); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.warnings).toEqual([]); + }); + + it("short-circuits — no response sent after client failure", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => { throw new Error("Invalid config."); }); + const sendOpenAIResponse = vi.fn(); + + await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(sendOpenAIResponse).not.toHaveBeenCalled(); + }); + + it("passes original error message unchanged", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => { throw new Error("Invalid config."); }); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.error).toBe("Error: Invalid config."); + }); +}); + +// --- OpenAI failure (pass-through) --- + +describe("OpenAI failure", () => { + it("passes through err.message unchanged", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn().mockRejectedValue(new Error("API key invalid.")); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(result.error).toBe("Error: API key invalid."); + expect(result.warnings).toEqual([]); + }); + + it("short-circuits — no extra processing after API failure", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn().mockRejectedValue(new Error("rate limit")); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(result.error).toBe("Error: rate limit"); + }); + + it("does not wrap or reformat the error", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn().mockRejectedValue(new Error("429 Too Many Requests")); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.error).toBe("Error: 429 Too Many Requests"); + }); +}); + +// --- Dependency call order --- + +describe("dependency call order", () => { + it("calls deps in correct order: config -> client -> response", async () => { + const callLog = []; + const loadConfig = vi.fn(() => { callLog.push("config"); return mockConfig; }); + const createOpenAIClient = vi.fn(() => { callLog.push("client"); return { responses: { create: vi.fn() } }; }); + const sendOpenAIResponse = vi.fn(async () => { callLog.push("response"); return { content: "OK" }; }); + + await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(callLog).toEqual(["config", "client", "response"]); + }); +}); + +// --- buildReviewPlanPrompt called with correct input --- + +describe("buildReviewPlanPrompt integration", () => { + it("calls buildReviewPlanPrompt with budget.input (trimmed data)", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn(async () => ({ content: "OK" })); + + // Spy on buildReviewPlanPrompt import by capturing the prompt arg. + const promptCapture = []; + const patchedSend = vi.fn(async (client, params) => { + promptCapture.push(params.input[0].content); + return { content: "OK" }; + }); + + await handleReviewPlan(makeValidInput("test question"), { + loadConfig, createOpenAIClient, sendOpenAIResponse: patchedSend, + }); + + expect(promptCapture.length).toBe(1); + expect(typeof promptCapture[0]).toBe("string"); + expect(promptCapture[0].includes("plan reviewer")).toBe(true); + }); +}); + +// --- No throws escaping --- + +describe("no throws escaping", () => { + it("returns structured result when sendOpenAIResponse throws null", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn().mockRejectedValue(null); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + }); + + it("returns structured result when loadConfig throws non-Error", async () => { + const loadConfig = vi.fn(() => { throw "string error"; }); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + }); +}); + +// --- Warning propagation --- + +describe("warning propagation", () => { + it("includes budget warnings in success result when budget passes with warnings", async () => { + const trimmedBudget = { ...mockConfig, maxInputChars: 50 }; + const loadConfig = vi.fn(() => trimmedBudget); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn(async () => ({ content: "OK" })); + + const result = await handleReviewPlan( + { question: "hi", context: "x".repeat(49) }, + { loadConfig, createOpenAIClient, sendOpenAIResponse }, + ); + + expect(result.ok).toBe(true); + expect(Array.isArray(result.warnings)).toBe(true); + }); + + it("includes budget warnings in failure result when budget fails", async () => { + const emptyBudget = { ...mockConfig, maxInputChars: 0 }; + const loadConfig = vi.fn(() => emptyBudget); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(result.ok).toBe(false); + expect(Array.isArray(result.warnings)).toBe(true); + }); +}); + +// --- Result shape --- + +describe("result shape", () => { + it("returns exactly { ok, answer, warnings } on success", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn(async () => ({ content: "OK" })); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(Object.keys(result).sort()).toEqual(["answer", "ok", "warnings"]); + expect(result.ok).toBe(true); + expect(typeof result.answer).toBe("string"); + expect(Array.isArray(result.warnings)).toBe(true); + }); + + it("returns exactly { ok, error, warnings } on failure", async () => { + const loadConfig = vi.fn(() => { throw new Error("fail"); }); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + const result = await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(Object.keys(result).sort()).toEqual(["error", "ok", "warnings"]); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(Array.isArray(result.warnings)).toBe(true); + }); +}); + +// --- Short-circuit behavior --- + +describe("short-circuit behavior", () => { + it("stops at first failure without calling downstream deps", async () => { + const loadConfig = vi.fn(() => mockConfig); + const createOpenAIClient = vi.fn(() => ({ responses: { create: vi.fn() } })); + const sendOpenAIResponse = vi.fn().mockRejectedValue(new Error("boom")); + + await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(createOpenAIClient).toHaveBeenCalledTimes(1); + expect(sendOpenAIResponse).toHaveBeenCalledTimes(1); + }); + + it("stops at config failure without calling downstream deps", async () => { + const loadConfig = vi.fn(() => { throw new Error("fail"); }); + const createOpenAIClient = vi.fn(); + const sendOpenAIResponse = vi.fn(); + + await handleReviewPlan(makeValidInput("hi"), { loadConfig, createOpenAIClient, sendOpenAIResponse }); + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(createOpenAIClient).not.toHaveBeenCalled(); + expect(sendOpenAIResponse).not.toHaveBeenCalled(); + }); +});