297 lines
8.6 KiB
TypeScript
297 lines
8.6 KiB
TypeScript
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<unknown>) => 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<Parameters<typeof runAssistantToolLoop>[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();
|
|
});
|
|
});
|