refactor(api): extract assistant chat orchestration

This commit is contained in:
2026-03-31 13:15:44 +02:00
parent 002114fcb1
commit 4bea9ddd14
4 changed files with 804 additions and 178 deletions
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../router/assistant-approvals.js", () => ({
clearPendingAssistantApproval: vi.fn().mockResolvedValue(undefined),
consumePendingAssistantApproval: 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-confirmation.js", () => ({
canExecuteMutationTool: vi.fn(),
isCancellationReply: vi.fn(),
parseToolArguments: vi.fn(() => ({ name: "Apollo" })),
}));
vi.mock("../router/assistant-insights.js", () => ({
buildAssistantInsight: vi.fn(),
}));
vi.mock("../router/assistant-tool-results.js", () => ({
readToolError: vi.fn(),
readToolSuccessMessage: vi.fn(),
}));
vi.mock("../router/assistant-tools.js", () => ({
executeTool: vi.fn(),
}));
import { createAuditEntry } from "../lib/audit.js";
import {
clearPendingAssistantApproval,
consumePendingAssistantApproval,
} from "../router/assistant-approvals.js";
import {
canExecuteMutationTool,
isCancellationReply,
} from "../router/assistant-confirmation.js";
import { buildAssistantInsight } from "../router/assistant-insights.js";
import { handlePendingAssistantApproval } from "../router/assistant-chat-response.js";
import {
readToolError,
readToolSuccessMessage,
} from "../router/assistant-tool-results.js";
import { executeTool } from "../router/assistant-tools.js";
function createPendingApproval() {
return {
id: "approval_1",
userId: "user_1",
conversationId: "conv_1",
toolName: "create_project",
toolArguments: "{\"name\":\"Apollo\"}",
summary: "create project (name=Apollo)",
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
};
}
function createHandleInput(overrides: Partial<Parameters<typeof handlePendingAssistantApproval>[0]> = {}) {
return {
db: {} as never,
dbUserId: "user_1",
toolCtx: {
db: {} as never,
userId: "user_1",
userRole: "USER",
permissions: new Set(),
session: null,
dbUser: null,
roleDefaults: null,
},
conversationId: "conv_1",
pendingApproval: createPendingApproval(),
lastUserMessage: { role: "user" as const, content: "ja" },
messages: [
{ role: "assistant" as const, content: "__CAPAKRAKEN_CONFIRM__ create project (name=Apollo). Bitte bestätigen." },
{ role: "user" as const, content: "ja" },
],
collectedActions: [],
collectedInsights: [],
...overrides,
};
}
describe("assistant pending approval handling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isCancellationReply).mockReturnValue(false);
vi.mocked(canExecuteMutationTool).mockReturnValue(true);
vi.mocked(readToolError).mockReturnValue(null);
vi.mocked(readToolSuccessMessage).mockReturnValue(null);
vi.mocked(buildAssistantInsight).mockReturnValue(undefined);
});
it("cancels pending approvals when the user aborts", async () => {
vi.mocked(isCancellationReply).mockReturnValue(true);
const result = await handlePendingAssistantApproval(createHandleInput({
lastUserMessage: { role: "user", content: "nein, abbrechen" },
}));
expect(result).toMatchObject({
response: {
content: "Aktion verworfen: create project (name=Apollo)",
approval: {
id: "approval_1",
status: "cancelled",
},
},
});
expect(clearPendingAssistantApproval).toHaveBeenCalledWith({}, "user_1", "conv_1");
expect(consumePendingAssistantApproval).not.toHaveBeenCalled();
expect(executeTool).not.toHaveBeenCalled();
});
it("executes the confirmed mutation and returns its success response", async () => {
vi.mocked(consumePendingAssistantApproval).mockResolvedValue({
...createPendingApproval(),
summary: "create project (name=Apollo, status=DRAFT)",
} as never);
vi.mocked(executeTool).mockResolvedValue({
content: "{\"message\":\"Projekt Apollo angelegt\"}",
data: { message: "Projekt Apollo angelegt" },
action: { type: "refresh" },
} as never);
vi.mocked(buildAssistantInsight).mockReturnValue({
kind: "holiday_region",
title: "Berlin",
metrics: [{ label: "Resolved holidays", value: "1" }],
});
vi.mocked(readToolSuccessMessage).mockReturnValue("Projekt Apollo angelegt");
const result = await handlePendingAssistantApproval(createHandleInput());
expect(result).toMatchObject({
response: {
content: "Ausgeführt: Projekt Apollo angelegt",
approval: {
id: "approval_1",
status: "approved",
},
actions: [{ type: "refresh" }],
insights: [{
kind: "holiday_region",
title: "Berlin",
}],
},
});
expect(executeTool).toHaveBeenCalledWith(
"create_project",
"{\"name\":\"Apollo\"}",
expect.objectContaining({ userId: "user_1" }),
);
expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({
entityName: "create_project",
summary: "AI executed previously approved tool: create_project",
}));
});
it("does nothing when the user reply is not a valid confirmation", async () => {
vi.mocked(canExecuteMutationTool).mockReturnValue(false);
const result = await handlePendingAssistantApproval(createHandleInput({
lastUserMessage: { role: "user", content: "vielleicht" },
}));
expect(result).toBeNull();
expect(consumePendingAssistantApproval).not.toHaveBeenCalled();
expect(executeTool).not.toHaveBeenCalled();
});
});