feat(assistant): add approval inbox and e2e hardening

This commit is contained in:
2026-03-29 10:10:59 +02:00
parent 4f48afe7b4
commit beae1a5d6e
12 changed files with 2482 additions and 331 deletions
@@ -1,12 +1,183 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { getAvailableAssistantTools } from "../router/assistant.js";
import {
ASSISTANT_CONFIRMATION_PREFIX,
canExecuteMutationTool,
clearPendingAssistantApproval,
consumePendingAssistantApproval,
createPendingAssistantApproval,
getAvailableAssistantTools,
listPendingAssistantApprovals,
peekPendingAssistantApproval,
} from "../router/assistant.js";
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
function getToolNames(permissions: PermissionKeyValue[]) {
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
}
const TEST_USER_ID = "assistant-test-user";
const TEST_CONVERSATION_ID = "assistant-test-conversation";
function createApprovalStoreMock() {
const records = new Map<string, {
id: string;
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
status: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
approvedAt: Date | null;
cancelledAt: Date | null;
createdAt: Date;
expiresAt: Date;
updatedAt: Date;
}>();
return {
assistantApproval: {
findFirst: vi.fn(async ({
where,
orderBy,
}: {
where: {
id?: string;
userId?: string;
conversationId?: string;
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
};
orderBy?: { createdAt: "desc" | "asc" };
}) => {
const matches = [...records.values()]
.filter((record) => (
(!where.id || record.id === where.id)
&& (!where.userId || record.userId === where.userId)
&& (!where.conversationId || record.conversationId === where.conversationId)
&& (!where.status || record.status === where.status)
))
.sort((a, b) => (
orderBy?.createdAt === "asc"
? a.createdAt.getTime() - b.createdAt.getTime()
: b.createdAt.getTime() - a.createdAt.getTime()
));
return matches[0] ?? null;
}),
findMany: vi.fn(async ({
where,
orderBy,
}: {
where: {
userId?: string;
conversationId?: string;
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
expiresAt?: { lte?: Date; gt?: Date };
};
orderBy?: { createdAt: "desc" | "asc" };
}) => (
[...records.values()]
.filter((record) => (
(!where.userId || record.userId === where.userId)
&& (!where.conversationId || record.conversationId === where.conversationId)
&& (!where.status || record.status === where.status)
&& (!where.expiresAt?.lte || record.expiresAt <= where.expiresAt.lte)
&& (!where.expiresAt?.gt || record.expiresAt > where.expiresAt.gt)
))
.sort((a, b) => (
orderBy?.createdAt === "asc"
? a.createdAt.getTime() - b.createdAt.getTime()
: b.createdAt.getTime() - a.createdAt.getTime()
))
)),
create: vi.fn(async ({
data,
}: {
data: {
userId: string;
conversationId: string;
toolName: string;
toolArguments: string;
summary: string;
createdAt: Date;
expiresAt: Date;
};
}) => {
const record = {
id: `approval-${records.size + 1}`,
...data,
status: "PENDING" as const,
approvedAt: null,
cancelledAt: null,
updatedAt: data.createdAt,
};
records.set(record.id, record);
return record;
}),
updateMany: vi.fn(async ({
where,
data,
}: {
where: {
id?: string;
userId?: string;
conversationId?: string;
status?: "PENDING" | "APPROVED" | "CANCELLED" | "EXPIRED";
expiresAt?: { lte?: Date; gt?: Date };
};
data: Partial<{
status: "APPROVED" | "CANCELLED" | "EXPIRED";
cancelledAt: Date;
approvedAt: Date;
}>;
}) => {
let count = 0;
for (const [id, record] of records.entries()) {
if (where.id && record.id !== where.id) continue;
if (where.userId && record.userId !== where.userId) continue;
if (where.conversationId && record.conversationId !== where.conversationId) continue;
if (where.status && record.status !== where.status) continue;
if (where.expiresAt?.lte && record.expiresAt > where.expiresAt.lte) continue;
if (where.expiresAt?.gt && record.expiresAt <= where.expiresAt.gt) continue;
records.set(id, {
...record,
...data,
updatedAt: new Date(),
});
count += 1;
}
return { count };
}),
update: vi.fn(async ({
where,
data,
}: {
where: { id: string };
data: {
status: "APPROVED";
approvedAt: Date;
};
}) => {
const record = records.get(where.id);
if (!record) throw new Error("Record not found");
const next = {
...record,
...data,
updatedAt: new Date(),
};
records.set(where.id, next);
return next;
}),
},
};
}
describe("assistant router tool gating", () => {
let approvalStore = createApprovalStoreMock();
beforeEach(() => {
approvalStore = createApprovalStoreMock();
});
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
const withAdvanced = getToolNames([
@@ -31,4 +202,200 @@ describe("assistant router tool gating", () => {
expect(names).not.toContain("find_best_project_resource");
});
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
expect(canExecuteMutationTool([
{ role: "user", content: "Lege bitte ein Projekt an" },
], "create_project")).toBe(false);
expect(canExecuteMutationTool([
{ role: "user", content: "Lege bitte ein Projekt an" },
{ role: "assistant", content: "Ich werde jetzt das Projekt erstellen." },
{ role: "user", content: "ja" },
], "create_project")).toBe(false);
expect(canExecuteMutationTool([
{ role: "user", content: "Lege bitte ein Projekt an" },
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} Ich werde das Projekt \"Apollo\" in DRAFT anlegen. Bitte bestätigen.` },
{ role: "user", content: "ja, bitte ausführen" },
], "create_project")).toBe(true);
});
it("requires a matching server-side pending approval for mutation execution when provided", async () => {
const pendingApproval = await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo", status: "DRAFT" }),
);
expect(canExecuteMutationTool([
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
{ role: "user", content: "ja" },
], "create_project", pendingApproval)).toBe(true);
expect(canExecuteMutationTool([
{ role: "assistant", content: `${ASSISTANT_CONFIRMATION_PREFIX} create project (name=Apollo). Bitte bestätigen.` },
{ role: "user", content: "ja" },
], "delete_project", pendingApproval)).toBe(false);
});
it("stores and consumes pending approvals independently from chat text", async () => {
const approval = await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Gelddruckmaschine", status: "DRAFT" }),
);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
id: approval.id,
toolName: "create_project",
summary: expect.stringContaining("create project"),
});
await expect(consumePendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toMatchObject({
id: approval.id,
toolName: "create_project",
});
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
});
it("expires stale pending approvals", async () => {
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
{ ttlMs: -1 },
);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
});
it("clears pending approvals for cancellation semantics", async () => {
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
);
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
});
it("isolates pending approvals by conversation", async () => {
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
);
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
otherConversationId,
"create_project",
JSON.stringify({ name: "Hermes" }),
);
await clearPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID);
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull();
await expect(peekPendingAssistantApproval(approvalStore, TEST_USER_ID, otherConversationId)).resolves.toMatchObject({
toolName: "create_project",
summary: expect.stringContaining("Hermes"),
});
});
it("lists only still-pending approvals for the current user across conversations", async () => {
const otherConversationId = `${TEST_CONVERSATION_ID}-other`;
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
TEST_CONVERSATION_ID,
"create_project",
JSON.stringify({ name: "Apollo" }),
);
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
otherConversationId,
"create_project",
JSON.stringify({ name: "Hermes" }),
);
const cancelled = await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
`${TEST_CONVERSATION_ID}-cancelled`,
"create_project",
JSON.stringify({ name: "Cancelled" }),
);
await approvalStore.assistantApproval.updateMany({
where: { id: cancelled.id, userId: TEST_USER_ID, status: "PENDING" },
data: { status: "CANCELLED", cancelledAt: new Date() },
});
await createPendingAssistantApproval(
approvalStore,
"other-user",
`${TEST_CONVERSATION_ID}-foreign`,
"create_project",
JSON.stringify({ name: "Foreign" }),
);
await createPendingAssistantApproval(
approvalStore,
TEST_USER_ID,
`${TEST_CONVERSATION_ID}-expired`,
"create_project",
JSON.stringify({ name: "Expired" }),
{ ttlMs: -1 },
);
const approvals = await listPendingAssistantApprovals(approvalStore, TEST_USER_ID);
const approvalSummaries = approvals.map((approval) => approval.summary).join(" ");
expect(approvals).toHaveLength(2);
expect([...approvals.map((approval) => approval.conversationId)].sort()).toEqual([
otherConversationId,
TEST_CONVERSATION_ID,
].sort());
expect(approvals.every((approval) => approval.userId === TEST_USER_ID)).toBe(true);
expect(approvalSummaries).toContain("Apollo");
expect(approvalSummaries).toContain("Hermes");
expect(approvalSummaries).not.toContain("Cancelled");
expect(approvalSummaries).not.toContain("Expired");
expect(approvalSummaries).not.toContain("Foreign");
});
it("does not require confirmation for read-only assistant tools", () => {
expect(canExecuteMutationTool([
{ role: "user", content: "Zeig mir meine Notifications" },
], "list_notifications")).toBe(true);
});
it("keeps assistant tool descriptions aligned with runtime permissions", () => {
const toolDescriptions = new Map(
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function.description]),
);
expect(toolDescriptions.get("create_estimate")).toContain("manageProjects");
expect(toolDescriptions.get("set_entitlement")).toContain("manageVacations");
expect(toolDescriptions.get("create_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("update_org_unit")).toContain("manageResources");
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
});
});