From f08b47171cd5cc4dd48d0fd89a591927a41eb361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 10:30:28 +0200 Subject: [PATCH] refactor(api): modularize assistant router workflow --- .../assistant-approval-test-helpers.ts | 166 +++ .../src/__tests__/assistant-approvals.test.ts | 221 ++++ .../__tests__/assistant-confirmation.test.ts | 93 ++ .../src/__tests__/assistant-router.test.ts | 1015 +---------------- .../__tests__/assistant-tool-policy.test.ts | 631 ++++++++++ .../__tests__/assistant-tool-results.test.ts | 35 + .../assistant-tool-selection.test.ts | 49 + .../api/src/router/assistant-approvals.ts | 284 +++++ .../api/src/router/assistant-confirmation.ts | 141 +++ .../api/src/router/assistant-tool-policy.ts | 294 +++++ .../api/src/router/assistant-tool-results.ts | 46 + .../src/router/assistant-tool-selection.ts | 165 +++ packages/api/src/router/assistant.ts | 921 +-------------- 13 files changed, 2186 insertions(+), 1875 deletions(-) create mode 100644 packages/api/src/__tests__/assistant-approval-test-helpers.ts create mode 100644 packages/api/src/__tests__/assistant-approvals.test.ts create mode 100644 packages/api/src/__tests__/assistant-confirmation.test.ts create mode 100644 packages/api/src/__tests__/assistant-tool-policy.test.ts create mode 100644 packages/api/src/__tests__/assistant-tool-results.test.ts create mode 100644 packages/api/src/__tests__/assistant-tool-selection.test.ts create mode 100644 packages/api/src/router/assistant-approvals.ts create mode 100644 packages/api/src/router/assistant-confirmation.ts create mode 100644 packages/api/src/router/assistant-tool-policy.ts create mode 100644 packages/api/src/router/assistant-tool-results.ts create mode 100644 packages/api/src/router/assistant-tool-selection.ts diff --git a/packages/api/src/__tests__/assistant-approval-test-helpers.ts b/packages/api/src/__tests__/assistant-approval-test-helpers.ts new file mode 100644 index 0000000..e08654a --- /dev/null +++ b/packages/api/src/__tests__/assistant-approval-test-helpers.ts @@ -0,0 +1,166 @@ +import { vi } from "vitest"; + +export const TEST_USER_ID = "assistant-test-user"; +export const TEST_CONVERSATION_ID = "assistant-test-conversation"; + +export function createApprovalStoreMock() { + const records = new Map(); + + 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; + }), + }, + }; +} + +export function createMissingApprovalTableError() { + return Object.assign( + new Error("The table `public.assistant_approvals` does not exist in the current database."), + { + code: "P2021", + meta: { table: "public.assistant_approvals" }, + }, + ); +} diff --git a/packages/api/src/__tests__/assistant-approvals.test.ts b/packages/api/src/__tests__/assistant-approvals.test.ts new file mode 100644 index 0000000..8a04403 --- /dev/null +++ b/packages/api/src/__tests__/assistant-approvals.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + AssistantApprovalStorageUnavailableError, + clearPendingAssistantApproval, + consumePendingAssistantApproval, + createPendingAssistantApproval, + listPendingAssistantApprovals, + peekPendingAssistantApproval, + resetAssistantApprovalStorageWarningStateForTests, +} from "../router/assistant-approvals.js"; +import { logger } from "../lib/logger.js"; +import { + createApprovalStoreMock, + createMissingApprovalTableError, + TEST_CONVERSATION_ID, + TEST_USER_ID, +} from "./assistant-approval-test-helpers.js"; + +describe("assistant approvals", () => { + let approvalStore = createApprovalStoreMock(); + + beforeEach(() => { + approvalStore = createApprovalStoreMock(); + resetAssistantApprovalStorageWarningStateForTests(); + }); + + 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("degrades approval reads gracefully when approval storage is missing", async () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger); + const missingTableError = createMissingApprovalTableError(); + const missingStore = { + assistantApproval: { + findFirst: vi.fn(async () => { + throw missingTableError; + }), + findMany: vi.fn(async () => { + throw missingTableError; + }), + create: vi.fn(async () => { + throw missingTableError; + }), + updateMany: vi.fn(async () => { + throw missingTableError; + }), + }, + }; + + await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]); + await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); + await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); + await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("returns an explicit error when approval storage is missing for mutation confirmation", async () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger); + const missingTableError = createMissingApprovalTableError(); + const missingStore = { + assistantApproval: { + findFirst: vi.fn(async () => { + throw missingTableError; + }), + findMany: vi.fn(async () => { + throw missingTableError; + }), + create: vi.fn(async () => { + throw missingTableError; + }), + updateMany: vi.fn(async () => { + throw missingTableError; + }), + }, + }; + + await expect(createPendingAssistantApproval( + missingStore, + TEST_USER_ID, + TEST_CONVERSATION_ID, + "create_project", + JSON.stringify({ name: "Apollo" }), + )).rejects.toBeInstanceOf(AssistantApprovalStorageUnavailableError); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/__tests__/assistant-confirmation.test.ts b/packages/api/src/__tests__/assistant-confirmation.test.ts new file mode 100644 index 0000000..c8ac008 --- /dev/null +++ b/packages/api/src/__tests__/assistant-confirmation.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + ASSISTANT_CONFIRMATION_PREFIX, + buildApprovalSummary, + canExecuteMutationTool, + formatApprovalValue, + isAffirmativeConfirmationReply, + isCancellationReply, + parseToolArguments, +} from "../router/assistant-confirmation.js"; + +describe("assistant confirmation", () => { + it("parses tool arguments defensively", () => { + expect(parseToolArguments("{\"name\":\"Apollo\",\"status\":\"DRAFT\"}")).toEqual({ + name: "Apollo", + status: "DRAFT", + }); + expect(parseToolArguments("[]")).toEqual({}); + expect(parseToolArguments("not-json")).toEqual({}); + }); + + it("formats approval values compactly", () => { + expect(formatApprovalValue("Apollo")).toBe("Apollo"); + expect(formatApprovalValue("x".repeat(60))).toBe(`${"x".repeat(45)}...`); + expect(formatApprovalValue([1, "two", true, "four"])).toBe("[1, two, true, ...]"); + expect(formatApprovalValue({ nested: true })).toBe("{...}"); + }); + + it("builds compact approval summaries from tool calls", () => { + expect(buildApprovalSummary( + "create_project", + JSON.stringify({ name: "Apollo", status: "DRAFT", budgetCents: 1200000 }), + )).toBe("create project (name=Apollo, status=DRAFT, budgetCents=1200000)"); + expect(buildApprovalSummary("delete_project", "{}")).toBe("delete project"); + }); + + it("recognizes affirmative and cancellation replies in multiple phrasings", () => { + expect(isAffirmativeConfirmationReply("ja, bitte ausführen")).toBe(true); + expect(isAffirmativeConfirmationReply("go ahead")).toBe(true); + expect(isAffirmativeConfirmationReply("vielleicht")).toBe(false); + + expect(isCancellationReply("nein, abbrechen")).toBe(true); + expect(isCancellationReply("stop")).toBe(true); + expect(isCancellationReply("später")).toBe(false); + }); + + 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", () => { + const pendingApproval = { + id: "approval-1", + userId: "user-1", + conversationId: "conversation-1", + toolName: "create_project", + toolArguments: JSON.stringify({ name: "Apollo", status: "DRAFT" }), + summary: "create project (name=Apollo, status=DRAFT)", + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; + + 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("does not require confirmation for read-only assistant tools", () => { + expect(canExecuteMutationTool([ + { role: "user", content: "Zeig mir meine Notifications" }, + ], "list_notifications")).toBe(true); + }); +}); diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 81c4947..40b9422 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -1,953 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; -import { apiRateLimiter } from "../middleware/rate-limit.js"; -import { - ASSISTANT_CONFIRMATION_PREFIX, - canExecuteMutationTool, - clearPendingAssistantApproval, - consumePendingAssistantApproval, - createPendingAssistantApproval, - getAvailableAssistantTools, - listPendingAssistantApprovals, - peekPendingAssistantApproval, - resetAssistantApprovalStorageWarningStateForTests, - selectAssistantToolsForRequest, -} from "../router/assistant.js"; +import { describe, expect, it } from "vitest"; import { TOOL_DEFINITIONS } from "../router/assistant-tools.js"; -import { logger } from "../lib/logger.js"; - -function getToolNames( - permissions: PermissionKeyValue[], - userRole: SystemRole = SystemRole.ADMIN, -) { - return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name); -} - -function getSelectedToolNames( - permissions: PermissionKeyValue[], - messages: Array<{ role: "user" | "assistant"; content: string }>, - userRole: SystemRole = SystemRole.ADMIN, - pageContext?: string, -) { - return selectAssistantToolsForRequest( - getAvailableAssistantTools(new Set(permissions), userRole), - messages, - pageContext, - ).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(); - - 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; - }), - }, - }; -} - -function createMissingApprovalTableError() { - return Object.assign( - new Error("The table `public.assistant_approvals` does not exist in the current database."), - { - code: "P2021", - meta: { table: "public.assistant_approvals" }, - }, - ); -} - -describe("assistant router tool gating", () => { - let approvalStore = createApprovalStoreMock(); - - beforeEach(async () => { - approvalStore = createApprovalStoreMock(); - await apiRateLimiter.reset(); - resetAssistantApprovalStorageWarningStateForTests(); - }); - - it("hides advanced tools unless the dedicated assistant permission is granted", () => { - const withoutAdvanced = getToolNames([ - PermissionKey.VIEW_PLANNING, - PermissionKey.VIEW_COSTS, - ]); - const withAdvanced = getToolNames([ - PermissionKey.VIEW_PLANNING, - PermissionKey.VIEW_COSTS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ]); - - expect(withoutAdvanced).not.toContain("find_best_project_resource"); - expect(withAdvanced).toContain("find_best_project_resource"); - expect(withAdvanced).toContain("get_chargeability_report"); - expect(withAdvanced).toContain("get_resource_computation_graph"); - expect(withAdvanced).toContain("get_project_computation_graph"); - }); - - it("keeps user self-service tools available to plain authenticated users", () => { - const userNames = getToolNames([], SystemRole.USER); - - expect(userNames).toContain("get_current_user"); - expect(userNames).toContain("get_dashboard_layout"); - expect(userNames).toContain("save_dashboard_layout"); - expect(userNames).toContain("get_favorite_project_ids"); - expect(userNames).toContain("toggle_favorite_project"); - expect(userNames).toContain("get_column_preferences"); - expect(userNames).toContain("set_column_preferences"); - expect(userNames).toContain("get_mfa_status"); - expect(userNames).toContain("list_notifications"); - expect(userNames).toContain("get_unread_notification_count"); - expect(userNames).toContain("list_tasks"); - expect(userNames).toContain("get_task_counts"); - expect(userNames).toContain("create_reminder"); - expect(userNames).toContain("list_reminders"); - expect(userNames).toContain("update_reminder"); - expect(userNames).toContain("delete_reminder"); - }); - - it("keeps admin-only user tools hidden from non-admin roles", () => { - const adminNames = getToolNames([], SystemRole.ADMIN); - const managerNames = getToolNames([], SystemRole.MANAGER); - const userNames = getToolNames([], SystemRole.USER); - - expect(adminNames).toContain("list_users"); - expect(adminNames).toContain("get_active_user_count"); - expect(adminNames).toContain("create_user"); - expect(adminNames).toContain("set_user_password"); - expect(adminNames).toContain("update_user_role"); - expect(adminNames).toContain("update_user_name"); - expect(adminNames).toContain("link_user_resource"); - expect(adminNames).toContain("auto_link_users_by_email"); - expect(adminNames).toContain("set_user_permissions"); - expect(adminNames).toContain("reset_user_permissions"); - expect(adminNames).toContain("get_effective_user_permissions"); - expect(adminNames).toContain("disable_user_totp"); - - expect(managerNames).not.toContain("list_users"); - expect(managerNames).not.toContain("create_user"); - expect(managerNames).not.toContain("set_user_permissions"); - expect(managerNames).not.toContain("disable_user_totp"); - expect(userNames).not.toContain("list_users"); - expect(userNames).not.toContain("get_active_user_count"); - expect(userNames).not.toContain("create_user"); - expect(userNames).not.toContain("set_user_password"); - expect(userNames).not.toContain("update_user_role"); - expect(userNames).not.toContain("update_user_name"); - expect(userNames).not.toContain("link_user_resource"); - expect(userNames).not.toContain("auto_link_users_by_email"); - expect(userNames).not.toContain("set_user_permissions"); - expect(userNames).not.toContain("reset_user_permissions"); - expect(userNames).not.toContain("get_effective_user_permissions"); - expect(userNames).not.toContain("disable_user_totp"); - }); - - it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => { - const allPermissions = Object.values(PermissionKey); - const selectedNames = getSelectedToolNames( - allPermissions, - [{ role: "user", content: "Bitte gib mir einen Überblick über das System." }], - SystemRole.ADMIN, - ); - - expect(selectedNames.length).toBeLessThanOrEqual(128); - expect(selectedNames).toContain("get_current_user"); - expect(selectedNames).toContain("search_resources"); - expect(selectedNames).toContain("search_projects"); - }); - - it("prioritizes holiday and resource tools for German holiday questions", () => { - const allPermissions = Object.values(PermissionKey); - const selectedNames = getSelectedToolNames( - allPermissions, - [{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }], - SystemRole.ADMIN, - ); - - expect(selectedNames.length).toBeLessThanOrEqual(128); - expect(selectedNames).toContain("search_resources"); - expect(selectedNames).toContain("get_resource"); - expect(selectedNames).toContain("get_resource_holidays"); - expect(selectedNames).toContain("list_holidays_by_region"); - expect(selectedNames).toContain("list_holiday_calendars"); - }); - - it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => { - const managerNames = getToolNames([], SystemRole.MANAGER); - const adminNames = getToolNames([], SystemRole.ADMIN); - const userNames = getToolNames([], SystemRole.USER); - - expect(managerNames).toContain("list_assignable_users"); - expect(managerNames).toContain("create_notification"); - expect(managerNames).toContain("create_task_for_user"); - expect(managerNames).toContain("assign_task"); - expect(managerNames).toContain("send_broadcast"); - expect(managerNames).toContain("list_broadcasts"); - expect(managerNames).toContain("get_broadcast_detail"); - expect(adminNames).toContain("list_assignable_users"); - expect(adminNames).toContain("create_task_for_user"); - expect(adminNames).toContain("send_broadcast"); - expect(userNames).not.toContain("list_assignable_users"); - expect(userNames).not.toContain("create_notification"); - expect(userNames).not.toContain("create_task_for_user"); - expect(userNames).not.toContain("assign_task"); - expect(userNames).not.toContain("send_broadcast"); - expect(userNames).not.toContain("list_broadcasts"); - expect(userNames).not.toContain("get_broadcast_detail"); - }); - - it("continues to hide cost-aware advanced tools when viewCosts is missing", () => { - const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]); - - expect(names).not.toContain("find_best_project_resource"); - expect(names).not.toContain("get_chargeability_report"); - expect(names).not.toContain("get_resource_computation_graph"); - expect(names).not.toContain("get_project_computation_graph"); - }); - - it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => { - const controllerNames = getToolNames([ - PermissionKey.VIEW_COSTS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.CONTROLLER); - const userNames = getToolNames([ - PermissionKey.VIEW_COSTS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.USER); - - expect(controllerNames).toContain("query_change_history"); - expect(controllerNames).toContain("get_entity_timeline"); - expect(controllerNames).toContain("search_by_skill"); - expect(controllerNames).toContain("list_comments"); - expect(controllerNames).toContain("create_comment"); - expect(controllerNames).toContain("resolve_comment"); - expect(controllerNames).toContain("export_resources_csv"); - expect(controllerNames).toContain("export_projects_csv"); - expect(controllerNames).toContain("list_audit_log_entries"); - expect(controllerNames).toContain("get_audit_log_entry"); - expect(controllerNames).toContain("get_audit_log_timeline"); - expect(controllerNames).toContain("get_audit_activity_summary"); - expect(controllerNames).toContain("get_chargeability_report"); - expect(controllerNames).toContain("get_resource_computation_graph"); - expect(controllerNames).toContain("get_project_computation_graph"); - expect(userNames).not.toContain("query_change_history"); - expect(userNames).not.toContain("get_entity_timeline"); - expect(userNames).not.toContain("search_by_skill"); - expect(userNames).not.toContain("list_comments"); - expect(userNames).not.toContain("create_comment"); - expect(userNames).not.toContain("resolve_comment"); - expect(userNames).not.toContain("export_resources_csv"); - expect(userNames).not.toContain("export_projects_csv"); - expect(userNames).not.toContain("list_audit_log_entries"); - expect(userNames).not.toContain("get_audit_log_entry"); - expect(userNames).not.toContain("get_audit_log_timeline"); - expect(userNames).not.toContain("get_audit_activity_summary"); - expect(userNames).not.toContain("get_chargeability_report"); - expect(userNames).not.toContain("get_resource_computation_graph"); - expect(userNames).not.toContain("get_project_computation_graph"); - }); - - it("keeps planning read tools behind the explicit planning permission", () => { - const userWithoutPlanning = getToolNames([], SystemRole.USER); - const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); - - expect(userWithoutPlanning).not.toContain("list_allocations"); - expect(userWithoutPlanning).not.toContain("list_demands"); - expect(userWithoutPlanning).not.toContain("list_blueprints"); - expect(userWithoutPlanning).not.toContain("get_blueprint"); - expect(userWithoutPlanning).not.toContain("list_clients"); - expect(userWithoutPlanning).not.toContain("list_roles"); - expect(userWithoutPlanning).not.toContain("list_management_levels"); - expect(userWithoutPlanning).not.toContain("list_utilization_categories"); - expect(userWithoutPlanning).not.toContain("check_resource_availability"); - expect(userWithoutPlanning).not.toContain("find_capacity"); - expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); - expect(userWithoutPlanning).not.toContain("find_best_project_resource"); - expect(userWithPlanning).toContain("list_allocations"); - expect(userWithPlanning).toContain("list_demands"); - expect(userWithPlanning).toContain("list_blueprints"); - expect(userWithPlanning).toContain("get_blueprint"); - expect(userWithPlanning).toContain("list_clients"); - expect(userWithPlanning).toContain("list_roles"); - expect(userWithPlanning).toContain("list_management_levels"); - expect(userWithPlanning).toContain("list_utilization_categories"); - expect(userWithPlanning).toContain("check_resource_availability"); - expect(userWithPlanning).toContain("find_capacity"); - expect(userWithPlanning).not.toContain("get_staffing_suggestions"); - expect(userWithPlanning).not.toContain("find_best_project_resource"); - }); - - it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => { - const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); - const planningAndCosts = getToolNames([ - PermissionKey.VIEW_PLANNING, - PermissionKey.VIEW_COSTS, - ], SystemRole.USER); - const planningCostsAndAdvanced = getToolNames([ - PermissionKey.VIEW_PLANNING, - PermissionKey.VIEW_COSTS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.USER); - - expect(planningOnly).not.toContain("get_staffing_suggestions"); - expect(planningOnly).not.toContain("find_best_project_resource"); - expect(planningAndCosts).toContain("get_staffing_suggestions"); - expect(planningAndCosts).not.toContain("find_best_project_resource"); - expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions"); - expect(planningCostsAndAdvanced).toContain("find_best_project_resource"); - }); - - it("keeps controller-only project and dashboard reads hidden from plain users", () => { - const controllerNames = getToolNames([ - PermissionKey.VIEW_COSTS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.CONTROLLER); - const userNames = getToolNames([ - PermissionKey.VIEW_COSTS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - PermissionKey.VIEW_PLANNING, - ], SystemRole.USER); - - expect(controllerNames).toContain("search_projects"); - expect(controllerNames).toContain("get_project"); - expect(controllerNames).toContain("get_statistics"); - expect(controllerNames).toContain("get_dashboard_detail"); - expect(controllerNames).toContain("get_skill_gaps"); - expect(controllerNames).toContain("get_project_health"); - expect(controllerNames).toContain("get_budget_forecast"); - expect(userNames).not.toContain("search_projects"); - expect(userNames).not.toContain("get_project"); - expect(userNames).not.toContain("get_statistics"); - expect(userNames).not.toContain("get_dashboard_detail"); - expect(userNames).not.toContain("get_skill_gaps"); - expect(userNames).not.toContain("get_project_health"); - expect(userNames).not.toContain("get_budget_forecast"); - }); - - it("requires both controller role and advanced assistant access for timeline detail tools", () => { - const controllerWithAdvanced = getToolNames([ - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.CONTROLLER); - const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER); - const userWithAdvanced = getToolNames([ - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.USER); - - expect(controllerWithAdvanced).toContain("get_timeline_entries_view"); - expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays"); - expect(controllerWithAdvanced).toContain("get_project_timeline_context"); - expect(controllerWithAdvanced).toContain("preview_project_shift"); - expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view"); - expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays"); - expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context"); - expect(controllerWithoutAdvanced).not.toContain("preview_project_shift"); - expect(userWithAdvanced).not.toContain("get_timeline_entries_view"); - expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays"); - expect(userWithAdvanced).not.toContain("get_project_timeline_context"); - expect(userWithAdvanced).not.toContain("preview_project_shift"); - }); - - it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => { - const managerNames = getToolNames([ - PermissionKey.MANAGE_ALLOCATIONS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.MANAGER); - const userNames = getToolNames([ - PermissionKey.MANAGE_ALLOCATIONS, - PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, - ], SystemRole.USER); - const missingAdvancedNames = getToolNames([ - PermissionKey.MANAGE_ALLOCATIONS, - ], SystemRole.MANAGER); - - expect(managerNames).toContain("update_timeline_allocation_inline"); - expect(managerNames).toContain("apply_timeline_project_shift"); - expect(managerNames).toContain("quick_assign_timeline_resource"); - expect(managerNames).toContain("batch_quick_assign_timeline_resources"); - expect(managerNames).toContain("batch_shift_timeline_allocations"); - expect(userNames).not.toContain("update_timeline_allocation_inline"); - expect(userNames).not.toContain("apply_timeline_project_shift"); - expect(userNames).not.toContain("quick_assign_timeline_resource"); - expect(userNames).not.toContain("batch_quick_assign_timeline_resources"); - expect(userNames).not.toContain("batch_shift_timeline_allocations"); - expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline"); - expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource"); - }); - - it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => { - const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER); - const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER); - const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER); - - expect(managerProjectNames).toContain("create_estimate"); - expect(managerProjectNames).toContain("clone_estimate"); - expect(managerProjectNames).toContain("update_estimate_draft"); - expect(managerProjectNames).toContain("submit_estimate_version"); - expect(managerProjectNames).toContain("approve_estimate_version"); - expect(managerProjectNames).toContain("create_estimate_revision"); - expect(managerProjectNames).toContain("create_estimate_export"); - expect(managerProjectNames).toContain("generate_estimate_weekly_phasing"); - expect(managerProjectNames).toContain("update_estimate_commercial_terms"); - expect(managerProjectNames).not.toContain("create_estimate_planning_handoff"); - expect(managerAllocationNames).toContain("create_estimate_planning_handoff"); - expect(managerAllocationNames).not.toContain("create_estimate"); - expect(userProjectNames).not.toContain("create_estimate"); - expect(userProjectNames).not.toContain("clone_estimate"); - expect(userProjectNames).not.toContain("update_estimate_draft"); - expect(userProjectNames).not.toContain("submit_estimate_version"); - expect(userProjectNames).not.toContain("approve_estimate_version"); - expect(userProjectNames).not.toContain("create_estimate_revision"); - expect(userProjectNames).not.toContain("create_estimate_export"); - expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing"); - expect(userProjectNames).not.toContain("update_estimate_commercial_terms"); - expect(userProjectNames).not.toContain("create_estimate_planning_handoff"); - }); - - it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => { - const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER); - const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER); - const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER); - const managerWithoutCosts = getToolNames([], SystemRole.MANAGER); - const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER); - - expect(controllerNames).toContain("search_estimates"); - expect(controllerNames).toContain("get_estimate_detail"); - expect(controllerNames).toContain("list_estimate_versions"); - expect(controllerNames).toContain("get_estimate_version_snapshot"); - expect(controllerNames).toContain("get_estimate_weekly_phasing"); - expect(controllerNames).toContain("get_estimate_commercial_terms"); - expect(controllerWithoutCosts).toContain("search_estimates"); - expect(controllerWithoutCosts).not.toContain("get_estimate_detail"); - expect(controllerWithoutCosts).toContain("list_estimate_versions"); - expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot"); - expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing"); - expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms"); - expect(managerNames).toContain("search_estimates"); - expect(managerNames).toContain("get_estimate_detail"); - expect(managerNames).toContain("list_estimate_versions"); - expect(managerNames).toContain("get_estimate_version_snapshot"); - expect(managerNames).toContain("get_estimate_weekly_phasing"); - expect(managerNames).toContain("get_estimate_commercial_terms"); - expect(managerWithoutCosts).toContain("search_estimates"); - expect(managerWithoutCosts).toContain("list_estimate_versions"); - expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot"); - expect(userNames).not.toContain("search_estimates"); - expect(userNames).not.toContain("get_estimate_detail"); - expect(userNames).not.toContain("list_estimate_versions"); - expect(userNames).not.toContain("get_estimate_version_snapshot"); - expect(userNames).not.toContain("get_estimate_weekly_phasing"); - expect(userNames).not.toContain("get_estimate_commercial_terms"); - }); - - it("keeps import/dispo parity tools aligned to router roles and permissions", () => { - const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER); - const controllerNames = getToolNames([], SystemRole.CONTROLLER); - const adminNames = getToolNames([], SystemRole.ADMIN); - const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER); - - expect(managerNames).toContain("import_csv_data"); - expect(controllerNames).toContain("export_resources_csv"); - expect(controllerNames).toContain("export_projects_csv"); - expect(adminNames).toContain("list_dispo_import_batches"); - expect(adminNames).toContain("get_dispo_import_batch"); - expect(adminNames).toContain("stage_dispo_import_batch"); - expect(adminNames).toContain("validate_dispo_import_batch"); - expect(adminNames).toContain("cancel_dispo_import_batch"); - expect(adminNames).toContain("list_dispo_staged_resources"); - expect(adminNames).toContain("list_dispo_staged_projects"); - expect(adminNames).toContain("list_dispo_staged_assignments"); - expect(adminNames).toContain("list_dispo_staged_vacations"); - expect(adminNames).toContain("list_dispo_staged_unresolved_records"); - expect(adminNames).toContain("resolve_dispo_staged_record"); - expect(adminNames).toContain("commit_dispo_import_batch"); - expect(userNames).not.toContain("import_csv_data"); - expect(userNames).not.toContain("export_resources_csv"); - expect(userNames).not.toContain("export_projects_csv"); - expect(userNames).not.toContain("list_dispo_import_batches"); - expect(userNames).not.toContain("get_dispo_import_batch"); - expect(userNames).not.toContain("stage_dispo_import_batch"); - expect(userNames).not.toContain("validate_dispo_import_batch"); - expect(userNames).not.toContain("list_dispo_staged_resources"); - expect(userNames).not.toContain("commit_dispo_import_batch"); - }); - - it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => { - const adminNames = getToolNames([], SystemRole.ADMIN); - const userNames = getToolNames([], SystemRole.USER); - - const managerNames = getToolNames([], SystemRole.MANAGER); - expect(adminNames).toContain("get_system_settings"); - expect(adminNames).toContain("update_system_settings"); - expect(adminNames).toContain("test_ai_connection"); - expect(adminNames).toContain("test_smtp_connection"); - expect(adminNames).toContain("clear_stored_runtime_secrets"); - expect(adminNames).toContain("test_gemini_connection"); - expect(adminNames).toContain("list_system_role_configs"); - expect(adminNames).toContain("update_system_role_config"); - expect(adminNames).toContain("list_webhooks"); - expect(adminNames).toContain("get_webhook"); - expect(adminNames).toContain("create_webhook"); - expect(adminNames).toContain("update_webhook"); - expect(adminNames).toContain("delete_webhook"); - expect(adminNames).toContain("test_webhook"); - expect(adminNames).toContain("get_ai_configured"); - - expect(managerNames).not.toContain("get_system_settings"); - expect(managerNames).not.toContain("update_system_settings"); - expect(managerNames).not.toContain("clear_stored_runtime_secrets"); - expect(managerNames).not.toContain("test_ai_connection"); - expect(managerNames).not.toContain("get_ai_configured"); - expect(managerNames).not.toContain("list_system_role_configs"); - expect(managerNames).not.toContain("update_system_role_config"); - expect(managerNames).not.toContain("list_webhooks"); - expect(managerNames).not.toContain("create_webhook"); - - expect(userNames).not.toContain("get_system_settings"); - expect(userNames).not.toContain("update_system_settings"); - expect(userNames).not.toContain("test_ai_connection"); - expect(userNames).not.toContain("get_ai_configured"); - expect(userNames).not.toContain("clear_stored_runtime_secrets"); - expect(userNames).not.toContain("list_system_role_configs"); - expect(userNames).not.toContain("update_system_role_config"); - expect(userNames).not.toContain("list_webhooks"); - expect(userNames).not.toContain("create_webhook"); - }); - - it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => { - const adminNames = getToolNames([], SystemRole.ADMIN); - const managerNames = getToolNames([], SystemRole.MANAGER); - const userNames = getToolNames([], SystemRole.USER); - - expect(adminNames).toContain("list_holiday_calendars"); - expect(adminNames).toContain("get_holiday_calendar"); - expect(adminNames).toContain("preview_resolved_holiday_calendar"); - expect(adminNames).toContain("create_holiday_calendar"); - expect(managerNames).not.toContain("list_holiday_calendars"); - expect(managerNames).not.toContain("get_holiday_calendar"); - expect(managerNames).toContain("preview_resolved_holiday_calendar"); - expect(userNames).not.toContain("list_holiday_calendars"); - expect(userNames).not.toContain("get_holiday_calendar"); - expect(userNames).toContain("preview_resolved_holiday_calendar"); - expect(managerNames).not.toContain("create_holiday_calendar"); - expect(managerNames).not.toContain("update_holiday_calendar"); - expect(managerNames).not.toContain("delete_holiday_calendar"); - expect(managerNames).not.toContain("create_holiday_calendar_entry"); - expect(managerNames).not.toContain("update_holiday_calendar_entry"); - expect(managerNames).not.toContain("delete_holiday_calendar_entry"); - }); - - it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => { - const adminNames = getToolNames([], SystemRole.ADMIN); - const managerNames = getToolNames([], SystemRole.MANAGER); - const userNames = getToolNames([], SystemRole.USER); - const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER); - const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER); - - expect(adminNames).toContain("list_countries"); - expect(adminNames).toContain("create_country"); - expect(adminNames).toContain("update_country"); - expect(adminNames).toContain("create_metro_city"); - expect(adminNames).toContain("update_metro_city"); - expect(adminNames).toContain("delete_metro_city"); - expect(managerNames).toContain("list_countries"); - expect(managerNames).not.toContain("create_country"); - expect(managerNames).not.toContain("update_country"); - expect(managerNames).not.toContain("create_metro_city"); - expect(managerNames).not.toContain("update_metro_city"); - expect(managerNames).not.toContain("delete_metro_city"); - expect(userNames).not.toContain("search_resources"); - expect(userNames).not.toContain("get_country"); - expect(userNames).not.toContain("list_org_units"); - expect(userWithResourceOverview).toContain("search_resources"); - expect(userWithResourceOverview).toContain("get_country"); - expect(userWithResourceOverview).toContain("list_org_units"); - expect(userWithManagedResources).toContain("search_resources"); - expect(userWithManagedResources).toContain("get_country"); - expect(userWithManagedResources).toContain("list_org_units"); - }); - - 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("degrades approval reads gracefully when approval storage is missing", async () => { - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger); - const missingTableError = createMissingApprovalTableError(); - const missingStore = { - assistantApproval: { - findFirst: vi.fn(async () => { - throw missingTableError; - }), - findMany: vi.fn(async () => { - throw missingTableError; - }), - create: vi.fn(async () => { - throw missingTableError; - }), - updateMany: vi.fn(async () => { - throw missingTableError; - }), - }, - }; - - await expect(listPendingAssistantApprovals(missingStore, TEST_USER_ID)).resolves.toEqual([]); - await expect(peekPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); - await expect(consumePendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeNull(); - await expect(clearPendingAssistantApproval(missingStore, TEST_USER_ID, TEST_CONVERSATION_ID)).resolves.toBeUndefined(); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - it("returns an explicit error when approval storage is missing for mutation confirmation", async () => { - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => logger); - const missingTableError = createMissingApprovalTableError(); - const missingStore = { - assistantApproval: { - findFirst: vi.fn(async () => { - throw missingTableError; - }), - findMany: vi.fn(async () => { - throw missingTableError; - }), - create: vi.fn(async () => { - throw missingTableError; - }), - updateMany: vi.fn(async () => { - throw missingTableError; - }), - }, - }; - - await expect(createPendingAssistantApproval( - missingStore, - TEST_USER_ID, - TEST_CONVERSATION_ID, - "create_project", - JSON.stringify({ name: "Apollo" }), - )).rejects.toThrow("Assistant approval storage is unavailable"); - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - - it("does not require confirmation for read-only assistant tools", () => { - expect(canExecuteMutationTool([ - { role: "user", content: "Zeig mir meine Notifications" }, - ], "list_notifications")).toBe(true); - }); +describe("assistant router", () => { it("keeps assistant tool descriptions aligned with runtime permissions", () => { const toolDescriptions = new Map( TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function.description]), @@ -960,7 +14,8 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("get_estimate_version_snapshot")).toContain("viewCosts"); expect(toolDescriptions.get("get_estimate_weekly_phasing")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("get_estimate_commercial_terms")).toContain("Controller/manager/admin"); - expect(toolDescriptions.get("create_vacation")).toContain("authenticated user"); + expect(toolDescriptions.get("create_vacation")).toContain("Any authenticated user can request leave for their own resource"); + expect(toolDescriptions.get("create_vacation")).toContain("manager/admin can create requests for others"); expect(toolDescriptions.get("approve_vacation")).toContain("Manager or admin role"); expect(toolDescriptions.get("reject_vacation")).toContain("Manager or admin role"); expect(toolDescriptions.get("cancel_vacation")).toContain("Users can cancel their own requests"); @@ -975,11 +30,11 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile"); expect(toolDescriptions.get("search_resources")).toContain("Resource overview access required"); expect(toolDescriptions.get("search_by_skill")).toContain("Controller/manager/admin access required"); - expect(toolDescriptions.get("list_comments")).toContain("Currently only estimate comments are enabled"); - expect(toolDescriptions.get("list_comments")).toContain("Controller/manager/admin access required"); - expect(toolDescriptions.get("create_comment")).toContain("Currently only estimate comments are enabled"); - expect(toolDescriptions.get("create_comment")).toContain("Controller/manager/admin access required"); - expect(toolDescriptions.get("resolve_comment")).toContain("Currently only estimate comments are enabled"); + expect(toolDescriptions.get("list_comments")).toContain("Supported comment entities: estimate, resource."); + expect(toolDescriptions.get("list_comments")).toContain("Entity visibility is required"); + expect(toolDescriptions.get("create_comment")).toContain("Supported comment entities: estimate, resource."); + expect(toolDescriptions.get("create_comment")).toContain("Entity visibility is required"); + expect(toolDescriptions.get("resolve_comment")).toContain("Supported comment entities: estimate, resource."); expect(toolDescriptions.get("list_notifications")).toContain("current user"); expect(toolDescriptions.get("get_unread_notification_count")).toContain("current user"); expect(toolDescriptions.get("list_tasks")).toContain("current user"); @@ -989,8 +44,8 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("send_broadcast")).toContain("Manager or admin role"); expect(toolDescriptions.get("list_broadcasts")).toContain("Manager or admin role"); expect(toolDescriptions.get("get_broadcast_detail")).toContain("Manager or admin role"); - expect(toolDescriptions.get("create_client")).toContain("manager or admin role"); - expect(toolDescriptions.get("update_client")).toContain("manager or admin role"); + expect(toolDescriptions.get("create_client")).toContain("Requires manager or admin role"); + expect(toolDescriptions.get("update_client")).toContain("Requires manager or admin role"); expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role"); expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role"); expect(toolDescriptions.get("query_change_history")).toContain("Controller/manager/admin"); @@ -998,7 +53,7 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("export_resources_csv")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("export_projects_csv")).toContain("Controller/manager/admin"); expect(toolDescriptions.get("import_csv_data")).toContain("importData"); - expect(toolDescriptions.get("import_csv_data")).toContain("manager/admin"); + expect(toolDescriptions.get("import_csv_data")).toContain("defaults to dry-run"); expect(toolDescriptions.get("list_dispo_import_batches")).toContain("Admin role"); expect(toolDescriptions.get("get_dispo_import_batch")).toContain("Admin role"); expect(toolDescriptions.get("stage_dispo_import_batch")).toContain("Admin role"); @@ -1006,10 +61,10 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("commit_dispo_import_batch")).toContain("Always confirm first"); expect(toolDescriptions.get("get_system_settings")).toContain("Admin role"); expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first"); - expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role"); - expect(toolDescriptions.get("list_system_role_configs")).toContain("Admin role"); expect(toolDescriptions.get("update_system_settings")).toContain("Runtime secrets must be provisioned"); expect(toolDescriptions.get("clear_stored_runtime_secrets")).toContain("deployment secret management"); + expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role required"); + expect(toolDescriptions.get("list_system_role_configs")).toContain("Admin role required"); expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role"); expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked"); expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first"); @@ -1029,48 +84,6 @@ describe("assistant router tool gating", () => { expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin"); }); - it("aligns assistant tool visibility with router role and permission rules", () => { - const managerWithRolePermission = getToolNames( - [PermissionKey.MANAGE_ROLES], - SystemRole.MANAGER, - ); - const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER); - - expect(managerWithRolePermission).toContain("create_role"); - expect(managerWithRolePermission).toContain("update_role"); - expect(managerWithRolePermission).toContain("delete_role"); - expect(managerWithRolePermission).toContain("create_client"); - expect(managerWithRolePermission).toContain("update_client"); - expect(managerWithRolePermission).not.toContain("create_org_unit"); - expect(managerWithRolePermission).not.toContain("update_org_unit"); - - expect(managerWithoutRolePermission).not.toContain("create_role"); - expect(managerWithoutRolePermission).not.toContain("update_role"); - expect(managerWithoutRolePermission).not.toContain("delete_role"); - expect(managerWithoutRolePermission).toContain("create_client"); - expect(managerWithoutRolePermission).toContain("update_client"); - - const adminWithRolePermission = getToolNames( - [PermissionKey.MANAGE_ROLES], - SystemRole.ADMIN, - ); - expect(adminWithRolePermission).toContain("create_org_unit"); - expect(adminWithRolePermission).toContain("update_org_unit"); - - const standardUserTools = getToolNames([], SystemRole.USER); - expect(standardUserTools).toContain("get_vacation_balance"); - expect(standardUserTools).toContain("create_vacation"); - expect(standardUserTools).toContain("cancel_vacation"); - expect(standardUserTools).not.toContain("approve_vacation"); - expect(standardUserTools).not.toContain("reject_vacation"); - expect(standardUserTools).not.toContain("set_entitlement"); - - const managerVacationTools = getToolNames([], SystemRole.MANAGER); - expect(managerVacationTools).toContain("approve_vacation"); - expect(managerVacationTools).toContain("reject_vacation"); - expect(managerVacationTools).toContain("set_entitlement"); - }); - it("keeps estimate tool parameter enums aligned with the current estimate schema", () => { const definitionByName = new Map( TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.function]), diff --git a/packages/api/src/__tests__/assistant-tool-policy.test.ts b/packages/api/src/__tests__/assistant-tool-policy.test.ts new file mode 100644 index 0000000..8f9845d --- /dev/null +++ b/packages/api/src/__tests__/assistant-tool-policy.test.ts @@ -0,0 +1,631 @@ +import { describe, expect, it } from "vitest"; +import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; +import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js"; +import { TOOL_DEFINITIONS } from "../router/assistant-tools.js"; + +function getToolNames( + permissions: PermissionKeyValue[], + userRole: SystemRole = SystemRole.ADMIN, +) { + return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name); +} + +describe("assistant tool policy", () => { + it("hides advanced tools unless the dedicated assistant permission is granted", () => { + const withoutAdvanced = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + ]); + const withAdvanced = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ]); + + expect(withoutAdvanced).not.toContain("find_best_project_resource"); + expect(withAdvanced).toContain("find_best_project_resource"); + expect(withAdvanced).toContain("get_chargeability_report"); + expect(withAdvanced).toContain("get_resource_computation_graph"); + expect(withAdvanced).toContain("get_project_computation_graph"); + }); + + it("keeps user self-service tools available to plain authenticated users", () => { + const userNames = getToolNames([], SystemRole.USER); + + expect(userNames).toContain("get_current_user"); + expect(userNames).toContain("get_dashboard_layout"); + expect(userNames).toContain("save_dashboard_layout"); + expect(userNames).toContain("get_favorite_project_ids"); + expect(userNames).toContain("toggle_favorite_project"); + expect(userNames).toContain("get_column_preferences"); + expect(userNames).toContain("set_column_preferences"); + expect(userNames).toContain("get_mfa_status"); + expect(userNames).toContain("list_notifications"); + expect(userNames).toContain("get_unread_notification_count"); + expect(userNames).toContain("list_tasks"); + expect(userNames).toContain("get_task_counts"); + expect(userNames).toContain("create_reminder"); + expect(userNames).toContain("list_reminders"); + expect(userNames).toContain("update_reminder"); + expect(userNames).toContain("delete_reminder"); + }); + + it("keeps admin-only user tools hidden from non-admin roles", () => { + const adminNames = getToolNames([], SystemRole.ADMIN); + const managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + + expect(adminNames).toContain("list_users"); + expect(adminNames).toContain("get_active_user_count"); + expect(adminNames).toContain("create_user"); + expect(adminNames).toContain("set_user_password"); + expect(adminNames).toContain("update_user_role"); + expect(adminNames).toContain("update_user_name"); + expect(adminNames).toContain("link_user_resource"); + expect(adminNames).toContain("auto_link_users_by_email"); + expect(adminNames).toContain("set_user_permissions"); + expect(adminNames).toContain("reset_user_permissions"); + expect(adminNames).toContain("get_effective_user_permissions"); + expect(adminNames).toContain("disable_user_totp"); + + expect(managerNames).not.toContain("list_users"); + expect(managerNames).not.toContain("create_user"); + expect(managerNames).not.toContain("set_user_permissions"); + expect(managerNames).not.toContain("disable_user_totp"); + expect(userNames).not.toContain("list_users"); + expect(userNames).not.toContain("get_active_user_count"); + expect(userNames).not.toContain("create_user"); + expect(userNames).not.toContain("set_user_password"); + expect(userNames).not.toContain("update_user_role"); + expect(userNames).not.toContain("update_user_name"); + expect(userNames).not.toContain("link_user_resource"); + expect(userNames).not.toContain("auto_link_users_by_email"); + expect(userNames).not.toContain("set_user_permissions"); + expect(userNames).not.toContain("reset_user_permissions"); + expect(userNames).not.toContain("get_effective_user_permissions"); + expect(userNames).not.toContain("disable_user_totp"); + }); + + it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => { + const managerNames = getToolNames([], SystemRole.MANAGER); + const adminNames = getToolNames([], SystemRole.ADMIN); + const userNames = getToolNames([], SystemRole.USER); + + expect(managerNames).toContain("list_assignable_users"); + expect(managerNames).toContain("create_notification"); + expect(managerNames).toContain("create_task_for_user"); + expect(managerNames).toContain("assign_task"); + expect(managerNames).toContain("send_broadcast"); + expect(managerNames).toContain("list_broadcasts"); + expect(managerNames).toContain("get_broadcast_detail"); + expect(adminNames).toContain("list_assignable_users"); + expect(adminNames).toContain("create_task_for_user"); + expect(adminNames).toContain("send_broadcast"); + expect(userNames).not.toContain("list_assignable_users"); + expect(userNames).not.toContain("create_notification"); + expect(userNames).not.toContain("create_task_for_user"); + expect(userNames).not.toContain("assign_task"); + expect(userNames).not.toContain("send_broadcast"); + expect(userNames).not.toContain("list_broadcasts"); + expect(userNames).not.toContain("get_broadcast_detail"); + }); + + it("continues to hide cost-aware advanced tools when viewCosts is missing", () => { + const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]); + + expect(names).not.toContain("find_best_project_resource"); + expect(names).not.toContain("get_chargeability_report"); + expect(names).not.toContain("get_resource_computation_graph"); + expect(names).not.toContain("get_project_computation_graph"); + }); + + it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => { + const controllerNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.CONTROLLER); + const userNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.USER); + + expect(controllerNames).toContain("query_change_history"); + expect(controllerNames).toContain("get_entity_timeline"); + expect(controllerNames).toContain("search_by_skill"); + expect(controllerNames).toContain("export_resources_csv"); + expect(controllerNames).toContain("export_projects_csv"); + expect(controllerNames).toContain("list_audit_log_entries"); + expect(controllerNames).toContain("get_audit_log_entry"); + expect(controllerNames).toContain("get_audit_log_timeline"); + expect(controllerNames).toContain("get_audit_activity_summary"); + expect(controllerNames).toContain("get_chargeability_report"); + expect(controllerNames).toContain("get_resource_computation_graph"); + expect(controllerNames).toContain("get_project_computation_graph"); + expect(userNames).not.toContain("query_change_history"); + expect(userNames).not.toContain("get_entity_timeline"); + expect(userNames).not.toContain("search_by_skill"); + expect(userNames).not.toContain("export_resources_csv"); + expect(userNames).not.toContain("export_projects_csv"); + expect(userNames).not.toContain("list_audit_log_entries"); + expect(userNames).not.toContain("get_audit_log_entry"); + expect(userNames).not.toContain("get_audit_log_timeline"); + expect(userNames).not.toContain("get_audit_activity_summary"); + expect(userNames).not.toContain("get_chargeability_report"); + expect(userNames).not.toContain("get_resource_computation_graph"); + expect(userNames).not.toContain("get_project_computation_graph"); + }); + + it("keeps entity-scoped comment tools available to plain authenticated users", () => { + const userNames = getToolNames([], SystemRole.USER); + + expect(userNames).toContain("list_comments"); + expect(userNames).toContain("create_comment"); + expect(userNames).toContain("resolve_comment"); + }); + + it("keeps planning read tools behind the explicit planning permission", () => { + const userWithoutPlanning = getToolNames([], SystemRole.USER); + const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); + + expect(userWithoutPlanning).not.toContain("list_allocations"); + expect(userWithoutPlanning).not.toContain("list_demands"); + expect(userWithoutPlanning).not.toContain("list_blueprints"); + expect(userWithoutPlanning).not.toContain("get_blueprint"); + expect(userWithoutPlanning).not.toContain("list_clients"); + expect(userWithoutPlanning).not.toContain("list_roles"); + expect(userWithoutPlanning).not.toContain("list_management_levels"); + expect(userWithoutPlanning).not.toContain("list_utilization_categories"); + expect(userWithoutPlanning).not.toContain("check_resource_availability"); + expect(userWithoutPlanning).not.toContain("find_capacity"); + expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); + expect(userWithoutPlanning).not.toContain("find_best_project_resource"); + expect(userWithPlanning).toContain("list_allocations"); + expect(userWithPlanning).toContain("list_demands"); + expect(userWithPlanning).toContain("list_blueprints"); + expect(userWithPlanning).toContain("get_blueprint"); + expect(userWithPlanning).toContain("list_clients"); + expect(userWithPlanning).toContain("list_roles"); + expect(userWithPlanning).toContain("list_management_levels"); + expect(userWithPlanning).toContain("list_utilization_categories"); + expect(userWithPlanning).toContain("check_resource_availability"); + expect(userWithPlanning).toContain("find_capacity"); + expect(userWithPlanning).not.toContain("get_staffing_suggestions"); + expect(userWithPlanning).not.toContain("find_best_project_resource"); + }); + + it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => { + const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); + const planningAndCosts = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + ], SystemRole.USER); + const planningCostsAndAdvanced = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.USER); + + expect(planningOnly).not.toContain("get_staffing_suggestions"); + expect(planningOnly).not.toContain("find_best_project_resource"); + expect(planningAndCosts).toContain("get_staffing_suggestions"); + expect(planningAndCosts).not.toContain("find_best_project_resource"); + expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions"); + expect(planningCostsAndAdvanced).toContain("find_best_project_resource"); + }); + + it("keeps controller-only project and dashboard reads hidden from plain users", () => { + const controllerNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.CONTROLLER); + const userNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + PermissionKey.VIEW_PLANNING, + ], SystemRole.USER); + + expect(controllerNames).toContain("search_projects"); + expect(controllerNames).toContain("get_project"); + expect(controllerNames).toContain("get_statistics"); + expect(controllerNames).toContain("get_dashboard_detail"); + expect(controllerNames).toContain("get_skill_gaps"); + expect(controllerNames).toContain("get_project_health"); + expect(controllerNames).toContain("get_budget_forecast"); + expect(controllerNames).toContain("get_budget_status"); + expect(controllerNames).toContain("get_shoring_ratio"); + expect(userNames).not.toContain("search_projects"); + expect(userNames).not.toContain("get_project"); + expect(userNames).not.toContain("get_statistics"); + expect(userNames).not.toContain("get_dashboard_detail"); + expect(userNames).not.toContain("get_skill_gaps"); + expect(userNames).not.toContain("get_project_health"); + expect(userNames).not.toContain("get_budget_forecast"); + expect(userNames).not.toContain("get_budget_status"); + expect(userNames).not.toContain("get_shoring_ratio"); + }); + + it("keeps legacy controller-only analysis and report tools hidden from plain users", () => { + const controllerNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.VIEW_PLANNING, + ], SystemRole.CONTROLLER); + const userNames = getToolNames([ + PermissionKey.VIEW_COSTS, + PermissionKey.VIEW_PLANNING, + ], SystemRole.USER); + + expect(controllerNames).toContain("detect_anomalies"); + expect(controllerNames).toContain("get_insights_summary"); + expect(controllerNames).toContain("run_report"); + expect(controllerNames).toContain("lookup_rate"); + expect(controllerNames).toContain("simulate_scenario"); + expect(controllerNames).toContain("generate_project_narrative"); + expect(controllerNames).toContain("list_rate_cards"); + expect(controllerNames).toContain("resolve_rate"); + expect(userNames).not.toContain("detect_anomalies"); + expect(userNames).not.toContain("get_insights_summary"); + expect(userNames).not.toContain("run_report"); + expect(userNames).not.toContain("lookup_rate"); + expect(userNames).not.toContain("simulate_scenario"); + expect(userNames).not.toContain("generate_project_narrative"); + expect(userNames).not.toContain("list_rate_cards"); + expect(userNames).not.toContain("resolve_rate"); + }); + + it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => { + const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER); + const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER); + + expect(controllerWithoutCosts).not.toContain("list_rate_cards"); + expect(controllerWithoutCosts).not.toContain("resolve_rate"); + expect(controllerWithCosts).toContain("list_rate_cards"); + expect(controllerWithCosts).toContain("resolve_rate"); + }); + + it("requires both controller role and advanced assistant access for timeline detail tools", () => { + const controllerWithAdvanced = getToolNames([ + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.CONTROLLER); + const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER); + const userWithAdvanced = getToolNames([ + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.USER); + + expect(controllerWithAdvanced).toContain("get_timeline_entries_view"); + expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays"); + expect(controllerWithAdvanced).toContain("get_project_timeline_context"); + expect(controllerWithAdvanced).toContain("preview_project_shift"); + expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view"); + expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays"); + expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context"); + expect(controllerWithoutAdvanced).not.toContain("preview_project_shift"); + expect(userWithAdvanced).not.toContain("get_timeline_entries_view"); + expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays"); + expect(userWithAdvanced).not.toContain("get_project_timeline_context"); + expect(userWithAdvanced).not.toContain("preview_project_shift"); + }); + + it("exposes self-service timeline tools to authenticated users without advanced assistant access", () => { + const userNames = getToolNames([], SystemRole.USER); + const viewerNames = getToolNames([], SystemRole.VIEWER); + const controllerNames = getToolNames([], SystemRole.CONTROLLER); + + expect(userNames).toContain("get_my_timeline_entries_view"); + expect(userNames).toContain("get_my_timeline_holiday_overlays"); + expect(viewerNames).toContain("get_my_timeline_entries_view"); + expect(viewerNames).toContain("get_my_timeline_holiday_overlays"); + expect(controllerNames).toContain("get_my_timeline_entries_view"); + expect(controllerNames).toContain("get_my_timeline_holiday_overlays"); + }); + + it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => { + const managerNames = getToolNames([ + PermissionKey.MANAGE_ALLOCATIONS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.MANAGER); + const userNames = getToolNames([ + PermissionKey.MANAGE_ALLOCATIONS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.USER); + const missingAdvancedNames = getToolNames([ + PermissionKey.MANAGE_ALLOCATIONS, + ], SystemRole.MANAGER); + + expect(managerNames).toContain("update_timeline_allocation_inline"); + expect(managerNames).toContain("apply_timeline_project_shift"); + expect(managerNames).toContain("quick_assign_timeline_resource"); + expect(managerNames).toContain("batch_quick_assign_timeline_resources"); + expect(managerNames).toContain("batch_shift_timeline_allocations"); + expect(userNames).not.toContain("update_timeline_allocation_inline"); + expect(userNames).not.toContain("apply_timeline_project_shift"); + expect(userNames).not.toContain("quick_assign_timeline_resource"); + expect(userNames).not.toContain("batch_quick_assign_timeline_resources"); + expect(userNames).not.toContain("batch_shift_timeline_allocations"); + expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline"); + expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource"); + }); + + it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => { + const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER); + const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER); + const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER); + + expect(managerProjectNames).toContain("create_estimate"); + expect(managerProjectNames).toContain("clone_estimate"); + expect(managerProjectNames).toContain("update_estimate_draft"); + expect(managerProjectNames).toContain("submit_estimate_version"); + expect(managerProjectNames).toContain("approve_estimate_version"); + expect(managerProjectNames).toContain("create_estimate_revision"); + expect(managerProjectNames).toContain("create_estimate_export"); + expect(managerProjectNames).toContain("generate_estimate_weekly_phasing"); + expect(managerProjectNames).toContain("update_estimate_commercial_terms"); + expect(managerProjectNames).not.toContain("create_estimate_planning_handoff"); + expect(managerAllocationNames).toContain("create_estimate_planning_handoff"); + expect(managerAllocationNames).not.toContain("create_estimate"); + expect(userProjectNames).not.toContain("create_estimate"); + expect(userProjectNames).not.toContain("clone_estimate"); + expect(userProjectNames).not.toContain("update_estimate_draft"); + expect(userProjectNames).not.toContain("submit_estimate_version"); + expect(userProjectNames).not.toContain("approve_estimate_version"); + expect(userProjectNames).not.toContain("create_estimate_revision"); + expect(userProjectNames).not.toContain("create_estimate_export"); + expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing"); + expect(userProjectNames).not.toContain("update_estimate_commercial_terms"); + expect(userProjectNames).not.toContain("create_estimate_planning_handoff"); + }); + + it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => { + const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER); + const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER); + const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER); + const managerWithoutCosts = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER); + + expect(controllerNames).toContain("search_estimates"); + expect(controllerNames).toContain("get_estimate_detail"); + expect(controllerNames).toContain("list_estimate_versions"); + expect(controllerNames).toContain("get_estimate_version_snapshot"); + expect(controllerNames).toContain("get_estimate_weekly_phasing"); + expect(controllerNames).toContain("get_estimate_commercial_terms"); + expect(controllerWithoutCosts).toContain("search_estimates"); + expect(controllerWithoutCosts).not.toContain("get_estimate_detail"); + expect(controllerWithoutCosts).toContain("list_estimate_versions"); + expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot"); + expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing"); + expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms"); + expect(managerNames).toContain("search_estimates"); + expect(managerNames).toContain("get_estimate_detail"); + expect(managerNames).toContain("list_estimate_versions"); + expect(managerNames).toContain("get_estimate_version_snapshot"); + expect(managerNames).toContain("get_estimate_weekly_phasing"); + expect(managerNames).toContain("get_estimate_commercial_terms"); + expect(managerWithoutCosts).toContain("search_estimates"); + expect(managerWithoutCosts).toContain("list_estimate_versions"); + expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot"); + expect(userNames).not.toContain("search_estimates"); + expect(userNames).not.toContain("get_estimate_detail"); + expect(userNames).not.toContain("list_estimate_versions"); + expect(userNames).not.toContain("get_estimate_version_snapshot"); + expect(userNames).not.toContain("get_estimate_weekly_phasing"); + expect(userNames).not.toContain("get_estimate_commercial_terms"); + }); + + it("keeps import/dispo parity tools aligned to router roles and permissions", () => { + const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER); + const controllerNames = getToolNames([], SystemRole.CONTROLLER); + const adminNames = getToolNames([], SystemRole.ADMIN); + const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER); + + expect(managerNames).toContain("import_csv_data"); + expect(controllerNames).toContain("export_resources_csv"); + expect(controllerNames).toContain("export_projects_csv"); + expect(adminNames).toContain("list_dispo_import_batches"); + expect(adminNames).toContain("get_dispo_import_batch"); + expect(adminNames).toContain("stage_dispo_import_batch"); + expect(adminNames).toContain("validate_dispo_import_batch"); + expect(adminNames).toContain("cancel_dispo_import_batch"); + expect(adminNames).toContain("list_dispo_staged_resources"); + expect(adminNames).toContain("list_dispo_staged_projects"); + expect(adminNames).toContain("list_dispo_staged_assignments"); + expect(adminNames).toContain("list_dispo_staged_vacations"); + expect(adminNames).toContain("list_dispo_staged_unresolved_records"); + expect(adminNames).toContain("resolve_dispo_staged_record"); + expect(adminNames).toContain("commit_dispo_import_batch"); + expect(userNames).not.toContain("import_csv_data"); + expect(userNames).not.toContain("export_resources_csv"); + expect(userNames).not.toContain("export_projects_csv"); + expect(userNames).not.toContain("list_dispo_import_batches"); + expect(userNames).not.toContain("get_dispo_import_batch"); + expect(userNames).not.toContain("stage_dispo_import_batch"); + expect(userNames).not.toContain("validate_dispo_import_batch"); + expect(userNames).not.toContain("list_dispo_staged_resources"); + expect(userNames).not.toContain("commit_dispo_import_batch"); + }); + + it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => { + const adminNames = getToolNames([], SystemRole.ADMIN); + const managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + + expect(adminNames).toContain("get_system_settings"); + expect(adminNames).toContain("update_system_settings"); + expect(adminNames).toContain("clear_stored_runtime_secrets"); + expect(adminNames).toContain("test_ai_connection"); + expect(adminNames).toContain("test_smtp_connection"); + expect(adminNames).toContain("test_gemini_connection"); + expect(adminNames).toContain("list_system_role_configs"); + expect(adminNames).toContain("update_system_role_config"); + expect(adminNames).toContain("list_webhooks"); + expect(adminNames).toContain("get_webhook"); + expect(adminNames).toContain("create_webhook"); + expect(adminNames).toContain("update_webhook"); + expect(adminNames).toContain("delete_webhook"); + expect(adminNames).toContain("test_webhook"); + expect(adminNames).toContain("get_ai_configured"); + + expect(managerNames).not.toContain("get_system_settings"); + expect(managerNames).not.toContain("update_system_settings"); + expect(managerNames).not.toContain("clear_stored_runtime_secrets"); + expect(managerNames).not.toContain("test_ai_connection"); + expect(managerNames).not.toContain("get_ai_configured"); + expect(managerNames).not.toContain("list_system_role_configs"); + expect(managerNames).not.toContain("update_system_role_config"); + expect(managerNames).not.toContain("list_webhooks"); + expect(managerNames).not.toContain("create_webhook"); + + expect(userNames).not.toContain("get_system_settings"); + expect(userNames).not.toContain("update_system_settings"); + expect(userNames).not.toContain("clear_stored_runtime_secrets"); + expect(userNames).not.toContain("test_ai_connection"); + expect(userNames).not.toContain("get_ai_configured"); + expect(userNames).not.toContain("list_system_role_configs"); + expect(userNames).not.toContain("update_system_role_config"); + expect(userNames).not.toContain("list_webhooks"); + expect(userNames).not.toContain("create_webhook"); + }); + + it("keeps client deletion admin-only while still allowing manager client maintenance", () => { + const adminNames = getToolNames([], SystemRole.ADMIN); + const managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + + expect(adminNames).toContain("create_client"); + expect(adminNames).toContain("update_client"); + expect(adminNames).toContain("delete_client"); + expect(managerNames).toContain("create_client"); + expect(managerNames).toContain("update_client"); + expect(managerNames).not.toContain("delete_client"); + expect(userNames).not.toContain("create_client"); + expect(userNames).not.toContain("update_client"); + expect(userNames).not.toContain("delete_client"); + }); + + it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => { + const adminNames = getToolNames([], SystemRole.ADMIN); + const managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + + expect(adminNames).toContain("list_holiday_calendars"); + expect(adminNames).toContain("get_holiday_calendar"); + expect(adminNames).toContain("preview_resolved_holiday_calendar"); + expect(adminNames).toContain("create_holiday_calendar"); + expect(managerNames).not.toContain("list_holiday_calendars"); + expect(managerNames).not.toContain("get_holiday_calendar"); + expect(managerNames).toContain("preview_resolved_holiday_calendar"); + expect(userNames).not.toContain("list_holiday_calendars"); + expect(userNames).not.toContain("get_holiday_calendar"); + expect(userNames).toContain("preview_resolved_holiday_calendar"); + expect(managerNames).not.toContain("create_holiday_calendar"); + expect(managerNames).not.toContain("update_holiday_calendar"); + expect(managerNames).not.toContain("delete_holiday_calendar"); + expect(managerNames).not.toContain("create_holiday_calendar_entry"); + expect(managerNames).not.toContain("update_holiday_calendar_entry"); + expect(managerNames).not.toContain("delete_holiday_calendar_entry"); + }); + + it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => { + const adminNames = getToolNames([], SystemRole.ADMIN); + const managerNames = getToolNames([], SystemRole.MANAGER); + const userNames = getToolNames([], SystemRole.USER); + const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER); + const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER); + + expect(adminNames).toContain("list_countries"); + expect(adminNames).toContain("create_country"); + expect(adminNames).toContain("update_country"); + expect(adminNames).toContain("create_metro_city"); + expect(adminNames).toContain("update_metro_city"); + expect(adminNames).toContain("delete_metro_city"); + expect(managerNames).toContain("list_countries"); + expect(managerNames).not.toContain("create_country"); + expect(managerNames).not.toContain("update_country"); + expect(managerNames).not.toContain("create_metro_city"); + expect(managerNames).not.toContain("update_metro_city"); + expect(managerNames).not.toContain("delete_metro_city"); + expect(userNames).not.toContain("search_resources"); + expect(userNames).not.toContain("get_country"); + expect(userNames).not.toContain("list_org_units"); + expect(userWithResourceOverview).toContain("search_resources"); + expect(userWithResourceOverview).toContain("get_country"); + expect(userWithResourceOverview).toContain("list_org_units"); + expect(userWithManagedResources).toContain("search_resources"); + expect(userWithManagedResources).toContain("get_country"); + expect(userWithManagedResources).toContain("list_org_units"); + }); + + it("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => { + const toolAccess = new Map( + TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]), + ); + + expect(toolAccess.get("run_report")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("simulate_scenario")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("detect_anomalies")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("get_insights_summary")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("lookup_rate")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + }); + expect(toolAccess.get("list_rate_cards")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiresCostView: true, + }); + expect(toolAccess.get("resolve_rate")).toEqual({ + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER], + requiresCostView: true, + }); + expect(toolAccess.get("import_csv_data")).toEqual({ + requiredPermissions: [PermissionKey.IMPORT_DATA], + allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER], + }); + }); + + it("aligns assistant tool visibility with router role and permission rules", () => { + const managerWithRolePermission = getToolNames( + [PermissionKey.MANAGE_ROLES], + SystemRole.MANAGER, + ); + const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER); + + expect(managerWithRolePermission).toContain("create_role"); + expect(managerWithRolePermission).toContain("update_role"); + expect(managerWithRolePermission).toContain("delete_role"); + expect(managerWithRolePermission).toContain("create_client"); + expect(managerWithRolePermission).toContain("update_client"); + expect(managerWithRolePermission).not.toContain("create_org_unit"); + expect(managerWithRolePermission).not.toContain("update_org_unit"); + + expect(managerWithoutRolePermission).not.toContain("create_role"); + expect(managerWithoutRolePermission).not.toContain("update_role"); + expect(managerWithoutRolePermission).not.toContain("delete_role"); + expect(managerWithoutRolePermission).toContain("create_client"); + expect(managerWithoutRolePermission).toContain("update_client"); + + const adminWithRolePermission = getToolNames( + [PermissionKey.MANAGE_ROLES], + SystemRole.ADMIN, + ); + expect(adminWithRolePermission).toContain("create_org_unit"); + expect(adminWithRolePermission).toContain("update_org_unit"); + + const standardUserTools = getToolNames([], SystemRole.USER); + expect(standardUserTools).toContain("get_vacation_balance"); + expect(standardUserTools).toContain("create_vacation"); + expect(standardUserTools).toContain("cancel_vacation"); + expect(standardUserTools).not.toContain("approve_vacation"); + expect(standardUserTools).not.toContain("reject_vacation"); + expect(standardUserTools).not.toContain("set_entitlement"); + + const managerVacationTools = getToolNames([], SystemRole.MANAGER); + expect(managerVacationTools).toContain("approve_vacation"); + expect(managerVacationTools).toContain("reject_vacation"); + expect(managerVacationTools).toContain("set_entitlement"); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tool-results.test.ts b/packages/api/src/__tests__/assistant-tool-results.test.ts new file mode 100644 index 0000000..9fd0c5a --- /dev/null +++ b/packages/api/src/__tests__/assistant-tool-results.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { readToolError, readToolSuccessMessage } from "../router/assistant-tool-results.js"; + +describe("assistant tool results", () => { + it("reads structured tool errors from result data or JSON content", () => { + expect(readToolError({ + content: "", + data: { error: "Forbidden" }, + })).toBe("Forbidden"); + + expect(readToolError({ + content: JSON.stringify({ error: "Validation failed" }), + })).toBe("Validation failed"); + + expect(readToolError({ + content: "plain text", + data: { ok: true }, + })).toBeNull(); + }); + + it("prefers structured success messages and falls back to plain content", () => { + expect(readToolSuccessMessage({ + content: "", + data: { message: "Created project Apollo" }, + })).toBe("Created project Apollo"); + + expect(readToolSuccessMessage({ + content: JSON.stringify({ description: "Updated allocation" }), + })).toBe("Updated allocation"); + + expect(readToolSuccessMessage({ + content: "Fallback tool response", + })).toBe("Fallback tool response"); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tool-selection.test.ts b/packages/api/src/__tests__/assistant-tool-selection.test.ts new file mode 100644 index 0000000..1997a5f --- /dev/null +++ b/packages/api/src/__tests__/assistant-tool-selection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared"; +import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js"; +import { selectAssistantToolsForRequest } from "../router/assistant-tool-selection.js"; + +function getSelectedToolNames( + permissions: PermissionKeyValue[], + messages: Array<{ role: "user" | "assistant"; content: string }>, + userRole: SystemRole = SystemRole.ADMIN, + pageContext?: string, +) { + return selectAssistantToolsForRequest( + getAvailableAssistantTools(new Set(permissions), userRole), + messages, + pageContext, + ).map((tool) => tool.function.name); +} + +describe("assistant tool selection", () => { + it("caps the OpenAI tool payload to 128 definitions even for fully privileged admins", () => { + const allPermissions = Object.values(PermissionKey); + const selectedNames = getSelectedToolNames( + allPermissions, + [{ role: "user", content: "Bitte gib mir einen Überblick über das System." }], + SystemRole.ADMIN, + ); + + expect(selectedNames.length).toBeLessThanOrEqual(128); + expect(selectedNames).toContain("get_current_user"); + expect(selectedNames).toContain("search_resources"); + expect(selectedNames).toContain("search_projects"); + }); + + it("prioritizes holiday and resource tools for German holiday questions", () => { + const allPermissions = Object.values(PermissionKey); + const selectedNames = getSelectedToolNames( + allPermissions, + [{ role: "user", content: "Kannst du mir alle Feiertage nennen, die Peter Parker in 2026 zustehen?" }], + SystemRole.ADMIN, + ); + + expect(selectedNames.length).toBeLessThanOrEqual(128); + expect(selectedNames).toContain("search_resources"); + expect(selectedNames).toContain("get_resource"); + expect(selectedNames).toContain("get_resource_holidays"); + expect(selectedNames).toContain("list_holidays_by_region"); + expect(selectedNames).toContain("list_holiday_calendars"); + }); +}); diff --git a/packages/api/src/router/assistant-approvals.ts b/packages/api/src/router/assistant-approvals.ts new file mode 100644 index 0000000..d776d44 --- /dev/null +++ b/packages/api/src/router/assistant-approvals.ts @@ -0,0 +1,284 @@ +import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db"; +import { logger } from "../lib/logger.js"; +import { buildApprovalSummary } from "./assistant-confirmation.js"; + +const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000; +const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals"; +let hasLoggedAssistantApprovalStorageUnavailable = false; + +export type AssistantApprovalStore = Pick; + +export class AssistantApprovalStorageUnavailableError extends Error { + constructor() { + super("Assistant approval storage is unavailable."); + this.name = "AssistantApprovalStorageUnavailableError"; + } +} + +export interface PendingAssistantApproval { + id: string; + userId: string; + conversationId: string; + toolName: string; + toolArguments: string; + summary: string; + createdAt: number; + expiresAt: number; +} + +export interface AssistantApprovalPayload { + id: string; + status: "pending" | "approved" | "cancelled"; + conversationId: string; + toolName: string; + summary: string; + createdAt: string; + expiresAt: string; +} + +type AssistantApprovalRecord = { + id: string; + userId: string; + conversationId: string; + toolName: string; + toolArguments: string; + summary: string; + createdAt: Date; + expiresAt: Date; +}; + +function mapPendingApproval(record: AssistantApprovalRecord): PendingAssistantApproval { + return { + id: record.id, + userId: record.userId, + conversationId: record.conversationId, + toolName: record.toolName, + toolArguments: record.toolArguments, + summary: record.summary, + createdAt: record.createdAt.getTime(), + expiresAt: record.expiresAt.getTime(), + }; +} + +export function toApprovalPayload( + approval: PendingAssistantApproval, + status: AssistantApprovalPayload["status"], +): AssistantApprovalPayload { + return { + id: approval.id, + status, + conversationId: approval.conversationId, + toolName: approval.toolName, + summary: approval.summary, + createdAt: new Date(approval.createdAt).toISOString(), + expiresAt: new Date(approval.expiresAt).toISOString(), + }; +} + +function isAssistantApprovalTableMissingError(error: unknown): boolean { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code !== "P2021") return false; + const table = typeof error.meta?.table === "string" ? error.meta.table : ""; + return table.includes("assistant_approvals") || error.message.includes("assistant_approvals"); + } + + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + + const candidate = error as { + code?: unknown; + message?: unknown; + meta?: { + table?: unknown; + }; + }; + const code = typeof candidate.code === "string" ? candidate.code : ""; + if (code !== "P2021") return false; + + const message = typeof candidate.message === "string" + ? candidate.message + : ""; + const metaTable = typeof candidate.meta?.table === "string" + ? candidate.meta.table + : ""; + + return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals"); +} + +function logAssistantApprovalStorageUnavailable(error: unknown) { + if (hasLoggedAssistantApprovalStorageUnavailable) { + return; + } + hasLoggedAssistantApprovalStorageUnavailable = true; + logger.warn( + { + err: error, + table: ASSISTANT_APPROVALS_TABLE_NAME, + }, + "Assistant approval storage is unavailable", + ); +} + +async function withAssistantApprovalFallback( + operation: () => Promise, + fallback: () => T, +): Promise { + try { + return await operation(); + } catch (error) { + if (!isAssistantApprovalTableMissingError(error)) throw error; + logAssistantApprovalStorageUnavailable(error); + return fallback(); + } +} + +export function resetAssistantApprovalStorageWarningStateForTests(): void { + hasLoggedAssistantApprovalStorageUnavailable = false; +} + +export async function listPendingAssistantApprovals( + db: AssistantApprovalStore, + userId: string, +): Promise { + return withAssistantApprovalFallback(async () => { + await db.assistantApproval.updateMany({ + where: { + userId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { lte: new Date() }, + }, + data: { + status: AssistantApprovalStatus.EXPIRED, + }, + }); + + const approvals = await db.assistantApproval.findMany({ + where: { + userId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "desc" }, + }); + + return approvals.map(mapPendingApproval); + }, () => []); +} + +export async function clearPendingAssistantApproval( + db: AssistantApprovalStore, + userId: string, + conversationId: string, +): Promise { + await withAssistantApprovalFallback(async () => { + await db.assistantApproval.updateMany({ + where: { + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + }, + data: { + status: AssistantApprovalStatus.CANCELLED, + cancelledAt: new Date(), + }, + }); + }, () => undefined); +} + +export async function peekPendingAssistantApproval( + db: AssistantApprovalStore, + userId: string, + conversationId: string, +): Promise { + return withAssistantApprovalFallback(async () => { + await db.assistantApproval.updateMany({ + where: { + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { lte: new Date() }, + }, + data: { + status: AssistantApprovalStatus.EXPIRED, + }, + }); + + const pending = await db.assistantApproval.findFirst({ + where: { + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + }, + orderBy: { createdAt: "desc" }, + }); + if (!pending) return null; + return mapPendingApproval(pending); + }, () => null); +} + +export async function consumePendingAssistantApproval( + db: AssistantApprovalStore, + userId: string, + conversationId: string, +): Promise { + const pending = await peekPendingAssistantApproval(db, userId, conversationId); + if (!pending) return null; + const approvedAt = new Date(); + const updateResult = await db.assistantApproval.updateMany({ + where: { + id: pending.id, + userId, + conversationId, + status: AssistantApprovalStatus.PENDING, + expiresAt: { gt: approvedAt }, + }, + data: { + status: AssistantApprovalStatus.APPROVED, + approvedAt, + }, + }); + if (updateResult.count === 0) return null; + + const approved = await db.assistantApproval.findFirst({ + where: { + id: pending.id, + userId, + conversationId, + }, + }); + if (!approved) return null; + return mapPendingApproval(approved); +} + +export async function createPendingAssistantApproval( + db: AssistantApprovalStore, + userId: string, + conversationId: string, + toolName: string, + toolArguments: string, + options?: { summary?: string; ttlMs?: number }, +): Promise { + const now = new Date(); + const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS)); + const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments); + try { + await clearPendingAssistantApproval(db, userId, conversationId); + const pendingApproval = await db.assistantApproval.create({ + data: { + userId, + conversationId, + toolName, + toolArguments, + summary, + createdAt: now, + expiresAt, + }, + }); + return mapPendingApproval(pendingApproval); + } catch (error) { + if (!isAssistantApprovalTableMissingError(error)) throw error; + logAssistantApprovalStorageUnavailable(error); + throw new AssistantApprovalStorageUnavailableError(); + } +} diff --git a/packages/api/src/router/assistant-confirmation.ts b/packages/api/src/router/assistant-confirmation.ts new file mode 100644 index 0000000..dae7eea --- /dev/null +++ b/packages/api/src/router/assistant-confirmation.ts @@ -0,0 +1,141 @@ +import { MUTATION_TOOLS } from "./assistant-tools.js"; +import type { PendingAssistantApproval } from "./assistant-approvals.js"; + +export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:"; + +export type ChatMessage = { role: "user" | "assistant"; content: string }; + +export function parseToolArguments(args: string): Record { + try { + const parsed = JSON.parse(args) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +export function formatApprovalValue(value: unknown): string { + if (typeof value === "string") { + return value.length > 48 ? `${value.slice(0, 45)}...` : value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + if (value.length === 0) return "[]"; + return `[${value.slice(0, 3).map((item) => formatApprovalValue(item)).join(", ")}${value.length > 3 ? ", ..." : ""}]`; + } + if (value && typeof value === "object") { + return "{...}"; + } + return "null"; +} + +export function buildApprovalSummary(toolName: string, toolArguments: string): string { + const params = parseToolArguments(toolArguments); + const details = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .slice(0, 4) + .map(([key, value]) => `${key}=${formatApprovalValue(value)}`) + .join(", "); + + const action = toolName.replace(/_/g, " "); + return details ? `${action} (${details})` : action; +} + +export function isAffirmativeConfirmationReply(content: string): boolean { + const normalized = content.trim().toLowerCase(); + if (!normalized) return false; + + const exactMatches = new Set([ + "ja", + "yes", + "y", + "ok", + "okay", + "okey", + "mach das", + "bitte machen", + "bitte ausführen", + "bitte ausfuehren", + "ausführen", + "ausfuehren", + "bestätigt", + "bestaetigt", + "bestätigen", + "bestaetigen", + "confirm", + "confirmed", + "do it", + "go ahead", + "proceed", + ]); + if (exactMatches.has(normalized)) return true; + + const affirmativePatterns = [ + /^(ja|yes|ok|okay)\b/, + /\b(mach|make|do|führ|fuehr|execute|run)\b.*\b(das|it|bitte|jetzt)\b/, + /\b(bit(?:te)?|please)\b.*\b(ausführen|ausfuehren|execute|run|machen|do)\b/, + /\b(bestätig|bestaetig|confirm)\w*\b/, + /\b(go ahead|proceed)\b/, + ]; + return affirmativePatterns.some((pattern) => pattern.test(normalized)); +} + +export function isCancellationReply(content: string): boolean { + const normalized = content.trim().toLowerCase(); + if (!normalized) return false; + + const exactMatches = new Set([ + "nein", + "no", + "abbrechen", + "cancel", + "stopp", + "stop", + "doch nicht", + "nicht ausführen", + "nicht ausfuehren", + ]); + if (exactMatches.has(normalized)) return true; + + return [ + /\b(nein|no|cancel|abbrechen|stop|stopp)\b/, + /\b(doch nicht|nicht ausführen|nicht ausfuehren)\b/, + ].some((pattern) => pattern.test(normalized)); +} + +export function hasPendingAssistantConfirmation(messages: ChatMessage[]): boolean { + if (messages.length < 2) return false; + const lastMessage = messages[messages.length - 1]; + if (!lastMessage || lastMessage.role !== "user") return false; + + for (let index = messages.length - 2; index >= 0; index -= 1) { + const message = messages[index]; + if (!message) continue; + if (message.role === "assistant") { + return message.content.trimStart().startsWith(ASSISTANT_CONFIRMATION_PREFIX); + } + } + + return false; +} + +export function canExecuteMutationTool( + messages: ChatMessage[], + toolName: string, + pendingApproval?: PendingAssistantApproval | null, +): boolean { + if (!MUTATION_TOOLS.has(toolName)) return true; + const lastMessage = messages[messages.length - 1]; + if (!lastMessage || lastMessage.role !== "user") return false; + if (!isAffirmativeConfirmationReply(lastMessage.content)) return false; + + if (pendingApproval) { + return pendingApproval.toolName === toolName && pendingApproval.expiresAt > Date.now(); + } + + return hasPendingAssistantConfirmation(messages); +} diff --git a/packages/api/src/router/assistant-tool-policy.ts b/packages/api/src/router/assistant-tool-policy.ts new file mode 100644 index 0000000..18c9bd1 --- /dev/null +++ b/packages/api/src/router/assistant-tool-policy.ts @@ -0,0 +1,294 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS } from "./assistant-tools.js"; +import type { ToolDef } from "./assistant-tools/shared.js"; + +const TOOL_PERMISSION_MAP: Record = { + update_resource: "manageResources", + create_resource: "manageResources", + deactivate_resource: "manageResources", + create_role: PermissionKey.MANAGE_ROLES, + update_role: PermissionKey.MANAGE_ROLES, + delete_role: PermissionKey.MANAGE_ROLES, + update_project: "manageProjects", + create_project: "manageProjects", + delete_project: "manageProjects", + create_estimate: "manageProjects", + clone_estimate: "manageProjects", + update_estimate_draft: "manageProjects", + submit_estimate_version: "manageProjects", + approve_estimate_version: "manageProjects", + create_estimate_revision: "manageProjects", + create_estimate_export: "manageProjects", + generate_estimate_weekly_phasing: "manageProjects", + update_estimate_commercial_terms: "manageProjects", + generate_project_cover: "manageProjects", + remove_project_cover: "manageProjects", + import_csv_data: PermissionKey.IMPORT_DATA, + create_allocation: "manageAllocations", + cancel_allocation: "manageAllocations", + update_allocation_status: "manageAllocations", + update_timeline_allocation_inline: "manageAllocations", + apply_timeline_project_shift: "manageAllocations", + quick_assign_timeline_resource: "manageAllocations", + batch_quick_assign_timeline_resources: "manageAllocations", + batch_shift_timeline_allocations: "manageAllocations", + create_demand: "manageAllocations", + fill_demand: "manageAllocations", + create_estimate_planning_handoff: "manageAllocations", + execute_task_action: "manageAllocations", +}; + +const COST_TOOLS = new Set([ + "get_budget_status", + "get_chargeability", + "get_chargeability_report", + "get_resource_computation_graph", + "get_project_computation_graph", + "resolve_rate", + "list_rate_cards", + "get_estimate_detail", + "get_estimate_version_snapshot", + "find_best_project_resource", + "get_staffing_suggestions", +]); + +const PLANNING_READ_TOOLS = new Set([ + "list_allocations", + "list_demands", + "list_blueprints", + "get_blueprint", + "list_clients", + "list_roles", + "list_management_levels", + "list_utilization_categories", + "check_resource_availability", + "get_staffing_suggestions", + "find_capacity", + "find_best_project_resource", +]); + +const RESOURCE_OVERVIEW_TOOLS = new Set([ + "search_resources", + "get_country", + "list_org_units", +]); + +const CONTROLLER_ONLY_TOOLS = new Set([ + "search_by_skill", + "search_projects", + "get_project", + "search_estimates", + "get_timeline_entries_view", + "get_timeline_holiday_overlays", + "get_project_timeline_context", + "preview_project_shift", + "get_statistics", + "get_dashboard_detail", + "get_skill_gaps", + "get_project_health", + "get_budget_forecast", + "query_change_history", + "get_entity_timeline", + "export_resources_csv", + "export_projects_csv", + "list_audit_log_entries", + "get_audit_log_entry", + "get_audit_log_timeline", + "get_audit_activity_summary", + "get_chargeability_report", + "get_resource_computation_graph", + "get_project_computation_graph", + "get_estimate_detail", + "list_estimate_versions", + "get_estimate_version_snapshot", + "get_estimate_weekly_phasing", + "get_estimate_commercial_terms", +]); + +const MANAGER_ONLY_TOOLS = new Set([ + "import_csv_data", + "list_assignable_users", + "create_notification", + "update_timeline_allocation_inline", + "apply_timeline_project_shift", + "quick_assign_timeline_resource", + "batch_quick_assign_timeline_resources", + "batch_shift_timeline_allocations", + "create_estimate", + "clone_estimate", + "update_estimate_draft", + "submit_estimate_version", + "approve_estimate_version", + "create_estimate_revision", + "create_estimate_export", + "create_estimate_planning_handoff", + "generate_estimate_weekly_phasing", + "update_estimate_commercial_terms", + "create_task_for_user", + "assign_task", + "send_broadcast", + "list_broadcasts", + "get_broadcast_detail", + "approve_vacation", + "reject_vacation", + "get_pending_vacation_approvals", + "get_entitlement_summary", + "set_entitlement", + "create_role", + "update_role", + "delete_role", + "create_client", + "update_client", +]); + +const ADMIN_ONLY_TOOLS = new Set([ + "list_users", + "get_active_user_count", + "create_user", + "set_user_password", + "update_user_role", + "update_user_name", + "link_user_resource", + "auto_link_users_by_email", + "set_user_permissions", + "reset_user_permissions", + "get_effective_user_permissions", + "disable_user_totp", + "list_dispo_import_batches", + "get_dispo_import_batch", + "stage_dispo_import_batch", + "validate_dispo_import_batch", + "cancel_dispo_import_batch", + "list_dispo_staged_resources", + "list_dispo_staged_projects", + "list_dispo_staged_assignments", + "list_dispo_staged_vacations", + "list_dispo_staged_unresolved_records", + "resolve_dispo_staged_record", + "commit_dispo_import_batch", + "get_system_settings", + "update_system_settings", + "clear_stored_runtime_secrets", + "get_ai_configured", + "test_ai_connection", + "test_smtp_connection", + "test_gemini_connection", + "list_system_role_configs", + "update_system_role_config", + "list_webhooks", + "get_webhook", + "create_webhook", + "update_webhook", + "delete_webhook", + "test_webhook", + "create_org_unit", + "update_org_unit", + "create_country", + "update_country", + "create_metro_city", + "update_metro_city", + "delete_metro_city", + "list_holiday_calendars", + "get_holiday_calendar", + "create_holiday_calendar", + "update_holiday_calendar", + "delete_holiday_calendar", + "create_holiday_calendar_entry", + "update_holiday_calendar_entry", + "delete_holiday_calendar_entry", +]); + +function hasLegacyToolAccess( + toolName: string, + permissions: Set, + userRole: string, + hasResourceOverviewAccess: boolean, + hasControllerAccess: boolean, + hasManagerAccess: boolean, +) { + const requiredPerm = TOOL_PERMISSION_MAP[toolName]; + + if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) { + return false; + } + if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== SystemRole.ADMIN) { + return false; + } + if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) { + return false; + } + if (RESOURCE_OVERVIEW_TOOLS.has(toolName) && !hasResourceOverviewAccess) { + return false; + } + if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) { + return false; + } + if (PLANNING_READ_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_PLANNING)) { + return false; + } + if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) { + return false; + } + if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) { + return false; + } + + return true; +} + +function hasToolAccess( + tool: ToolDef, + permissions: Set, + userRole: string, + hasResourceOverviewAccess: boolean, +): boolean { + if (!tool.access) { + const hasControllerAccess = userRole === SystemRole.ADMIN + || userRole === SystemRole.MANAGER + || userRole === SystemRole.CONTROLLER; + const hasManagerAccess = userRole === SystemRole.ADMIN + || userRole === SystemRole.MANAGER; + + return hasLegacyToolAccess( + tool.function.name, + permissions, + userRole, + hasResourceOverviewAccess, + hasControllerAccess, + hasManagerAccess, + ); + } + + if (tool.access.requiredPermissions?.some((permission) => !permissions.has(permission))) { + return false; + } + if (tool.access.allowedSystemRoles && !tool.access.allowedSystemRoles.includes(userRole as SystemRole)) { + return false; + } + if (tool.access.requiresResourceOverview && !hasResourceOverviewAccess) { + return false; + } + if (tool.access.requiresPlanningRead && !permissions.has(PermissionKey.VIEW_PLANNING)) { + return false; + } + if (tool.access.requiresCostView && !permissions.has(PermissionKey.VIEW_COSTS)) { + return false; + } + if (tool.access.requiresAdvancedAssistant && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) { + return false; + } + + return true; +} + +export function getAvailableAssistantTools( + permissions: Set, + userRole: string, +): ToolDef[] { + const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES) + || permissions.has(PermissionKey.MANAGE_RESOURCES); + + return TOOL_DEFINITIONS.filter((tool) => ( + hasToolAccess(tool, permissions, userRole, hasResourceOverviewAccess) + )); +} diff --git a/packages/api/src/router/assistant-tool-results.ts b/packages/api/src/router/assistant-tool-results.ts new file mode 100644 index 0000000..fe9af79 --- /dev/null +++ b/packages/api/src/router/assistant-tool-results.ts @@ -0,0 +1,46 @@ +type AssistantToolResult = { + content: string; + data?: unknown; +}; + +export function readToolError(result: AssistantToolResult): string | null { + if (result.data && typeof result.data === "object" && result.data !== null && "error" in (result.data as Record)) { + const error = (result.data as Record).error; + return typeof error === "string" ? error : null; + } + + try { + const parsed = JSON.parse(result.content) as unknown; + if (parsed && typeof parsed === "object" && "error" in (parsed as Record)) { + const error = (parsed as Record).error; + return typeof error === "string" ? error : null; + } + } catch { + // tool content may be plain text + } + + return null; +} + +export function readToolSuccessMessage(result: AssistantToolResult): string | null { + if (result.data && typeof result.data === "object" && result.data !== null) { + const data = result.data as Record; + if (typeof data.message === "string" && data.message.trim().length > 0) return data.message; + if (typeof data.description === "string" && data.description.trim().length > 0) return data.description; + } + + try { + const parsed = JSON.parse(result.content) as unknown; + if (parsed && typeof parsed === "object") { + const content = parsed as Record; + if (typeof content.message === "string" && content.message.trim().length > 0) return content.message; + if (typeof content.description === "string" && content.description.trim().length > 0) return content.description; + } + } catch { + // tool content may be plain text + } + + return typeof result.content === "string" && result.content.trim().length > 0 + ? result.content + : null; +} diff --git a/packages/api/src/router/assistant-tool-selection.ts b/packages/api/src/router/assistant-tool-selection.ts new file mode 100644 index 0000000..7a79b53 --- /dev/null +++ b/packages/api/src/router/assistant-tool-selection.ts @@ -0,0 +1,165 @@ +import { MUTATION_TOOLS } from "./assistant-tools.js"; +import type { ToolDef } from "./assistant-tools/shared.js"; + +const MAX_OPENAI_TOOL_DEFINITIONS = 128; + +const ALWAYS_INCLUDED_TOOL_NAMES = new Set([ + "get_current_user", + "get_resource", + "search_projects", + "get_project", + "list_allocations", + "get_statistics", + "navigate_to_page", +]); + +const MUTATION_INTENT_KEYWORDS = [ + "create", "add", "new", "update", "change", "edit", "delete", "remove", "cancel", "approve", "reject", + "anlegen", "erstellen", "neu", "aendern", "ändern", "bearbeiten", "loeschen", "löschen", "entfernen", + "stornieren", "genehmigen", "ablehnen", "setzen", +]; + +const TOOL_SELECTION_HINTS = [ + { + keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"], + nameFragments: ["holiday", "vacation", "entitlement"], + exactTools: ["list_holidays_by_region", "get_resource_holidays", "get_my_timeline_holiday_overlays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"], + }, + { + keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"], + nameFragments: ["resource", "skill", "role", "user", "staffing", "capacity"], + exactTools: ["search_resources", "get_resource", "search_by_skill", "check_resource_availability", "get_staffing_suggestions", "find_capacity"], + }, + { + keywords: ["capacity", "availability", "available", "kapazitaet", "kapazität", "verfuegbar", "verfügbar", "auslastung", "chargeability", "sah", "lcr"], + nameFragments: ["capacity", "availability", "chargeability", "staffing", "rate", "budget"], + exactTools: ["check_resource_availability", "get_staffing_suggestions", "find_capacity", "get_chargeability", "find_best_project_resource", "resolve_rate"], + }, + { + keywords: ["project", "projects", "projekt", "projekte", "allocation", "allocations", "allokation", "allokationen", "assignment", "assignments", "demand", "demands", "timeline"], + nameFragments: ["project", "allocation", "demand", "timeline", "assignment", "blueprint"], + exactTools: ["search_projects", "get_project", "list_allocations", "list_demands", "get_my_timeline_entries_view", "get_my_timeline_holiday_overlays", "get_timeline_entries_view", "get_project_timeline_context"], + }, + { + keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"], + nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"], + exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"], + }, + { + keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"], + nameFragments: ["estimate", "budget", "rate", "cost"], + exactTools: ["get_budget_status", "list_rate_cards", "resolve_rate", "lookup_rate", "search_estimates", "get_estimate_detail"], + }, + { + keywords: ["notification", "notifications", "benachrichtigung", "benachrichtigungen", "task", "tasks", "aufgabe", "aufgaben", "reminder", "reminders", "broadcast"], + nameFragments: ["notification", "task", "reminder", "broadcast"], + exactTools: ["list_notifications", "get_unread_notification_count", "list_tasks", "get_task_counts", "list_reminders", "get_broadcast_detail"], + }, + { + keywords: ["country", "countries", "land", "laender", "länder", "city", "cities", "stadt", "staedte", "städte", "region", "regions", "state", "bundesland"], + nameFragments: ["country", "metro_city", "holiday_calendar"], + exactTools: ["list_countries", "get_country", "list_holidays_by_region", "list_holiday_calendars"], + }, + { + keywords: ["user", "users", "permission", "permissions", "rolle", "rollen", "admin", "system", "webhook", "import", "audit", "history", "rechte"], + nameFragments: ["user", "permission", "role", "system", "webhook", "import", "audit", "history", "org_unit", "country"], + exactTools: ["list_users", "get_effective_user_permissions", "list_audit_log_entries", "query_change_history", "get_system_settings", "list_webhooks"], + }, +]; + +const TOOL_SELECTION_STOP_WORDS = new Set([ + "the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "who", "how", + "und", "der", "die", "das", "ein", "eine", "einer", "einem", "einen", "mit", "von", "fuer", "für", + "auf", "ist", "sind", "im", "in", "am", "an", "zu", "zum", "zur", "mir", "bitte", "can", "you", + "mir", "alle", "all", "den", "dem", "des", +]); + +export type AssistantChatMessage = { role: "user" | "assistant"; content: string }; + +export function normalizeAssistantText(input: string): string { + return input + .toLowerCase() + .normalize("NFD") + .replace(/\p{Diacritic}/gu, " ") + .replace(/[^a-z0-9_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function tokenizeAssistantIntent(input: string): string[] { + return normalizeAssistantText(input) + .split(" ") + .map((token) => token.trim()) + .filter((token) => token.length >= 3 && !TOOL_SELECTION_STOP_WORDS.has(token)); +} + +export function selectAssistantToolsForRequest( + availableTools: ToolDef[], + messages: AssistantChatMessage[], + pageContext?: string, +): ToolDef[] { + if (availableTools.length <= MAX_OPENAI_TOOL_DEFINITIONS) { + return availableTools; + } + + const recentUserText = messages + .filter((message) => message.role === "user") + .slice(-4) + .map((message) => message.content) + .join(" "); + const intentText = [recentUserText, pageContext ?? ""].filter(Boolean).join(" "); + const normalizedIntent = normalizeAssistantText(intentText); + const intentTokens = tokenizeAssistantIntent(intentText); + const mutationIntent = MUTATION_INTENT_KEYWORDS.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword))); + + const selectedHintTools = new Set(); + for (const hint of TOOL_SELECTION_HINTS) { + const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword))); + if (!matchedKeyword) continue; + for (const toolName of hint.exactTools) { + selectedHintTools.add(toolName); + } + } + + return availableTools + .map((tool, index) => { + const name = tool.function.name; + const normalizedName = normalizeAssistantText(name.replace(/_/g, " ")); + const normalizedDescription = normalizeAssistantText(tool.function.description); + let score = 0; + + if (ALWAYS_INCLUDED_TOOL_NAMES.has(name)) score += 1000; + if (selectedHintTools.has(name)) score += 400; + + for (const hint of TOOL_SELECTION_HINTS) { + const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword))); + if (!matchedKeyword) continue; + if (hint.exactTools.includes(name)) score += 160; + if (hint.nameFragments.some((fragment) => name.includes(fragment))) score += 120; + if (hint.nameFragments.some((fragment) => normalizedDescription.includes(normalizeAssistantText(fragment)))) score += 40; + } + + for (const token of intentTokens) { + if (normalizedName.includes(token)) score += 45; + if (normalizedDescription.includes(token)) score += 10; + } + + if (name.startsWith("search_")) score += 18; + if (name.startsWith("get_")) score += 12; + if (name.startsWith("list_")) score += 10; + + if (MUTATION_TOOLS.has(name)) { + score += mutationIntent ? 40 : -30; + } else { + score += 8; + } + + return { tool, index, score }; + }) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return left.index - right.index; + }) + .slice(0, MAX_OPENAI_TOOL_DEFINITIONS) + .map((entry) => entry.tool); +} diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 108bbb7..6d47ff2 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -5,126 +5,63 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db"; import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; -import { ADVANCED_ASSISTANT_TOOLS, MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js"; +import { MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js"; +import { + AssistantApprovalStorageUnavailableError, + createPendingAssistantApproval, + clearPendingAssistantApproval, + consumePendingAssistantApproval, + listPendingAssistantApprovals, + peekPendingAssistantApproval, + toApprovalPayload, + type PendingAssistantApproval, +} from "./assistant-approvals.js"; +import { + ASSISTANT_CONFIRMATION_PREFIX, + canExecuteMutationTool, + isCancellationReply, + parseToolArguments, + type ChatMessage, +} from "./assistant-confirmation.js"; +import { getAvailableAssistantTools } from "./assistant-tool-policy.js"; +import { selectAssistantToolsForRequest } from "./assistant-tool-selection.js"; +import { readToolError, readToolSuccessMessage } from "./assistant-tool-results.js"; import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js"; import { checkPromptInjection } from "../lib/prompt-guard.js"; import { checkAiOutput } from "../lib/content-filter.js"; import { createAuditEntry } from "../lib/audit.js"; import { logger } from "../lib/logger.js"; +export { + AssistantApprovalStorageUnavailableError, + createPendingAssistantApproval, + clearPendingAssistantApproval, + consumePendingAssistantApproval, + listPendingAssistantApprovals, + peekPendingAssistantApproval, + resetAssistantApprovalStorageWarningStateForTests, + toApprovalPayload, + type AssistantApprovalPayload, + type PendingAssistantApproval, +} from "./assistant-approvals.js"; +export { + ASSISTANT_CONFIRMATION_PREFIX, + buildApprovalSummary, + canExecuteMutationTool, + formatApprovalValue, + hasPendingAssistantConfirmation, + isAffirmativeConfirmationReply, + isCancellationReply, + parseToolArguments, + type ChatMessage, +} from "./assistant-confirmation.js"; +export { getAvailableAssistantTools } from "./assistant-tool-policy.js"; +export { selectAssistantToolsForRequest } from "./assistant-tool-selection.js"; + const MAX_TOOL_ITERATIONS = 8; -const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000; -export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:"; -const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals"; -const MAX_OPENAI_TOOL_DEFINITIONS = 128; -let hasLoggedAssistantApprovalStorageUnavailable = false; - -const ALWAYS_INCLUDED_TOOL_NAMES = new Set([ - "get_current_user", - "get_resource", - "search_projects", - "get_project", - "list_allocations", - "get_statistics", - "navigate_to_page", -]); - -const MUTATION_INTENT_KEYWORDS = [ - "create", "add", "new", "update", "change", "edit", "delete", "remove", "cancel", "approve", "reject", - "anlegen", "erstellen", "neu", "aendern", "ändern", "bearbeiten", "loeschen", "löschen", "entfernen", - "stornieren", "genehmigen", "ablehnen", "setzen", -]; - -const TOOL_SELECTION_HINTS = [ - { - keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"], - nameFragments: ["holiday", "vacation", "entitlement"], - exactTools: ["list_holidays_by_region", "get_resource_holidays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"], - }, - { - keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"], - nameFragments: ["resource", "skill", "role", "user", "staffing", "capacity"], - exactTools: ["search_resources", "get_resource", "search_by_skill", "check_resource_availability", "get_staffing_suggestions", "find_capacity"], - }, - { - keywords: ["capacity", "availability", "available", "kapazitaet", "kapazität", "verfuegbar", "verfügbar", "auslastung", "chargeability", "sah", "lcr"], - nameFragments: ["capacity", "availability", "chargeability", "staffing", "rate", "budget"], - exactTools: ["check_resource_availability", "get_staffing_suggestions", "find_capacity", "get_chargeability", "find_best_project_resource", "resolve_rate"], - }, - { - keywords: ["project", "projects", "projekt", "projekte", "allocation", "allocations", "allokation", "allokationen", "assignment", "assignments", "demand", "demands", "timeline"], - nameFragments: ["project", "allocation", "demand", "timeline", "assignment", "blueprint"], - exactTools: ["search_projects", "get_project", "list_allocations", "list_demands", "get_timeline_entries_view", "get_project_timeline_context"], - }, - { - keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"], - nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"], - exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"], - }, - { - keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"], - nameFragments: ["estimate", "budget", "rate", "cost"], - exactTools: ["get_budget_status", "list_rate_cards", "resolve_rate", "lookup_rate", "search_estimates", "get_estimate_detail"], - }, - { - keywords: ["notification", "notifications", "benachrichtigung", "benachrichtigungen", "task", "tasks", "aufgabe", "aufgaben", "reminder", "reminders", "broadcast"], - nameFragments: ["notification", "task", "reminder", "broadcast"], - exactTools: ["list_notifications", "get_unread_notification_count", "list_tasks", "get_task_counts", "list_reminders", "get_broadcast_detail"], - }, - { - keywords: ["country", "countries", "land", "laender", "länder", "city", "cities", "stadt", "staedte", "städte", "region", "regions", "state", "bundesland"], - nameFragments: ["country", "metro_city", "holiday_calendar"], - exactTools: ["list_countries", "get_country", "list_holidays_by_region", "list_holiday_calendars"], - }, - { - keywords: ["user", "users", "permission", "permissions", "rolle", "rollen", "admin", "system", "webhook", "import", "audit", "history", "rechte"], - nameFragments: ["user", "permission", "role", "system", "webhook", "import", "audit", "history", "org_unit", "country"], - exactTools: ["list_users", "get_effective_user_permissions", "list_audit_log_entries", "query_change_history", "get_system_settings", "list_webhooks"], - }, -]; - -const TOOL_SELECTION_STOP_WORDS = new Set([ - "the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "who", "how", - "und", "der", "die", "das", "ein", "eine", "einer", "einem", "einen", "mit", "von", "fuer", "für", - "auf", "ist", "sind", "im", "in", "am", "an", "zu", "zum", "zur", "mir", "bitte", "can", "you", - "mir", "alle", "all", "den", "dem", "des", -]); - -type ChatMessage = { role: "user" | "assistant"; content: string }; - -type AssistantApprovalStore = Pick; - -class AssistantApprovalStorageUnavailableError extends Error { - constructor() { - super("Assistant approval storage is unavailable."); - this.name = "AssistantApprovalStorageUnavailableError"; - } -} - -export interface PendingAssistantApproval { - id: string; - userId: string; - conversationId: string; - toolName: string; - toolArguments: string; - summary: string; - createdAt: number; - expiresAt: number; -} - -export interface AssistantApprovalPayload { - id: string; - status: "pending" | "approved" | "cancelled"; - conversationId: string; - toolName: string; - summary: string; - createdAt: string; - expiresAt: string; -} const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-Assistent für Ressourcenplanung und Projektmanagement in einer 3D-Produktionsumgebung. @@ -168,348 +105,6 @@ Datenmodell: - Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten `; -/** Map tool names to the permission required to use them */ -const TOOL_PERMISSION_MAP: Record = { - // Resource management - update_resource: "manageResources", - create_resource: "manageResources", - deactivate_resource: "manageResources", - create_role: PermissionKey.MANAGE_ROLES, - update_role: PermissionKey.MANAGE_ROLES, - delete_role: PermissionKey.MANAGE_ROLES, - // Project management - update_project: "manageProjects", - create_project: "manageProjects", - delete_project: "manageProjects", - create_estimate: "manageProjects", - clone_estimate: "manageProjects", - update_estimate_draft: "manageProjects", - submit_estimate_version: "manageProjects", - approve_estimate_version: "manageProjects", - create_estimate_revision: "manageProjects", - create_estimate_export: "manageProjects", - generate_estimate_weekly_phasing: "manageProjects", - update_estimate_commercial_terms: "manageProjects", - generate_project_cover: "manageProjects", - remove_project_cover: "manageProjects", - import_csv_data: PermissionKey.IMPORT_DATA, - // Allocation management - create_allocation: "manageAllocations", - cancel_allocation: "manageAllocations", - update_allocation_status: "manageAllocations", - update_timeline_allocation_inline: "manageAllocations", - apply_timeline_project_shift: "manageAllocations", - quick_assign_timeline_resource: "manageAllocations", - batch_quick_assign_timeline_resources: "manageAllocations", - batch_shift_timeline_allocations: "manageAllocations", - create_demand: "manageAllocations", - fill_demand: "manageAllocations", - create_estimate_planning_handoff: "manageAllocations", - // Vacation management - // Task management - execute_task_action: "manageAllocations", -}; - -/** Tools that require cost visibility */ -const COST_TOOLS = new Set([ - "get_budget_status", - "get_chargeability", - "get_chargeability_report", - "get_resource_computation_graph", - "get_project_computation_graph", - "resolve_rate", - "list_rate_cards", - "get_estimate_detail", - "get_estimate_version_snapshot", - "find_best_project_resource", - "get_staffing_suggestions", -]); - -/** Tools that follow planningReadProcedure access rules in the main API. */ -const PLANNING_READ_TOOLS = new Set([ - "list_allocations", - "list_demands", - "list_blueprints", - "get_blueprint", - "list_clients", - "list_roles", - "list_management_levels", - "list_utilization_categories", - "check_resource_availability", - "get_staffing_suggestions", - "find_capacity", - "find_best_project_resource", -]); - -/** Tools that require broad people-directory visibility because the backing routes expose resource-linked counts. */ -const RESOURCE_OVERVIEW_TOOLS = new Set([ - "search_resources", - "get_country", - "list_org_units", -]); - -/** Tools that follow controllerProcedure access rules in the main API. */ -const CONTROLLER_ONLY_TOOLS = new Set([ - "search_by_skill", - "list_comments", - "create_comment", - "resolve_comment", - "search_projects", - "get_project", - "search_estimates", - "get_timeline_entries_view", - "get_timeline_holiday_overlays", - "get_project_timeline_context", - "preview_project_shift", - "get_statistics", - "get_dashboard_detail", - "get_skill_gaps", - "get_project_health", - "get_budget_forecast", - "query_change_history", - "get_entity_timeline", - "export_resources_csv", - "export_projects_csv", - "list_audit_log_entries", - "get_audit_log_entry", - "get_audit_log_timeline", - "get_audit_activity_summary", - "get_chargeability_report", - "get_resource_computation_graph", - "get_project_computation_graph", - "get_estimate_detail", - "list_estimate_versions", - "get_estimate_version_snapshot", - "get_estimate_weekly_phasing", - "get_estimate_commercial_terms", -]); - -/** Tools that follow managerProcedure access rules in the main API. */ -const MANAGER_ONLY_TOOLS = new Set([ - "import_csv_data", - "list_assignable_users", - "create_notification", - "update_timeline_allocation_inline", - "apply_timeline_project_shift", - "quick_assign_timeline_resource", - "batch_quick_assign_timeline_resources", - "batch_shift_timeline_allocations", - "create_estimate", - "clone_estimate", - "update_estimate_draft", - "submit_estimate_version", - "approve_estimate_version", - "create_estimate_revision", - "create_estimate_export", - "create_estimate_planning_handoff", - "generate_estimate_weekly_phasing", - "update_estimate_commercial_terms", - "create_task_for_user", - "assign_task", - "send_broadcast", - "list_broadcasts", - "get_broadcast_detail", - "approve_vacation", - "reject_vacation", - "get_pending_vacation_approvals", - "get_entitlement_summary", - "set_entitlement", - "create_role", - "update_role", - "delete_role", - "create_client", - "update_client", -]); - -/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */ -const ADMIN_ONLY_TOOLS = new Set([ - "list_users", - "get_active_user_count", - "create_user", - "set_user_password", - "update_user_role", - "update_user_name", - "link_user_resource", - "auto_link_users_by_email", - "set_user_permissions", - "reset_user_permissions", - "get_effective_user_permissions", - "disable_user_totp", - "list_dispo_import_batches", - "get_dispo_import_batch", - "stage_dispo_import_batch", - "validate_dispo_import_batch", - "cancel_dispo_import_batch", - "list_dispo_staged_resources", - "list_dispo_staged_projects", - "list_dispo_staged_assignments", - "list_dispo_staged_vacations", - "list_dispo_staged_unresolved_records", - "resolve_dispo_staged_record", - "commit_dispo_import_batch", - "get_system_settings", - "update_system_settings", - "clear_stored_runtime_secrets", - "get_ai_configured", - "test_ai_connection", - "test_smtp_connection", - "test_gemini_connection", - "list_system_role_configs", - "update_system_role_config", - "list_webhooks", - "get_webhook", - "create_webhook", - "update_webhook", - "delete_webhook", - "test_webhook", - "create_org_unit", - "update_org_unit", - "create_country", - "update_country", - "create_metro_city", - "update_metro_city", - "delete_metro_city", - "list_holiday_calendars", - "get_holiday_calendar", - "create_holiday_calendar", - "update_holiday_calendar", - "delete_holiday_calendar", - "create_holiday_calendar_entry", - "update_holiday_calendar_entry", - "delete_holiday_calendar_entry", -]); - -export function getAvailableAssistantTools(permissions: Set, userRole: string) { - return TOOL_DEFINITIONS.filter((tool) => { - const toolName = tool.function.name; - const requiredPerm = TOOL_PERMISSION_MAP[toolName]; - const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES) - || permissions.has(PermissionKey.MANAGE_RESOURCES); - const hasControllerAccess = userRole === SystemRole.ADMIN - || userRole === SystemRole.MANAGER - || userRole === SystemRole.CONTROLLER; - const hasManagerAccess = userRole === SystemRole.ADMIN - || userRole === SystemRole.MANAGER; - - if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) { - return false; - } - if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== "ADMIN") { - return false; - } - if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) { - return false; - } - if (RESOURCE_OVERVIEW_TOOLS.has(toolName) && !hasResourceOverviewAccess) { - return false; - } - if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) { - return false; - } - if (PLANNING_READ_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_PLANNING)) { - return false; - } - if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) { - return false; - } - if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) { - return false; - } - - return true; - }); -} - -function normalizeAssistantText(input: string): string { - return input - .toLowerCase() - .normalize("NFD") - .replace(/\p{Diacritic}/gu, " ") - .replace(/[^a-z0-9_]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function tokenizeAssistantIntent(input: string): string[] { - return normalizeAssistantText(input) - .split(" ") - .map((token) => token.trim()) - .filter((token) => token.length >= 3 && !TOOL_SELECTION_STOP_WORDS.has(token)); -} - -export function selectAssistantToolsForRequest( - availableTools: typeof TOOL_DEFINITIONS, - messages: ChatMessage[], - pageContext?: string, -) { - if (availableTools.length <= MAX_OPENAI_TOOL_DEFINITIONS) { - return availableTools; - } - - const recentUserText = messages - .filter((message) => message.role === "user") - .slice(-4) - .map((message) => message.content) - .join(" "); - const intentText = [recentUserText, pageContext ?? ""].filter(Boolean).join(" "); - const normalizedIntent = normalizeAssistantText(intentText); - const intentTokens = tokenizeAssistantIntent(intentText); - const mutationIntent = MUTATION_INTENT_KEYWORDS.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword))); - - const selectedHintTools = new Set(); - for (const hint of TOOL_SELECTION_HINTS) { - const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword))); - if (!matchedKeyword) continue; - for (const toolName of hint.exactTools) { - selectedHintTools.add(toolName); - } - } - - const scoredTools = availableTools - .map((tool, index) => { - const name = tool.function.name; - const normalizedName = normalizeAssistantText(name.replace(/_/g, " ")); - const normalizedDescription = normalizeAssistantText(tool.function.description); - let score = 0; - - if (ALWAYS_INCLUDED_TOOL_NAMES.has(name)) score += 1000; - if (selectedHintTools.has(name)) score += 400; - - for (const hint of TOOL_SELECTION_HINTS) { - const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword))); - if (!matchedKeyword) continue; - if (hint.exactTools.includes(name)) score += 160; - if (hint.nameFragments.some((fragment) => name.includes(fragment))) score += 120; - if (hint.nameFragments.some((fragment) => normalizedDescription.includes(normalizeAssistantText(fragment)))) score += 40; - } - - for (const token of intentTokens) { - if (normalizedName.includes(token)) score += 45; - if (normalizedDescription.includes(token)) score += 10; - } - - if (name.startsWith("search_")) score += 18; - if (name.startsWith("get_")) score += 12; - if (name.startsWith("list_")) score += 10; - - if (MUTATION_TOOLS.has(name)) { - score += mutationIntent ? 40 : -30; - } else { - score += 8; - } - - return { tool, index, score }; - }) - .sort((left, right) => { - if (right.score !== left.score) return right.score - left.score; - return left.index - right.index; - }); - - return scoredTools - .slice(0, MAX_OPENAI_TOOL_DEFINITIONS) - .map((entry) => entry.tool); -} - function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] { const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle); if (duplicateIndex >= 0) { @@ -520,428 +115,6 @@ function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): As return [...existing, next].slice(-6); } -function parseToolArguments(args: string): Record { - try { - const parsed = JSON.parse(args) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as Record - : {}; - } catch { - return {}; - } -} - -function formatApprovalValue(value: unknown): string { - if (typeof value === "string") { - return value.length > 48 ? `${value.slice(0, 45)}...` : value; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (Array.isArray(value)) { - if (value.length === 0) return "[]"; - return `[${value.slice(0, 3).map((item) => formatApprovalValue(item)).join(", ")}${value.length > 3 ? ", ..." : ""}]`; - } - if (value && typeof value === "object") { - return "{...}"; - } - return "null"; -} - -function buildApprovalSummary(toolName: string, toolArguments: string): string { - const params = parseToolArguments(toolArguments); - const details = Object.entries(params) - .filter(([, value]) => value !== undefined && value !== null && value !== "") - .slice(0, 4) - .map(([key, value]) => `${key}=${formatApprovalValue(value)}`) - .join(", "); - - const action = toolName.replace(/_/g, " "); - return details ? `${action} (${details})` : action; -} - -function mapPendingApproval(record: { - id: string; - userId: string; - conversationId: string; - toolName: string; - toolArguments: string; - summary: string; - createdAt: Date; - expiresAt: Date; -}): PendingAssistantApproval { - return { - id: record.id, - userId: record.userId, - conversationId: record.conversationId, - toolName: record.toolName, - toolArguments: record.toolArguments, - summary: record.summary, - createdAt: record.createdAt.getTime(), - expiresAt: record.expiresAt.getTime(), - }; -} - -function toApprovalPayload( - approval: PendingAssistantApproval, - status: AssistantApprovalPayload["status"], -): AssistantApprovalPayload { - return { - id: approval.id, - status, - conversationId: approval.conversationId, - toolName: approval.toolName, - summary: approval.summary, - createdAt: new Date(approval.createdAt).toISOString(), - expiresAt: new Date(approval.expiresAt).toISOString(), - }; -} - -function isAssistantApprovalTableMissingError(error: unknown): boolean { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code !== "P2021") return false; - const table = typeof error.meta?.table === "string" ? error.meta.table : ""; - return table.includes("assistant_approvals") || error.message.includes("assistant_approvals"); - } - - if (typeof error !== "object" || error === null || !("code" in error)) { - return false; - } - - const candidate = error as { - code?: unknown; - message?: unknown; - meta?: { - table?: unknown; - }; - }; - const code = typeof candidate.code === "string" ? candidate.code : ""; - if (code !== "P2021") return false; - - const message = typeof candidate.message === "string" - ? candidate.message - : ""; - const metaTable = typeof candidate.meta?.table === "string" - ? candidate.meta.table - : ""; - - return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals"); -} - -function logAssistantApprovalStorageUnavailable(error: unknown) { - if (hasLoggedAssistantApprovalStorageUnavailable) { - return; - } - hasLoggedAssistantApprovalStorageUnavailable = true; - logger.warn( - { - err: error, - table: ASSISTANT_APPROVALS_TABLE_NAME, - }, - "Assistant approval storage is unavailable", - ); -} - -async function withAssistantApprovalFallback( - operation: () => Promise, - fallback: () => T, -): Promise { - try { - return await operation(); - } catch (error) { - if (!isAssistantApprovalTableMissingError(error)) throw error; - logAssistantApprovalStorageUnavailable(error); - return fallback(); - } -} - -export function resetAssistantApprovalStorageWarningStateForTests(): void { - hasLoggedAssistantApprovalStorageUnavailable = false; -} - -export async function listPendingAssistantApprovals( - db: AssistantApprovalStore, - userId: string, -): Promise { - return withAssistantApprovalFallback(async () => { - await db.assistantApproval.updateMany({ - where: { - userId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { lte: new Date() }, - }, - data: { - status: AssistantApprovalStatus.EXPIRED, - }, - }); - - const approvals = await db.assistantApproval.findMany({ - where: { - userId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { gt: new Date() }, - }, - orderBy: { createdAt: "desc" }, - }); - - return approvals.map(mapPendingApproval); - }, () => []); -} - -export async function clearPendingAssistantApproval( - db: AssistantApprovalStore, - userId: string, - conversationId: string, -): Promise { - await withAssistantApprovalFallback(async () => { - await db.assistantApproval.updateMany({ - where: { - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - }, - data: { - status: AssistantApprovalStatus.CANCELLED, - cancelledAt: new Date(), - }, - }); - }, () => undefined); -} - -export async function peekPendingAssistantApproval( - db: AssistantApprovalStore, - userId: string, - conversationId: string, -): Promise { - return withAssistantApprovalFallback(async () => { - await db.assistantApproval.updateMany({ - where: { - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { lte: new Date() }, - }, - data: { - status: AssistantApprovalStatus.EXPIRED, - }, - }); - - const pending = await db.assistantApproval.findFirst({ - where: { - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - }, - orderBy: { createdAt: "desc" }, - }); - if (!pending) return null; - return mapPendingApproval(pending); - }, () => null); -} - -export async function consumePendingAssistantApproval( - db: AssistantApprovalStore, - userId: string, - conversationId: string, -): Promise { - const pending = await peekPendingAssistantApproval(db, userId, conversationId); - if (!pending) return null; - const approvedAt = new Date(); - const updateResult = await db.assistantApproval.updateMany({ - where: { - id: pending.id, - userId, - conversationId, - status: AssistantApprovalStatus.PENDING, - expiresAt: { gt: approvedAt }, - }, - data: { - status: AssistantApprovalStatus.APPROVED, - approvedAt, - }, - }); - if (updateResult.count === 0) return null; - - const approved = await db.assistantApproval.findFirst({ - where: { - id: pending.id, - userId, - conversationId, - }, - }); - if (!approved) return null; - return mapPendingApproval(approved); -} - -export async function createPendingAssistantApproval( - db: AssistantApprovalStore, - userId: string, - conversationId: string, - toolName: string, - toolArguments: string, - options?: { summary?: string; ttlMs?: number }, -): Promise { - const now = new Date(); - const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS)); - const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments); - try { - await clearPendingAssistantApproval(db, userId, conversationId); - const pendingApproval = await db.assistantApproval.create({ - data: { - userId, - conversationId, - toolName, - toolArguments, - summary, - createdAt: now, - expiresAt, - }, - }); - return mapPendingApproval(pendingApproval); - } catch (error) { - if (!isAssistantApprovalTableMissingError(error)) throw error; - logAssistantApprovalStorageUnavailable(error); - throw new AssistantApprovalStorageUnavailableError(); - } -} - -function isAffirmativeConfirmationReply(content: string): boolean { - const normalized = content.trim().toLowerCase(); - if (!normalized) return false; - - const exactMatches = new Set([ - "ja", - "yes", - "y", - "ok", - "okay", - "okey", - "mach das", - "bitte machen", - "bitte ausführen", - "bitte ausfuehren", - "ausführen", - "ausfuehren", - "bestätigt", - "bestaetigt", - "bestätigen", - "bestaetigen", - "confirm", - "confirmed", - "do it", - "go ahead", - "proceed", - ]); - if (exactMatches.has(normalized)) return true; - - const affirmativePatterns = [ - /^(ja|yes|ok|okay)\b/, - /\b(mach|make|do|führ|fuehr|execute|run)\b.*\b(das|it|bitte|jetzt)\b/, - /\b(bit(?:te)?|please)\b.*\b(ausführen|ausfuehren|execute|run|machen|do)\b/, - /\b(bestätig|bestaetig|confirm)\w*\b/, - /\b(go ahead|proceed)\b/, - ]; - return affirmativePatterns.some((pattern) => pattern.test(normalized)); -} - -function isCancellationReply(content: string): boolean { - const normalized = content.trim().toLowerCase(); - if (!normalized) return false; - - const exactMatches = new Set([ - "nein", - "no", - "abbrechen", - "cancel", - "stopp", - "stop", - "doch nicht", - "nicht ausführen", - "nicht ausfuehren", - ]); - if (exactMatches.has(normalized)) return true; - - return [ - /\b(nein|no|cancel|abbrechen|stop|stopp)\b/, - /\b(doch nicht|nicht ausführen|nicht ausfuehren)\b/, - ].some((pattern) => pattern.test(normalized)); -} - -function hasPendingAssistantConfirmation(messages: ChatMessage[]): boolean { - if (messages.length < 2) return false; - const lastMessage = messages[messages.length - 1]; - if (!lastMessage || lastMessage.role !== "user") return false; - - for (let index = messages.length - 2; index >= 0; index -= 1) { - const message = messages[index]; - if (!message) continue; - if (message.role === "assistant") { - return message.content.trimStart().startsWith(ASSISTANT_CONFIRMATION_PREFIX); - } - } - - return false; -} - -export function canExecuteMutationTool( - messages: ChatMessage[], - toolName: string, - pendingApproval?: PendingAssistantApproval | null, -): boolean { - if (!MUTATION_TOOLS.has(toolName)) return true; - const lastMessage = messages[messages.length - 1]; - if (!lastMessage || lastMessage.role !== "user") return false; - if (!isAffirmativeConfirmationReply(lastMessage.content)) return false; - - if (pendingApproval) { - return pendingApproval.toolName === toolName && pendingApproval.expiresAt > Date.now(); - } - - return hasPendingAssistantConfirmation(messages); -} - -function readToolError(result: Awaited>): string | null { - if (result.data && typeof result.data === "object" && result.data !== null && "error" in (result.data as Record)) { - const error = (result.data as Record).error; - return typeof error === "string" ? error : null; - } - - try { - const parsed = JSON.parse(result.content) as unknown; - if (parsed && typeof parsed === "object" && "error" in (parsed as Record)) { - const error = (parsed as Record).error; - return typeof error === "string" ? error : null; - } - } catch { - // tool content may be plain text - } - - return null; -} - -function readToolSuccessMessage(result: Awaited>): string | null { - if (result.data && typeof result.data === "object" && result.data !== null) { - const data = result.data as Record; - if (typeof data.message === "string" && data.message.trim().length > 0) return data.message; - if (typeof data.description === "string" && data.description.trim().length > 0) return data.description; - } - - try { - const parsed = JSON.parse(result.content) as unknown; - if (parsed && typeof parsed === "object") { - const content = parsed as Record; - if (typeof content.message === "string" && content.message.trim().length > 0) return content.message; - if (typeof content.description === "string" && content.description.trim().length > 0) return content.description; - } - } catch { - // tool content may be plain text - } - - return typeof result.content === "string" && result.content.trim().length > 0 - ? result.content - : null; -} - export const assistantRouter = createTRPCRouter({ listPendingApprovals: protectedProcedure .query(async ({ ctx }) => {