import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../ai-client.js", () => ({ loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn: () => Promise) => fn()), parseAiError: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error)), })); vi.mock("../lib/audit.js", () => ({ createAuditEntry: vi.fn().mockResolvedValue(undefined), })); vi.mock("../lib/content-filter.js", () => ({ checkAiOutput: vi.fn((content: string) => ({ clean: true, redacted: content })), })); vi.mock("../lib/logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); vi.mock("../router/assistant-approvals.js", () => { class AssistantApprovalStorageUnavailableError extends Error {} return { AssistantApprovalStorageUnavailableError, createPendingAssistantApproval: vi.fn(), toApprovalPayload: vi.fn((approval: { id: string; toolName: string; summary: string }, status: string) => ({ id: approval.id, toolName: approval.toolName, summary: approval.summary, status, })), }; }); vi.mock("../router/assistant-insights.js", () => ({ buildAssistantInsight: vi.fn(), })); vi.mock("../router/assistant-tools.js", () => ({ MUTATION_TOOLS: new Set(["create_project"]), executeTool: vi.fn(), })); import { createAuditEntry } from "../lib/audit.js"; import { logger } from "../lib/logger.js"; import { checkAiOutput } from "../lib/content-filter.js"; import { AssistantApprovalStorageUnavailableError, createPendingAssistantApproval, } from "../router/assistant-approvals.js"; import { ASSISTANT_CONFIRMATION_PREFIX } from "../router/assistant-confirmation.js"; import { buildAssistantInsight } from "../router/assistant-insights.js"; import { runAssistantToolLoop } from "../router/assistant-chat-loop.js"; import { executeTool } from "../router/assistant-tools.js"; function createClient(...responses: unknown[]) { return { chat: { completions: { create: vi.fn() .mockImplementation(async () => { const next = responses.shift(); if (!next) { throw new Error("No mock AI response configured"); } return next; }), }, }, }; } function createLoopInput(overrides: Partial[0]> = {}) { return { db: {} as never, dbUserId: "user_1", client: createClient({ choices: [{ message: { content: "ok" } }], }), provider: "openai", model: DEFAULT_OPENAI_MODEL, maxTokens: 2000, temperature: 0.4, openaiMessages: [{ role: "system", content: "system" }], availableTools: [], toolCtx: { db: {} as never, userId: "user_1", userRole: "USER", permissions: new Set(), session: null, dbUser: null, roleDefaults: null, }, userId: "user_1", conversationId: "conv_1", collectedActions: [], collectedInsights: [], maxToolIterations: 8, ...overrides, }; } describe("assistant chat loop", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(checkAiOutput).mockImplementation((content: string) => ({ clean: true, redacted: content })); vi.mocked(buildAssistantInsight).mockReturnValue(undefined); }); it("returns a confirmation response instead of executing mutation tools immediately", async () => { vi.mocked(createPendingAssistantApproval).mockResolvedValue({ id: "approval_1", toolName: "create_project", summary: "create project (name=Apollo)", } as never); const result = await runAssistantToolLoop(createLoopInput({ client: createClient({ choices: [{ message: { tool_calls: [{ id: "call_1", function: { name: "create_project", arguments: "{\"name\":\"Apollo\"}", }, }], }, }], }), })); expect(result).toMatchObject({ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.`, approval: { id: "approval_1", toolName: "create_project", status: "pending", }, }); expect(executeTool).not.toHaveBeenCalled(); expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ entityName: "create_project", summary: "AI tool blocked pending confirmation: create_project", })); }); it("continues after read-only tool calls and returns collected actions and insights", async () => { vi.mocked(executeTool).mockResolvedValue({ content: "{\"resources\":1}", data: { resources: 1 }, action: { type: "navigate", href: "/resources" }, } as never); vi.mocked(buildAssistantInsight).mockReturnValue({ kind: "holiday_region", title: "Berlin", subtitle: "Resolved public holiday set", metrics: [{ label: "Resolved holidays", value: "1" }], }); const result = await runAssistantToolLoop(createLoopInput({ client: createClient( { choices: [{ message: { tool_calls: [{ id: "call_1", function: { name: "search_resources", arguments: "{\"query\":\"Alice\"}", }, }], }, }], }, { choices: [{ message: { content: "Hier ist die passende Resource." } }], }, ), })); expect(result).toMatchObject({ content: "Hier ist die passende Resource.", actions: [{ type: "navigate", href: "/resources" }], insights: [{ kind: "holiday_region", title: "Berlin", }], }); expect(executeTool).toHaveBeenCalledWith( "search_resources", "{\"query\":\"Alice\"}", expect.objectContaining({ userId: "user_1" }), ); expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ entityName: "search_resources", summary: "AI executed tool: search_resources", })); }); it("redacts unsafe AI output before returning it", async () => { vi.mocked(checkAiOutput).mockReturnValue({ clean: false, redacted: "[redacted]", }); const result = await runAssistantToolLoop(createLoopInput({ client: createClient({ choices: [{ message: { content: "API key is sk-secret" } }], }), })); expect(result.content).toBe("[redacted]"); expect(logger.warn).toHaveBeenCalledWith( { userId: "user_1" }, "AI output contained sensitive content — redacted before delivery", ); expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ entityName: "AiOutputRedacted", })); }); it("returns a stable fallback after too many tool-call iterations", async () => { vi.mocked(executeTool).mockResolvedValue({ content: "{\"ok\":true}", data: { ok: true }, } as never); const result = await runAssistantToolLoop(createLoopInput({ client: createClient( { choices: [{ message: { tool_calls: [{ id: "call_1", function: { name: "search_resources", arguments: "{\"query\":\"A\"}", }, }], }, }], }, { choices: [{ message: { tool_calls: [{ id: "call_2", function: { name: "search_resources", arguments: "{\"query\":\"B\"}", }, }], }, }], }, ), maxToolIterations: 2, })); expect(result.content).toBe("I had to stop after too many tool calls. Please try a simpler question."); }); it("degrades mutation confirmations when approval storage is unavailable", async () => { vi.mocked(createPendingAssistantApproval).mockRejectedValue( new AssistantApprovalStorageUnavailableError("missing table"), ); const result = await runAssistantToolLoop(createLoopInput({ client: createClient({ choices: [{ message: { tool_calls: [{ id: "call_1", function: { name: "create_project", arguments: "{\"name\":\"Apollo\"}", }, }], }, }], }), })); expect(result.content).toContain("Schreibende Assistant-Aktionen sind gerade nicht verfuegbar"); expect(executeTool).not.toHaveBeenCalled(); }); });