Files
Nexus/packages/api/src/__tests__/assistant-chat-loop.test.ts
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

341 lines
9.4 KiB
TypeScript

import { DEFAULT_OPENAI_MODEL } from "@nexus/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();
});
});