From beae1a5d6eeff9e542732c1a79b2574cc9425ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 29 Mar 2026 10:10:59 +0200 Subject: [PATCH] feat(assistant): add approval inbox and e2e hardening --- apps/web/e2e/assistant-approvals.spec.ts | 265 +++++++++ apps/web/e2e/test-server.mjs | 2 +- .../src/components/assistant/ChatPanel.tsx | 242 ++++++++- apps/web/src/components/layout/AppShell.tsx | 1 + .../components/timeline/TimelineContext.tsx | 144 ++--- docs/assistant-capability-gap-analysis.md | 126 ++--- .../src/__tests__/assistant-router.test.ts | 371 ++++++++++++- .../assistant-tools-advanced.test.ts | 341 ++++++++++++ packages/api/src/router/assistant-tools.ts | 443 ++++++++++++++- packages/api/src/router/assistant.ts | 508 +++++++++++++++++- packages/api/src/router/timeline.ts | 341 ++++++------ packages/db/prisma/schema.prisma | 29 + 12 files changed, 2482 insertions(+), 331 deletions(-) create mode 100644 apps/web/e2e/assistant-approvals.spec.ts diff --git a/apps/web/e2e/assistant-approvals.spec.ts b/apps/web/e2e/assistant-approvals.spec.ts new file mode 100644 index 0000000..22ac2c8 --- /dev/null +++ b/apps/web/e2e/assistant-approvals.spec.ts @@ -0,0 +1,265 @@ +import { expect, test, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const ADMIN_EMAIL = "admin@capakraken.dev"; +const ADMIN_PASSWORD = "admin123"; +const CURRENT_CONVERSATION_ID = "assistant-e2e-current"; +const DB_WORKDIR = resolve(process.cwd(), "../../packages/db"); +const WEB_ENV_PATHS = [ + resolve(process.cwd(), ".env.local"), + resolve(process.cwd(), "apps/web/.env.local"), +]; + +function resolveDatabaseUrl(): string { + const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"]; + if (explicitPlaywrightUrl) { + return explicitPlaywrightUrl; + } + + for (const envPath of WEB_ENV_PATHS) { + if (!existsSync(envPath)) { + continue; + } + + const envFile = readFileSync(envPath, "utf8"); + for (const rawLine of envFile.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) { + return line.slice("PLAYWRIGHT_DATABASE_URL=".length); + } + if (line.startsWith("DATABASE_URL_TEST=")) { + return line.slice("DATABASE_URL_TEST=".length); + } + if (line.startsWith("DATABASE_URL=")) { + return line.slice("DATABASE_URL=".length); + } + } + } + + const fallbackTestUrl = process.env["DATABASE_URL_TEST"]; + if (fallbackTestUrl) { + return fallbackTestUrl; + } + + const fallback = process.env["DATABASE_URL"]; + if (fallback) { + return fallback; + } + + throw new Error("DATABASE_URL is not available for the Playwright assistant approval test."); +} + +function runDbJson(body: string): T { + const script = ` + import { PrismaClient } from "@prisma/client"; + const prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL, + }, + }, + }); + + try { + ${body} + } finally { + await prisma.$disconnect(); + } + `; + + const output = execFileSync("node", ["--input-type=module", "-e", script], { + cwd: DB_WORKDIR, + env: { + ...process.env, + DATABASE_URL: resolveDatabaseUrl(), + }, + encoding: "utf8", + }).trim(); + + return JSON.parse(output) as T; +} + +function runDb(body: string): void { + runDbJson(`${body}\nconsole.log("null");`); +} + +async function signInAsAdmin(page: Page) { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', ADMIN_EMAIL); + await page.fill('input[type="password"]', ADMIN_PASSWORD); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); +} + +test.describe("Assistant approvals", () => { + test.describe.configure({ mode: "serial" }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript((conversationId) => { + window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId); + }, CURRENT_CONVERSATION_ID); + + runDb(` + await prisma.systemSettings.upsert({ + where: { id: "singleton" }, + update: { + aiProvider: "openai", + azureOpenAiApiKey: "e2e-dummy-key", + azureOpenAiDeployment: "gpt-4o-mini", + }, + create: { + id: "singleton", + aiProvider: "openai", + azureOpenAiApiKey: "e2e-dummy-key", + azureOpenAiDeployment: "gpt-4o-mini", + }, + }); + + const admin = await prisma.user.findUniqueOrThrow({ + where: { email: ${JSON.stringify(ADMIN_EMAIL)} }, + select: { id: true }, + }); + + await prisma.assistantApproval.deleteMany({ + where: { userId: admin.id }, + }); + + await prisma.client.deleteMany({ + where: { + name: { + startsWith: "E2E Approval Client", + }, + }, + }); + `); + }); + + test.afterEach(async () => { + runDb(` + await prisma.assistantApproval.deleteMany({ + where: { + conversationId: { + startsWith: "assistant-e2e-", + }, + }, + }); + + await prisma.client.deleteMany({ + where: { + name: { + startsWith: "E2E Approval Client", + }, + }, + }); + `); + }); + + test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => { + const suffix = Date.now(); + const currentClientName = `E2E Approval Client Current ${suffix}`; + const otherClientName = `E2E Approval Client Other ${suffix}`; + const otherConversationId = `assistant-e2e-other-${suffix}`; + + const { currentApproval, otherApproval } = runDbJson<{ + currentApproval: { id: string; summary: string }; + otherApproval: { id: string; summary: string }; + }>(` + const admin = await prisma.user.findUniqueOrThrow({ + where: { email: ${JSON.stringify(ADMIN_EMAIL)} }, + select: { id: true }, + }); + + const currentApproval = await prisma.assistantApproval.create({ + data: { + userId: admin.id, + conversationId: ${JSON.stringify(CURRENT_CONVERSATION_ID)}, + toolName: "create_client", + toolArguments: ${JSON.stringify(JSON.stringify({ name: currentClientName }))}, + summary: ${JSON.stringify(`create client (name=${currentClientName})`)}, + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + }, + select: { id: true, summary: true }, + }); + + const otherApproval = await prisma.assistantApproval.create({ + data: { + userId: admin.id, + conversationId: ${JSON.stringify(otherConversationId)}, + toolName: "create_client", + toolArguments: ${JSON.stringify(JSON.stringify({ name: otherClientName }))}, + summary: ${JSON.stringify(`create client (name=${otherClientName})`)}, + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + }, + select: { id: true, summary: true }, + }); + + console.log(JSON.stringify({ currentApproval, otherApproval })); + `); + + await signInAsAdmin(page); + await page.goto("/dashboard"); + await page.getByTestId("assistant-open-button").click(); + + await expect(page.getByRole("heading", { name: "HartBOT" })).toBeVisible(); + await expect(page.getByTestId("assistant-open-approvals")).toBeVisible(); + await expect(page.getByText(currentApproval.summary)).toBeVisible(); + await expect(page.getByText(otherApproval.summary)).toBeVisible(); + + const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first(); + const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first(); + await expect(currentCard).toContainText("This chat"); + await expect(otherCard).toContainText("Other chat"); + + await otherCard.getByTestId("assistant-approval-cancel").click(); + await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible(); + await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0); + + await expect + .poll(async () => { + return runDbJson(` + const approval = await prisma.assistantApproval.findUnique({ + where: { id: ${JSON.stringify(otherApproval.id)} }, + select: { status: true }, + }); + console.log(JSON.stringify(approval?.status ?? null)); + `); + }) + .toBe("CANCELLED"); + + await currentCard.getByTestId("assistant-approval-confirm").click(); + + await expect(page.getByText(`Ausgeführt: Created client: ${currentClientName}`)).toBeVisible(); + await expect(page.getByText(currentApproval.summary)).toBeVisible(); + + await expect + .poll(async () => { + return runDbJson(` + const approval = await prisma.assistantApproval.findUnique({ + where: { id: ${JSON.stringify(currentApproval.id)} }, + select: { status: true }, + }); + console.log(JSON.stringify(approval?.status ?? null)); + `); + }) + .toBe("APPROVED"); + + await expect + .poll(async () => { + return runDbJson<"created" | "missing">(` + const client = await prisma.client.findFirst({ + where: { name: ${JSON.stringify(currentClientName)} }, + select: { id: true }, + }); + console.log(JSON.stringify(client ? "created" : "missing")); + `); + }) + .toBe("created"); + + await expect(page.getByTestId("assistant-approval-card")).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/test-server.mjs b/apps/web/e2e/test-server.mjs index 3eed60d..887c01d 100644 --- a/apps/web/e2e/test-server.mjs +++ b/apps/web/e2e/test-server.mjs @@ -78,7 +78,7 @@ function writeManagedWebEnv(rootEnv) { const contents = managedEnvKeys .map((key) => { - const value = rootEnv[key] ?? process.env[key]; + const value = process.env[key] ?? rootEnv[key]; return value ? `${key}=${value}` : null; }) .filter(Boolean) diff --git a/apps/web/src/components/assistant/ChatPanel.tsx b/apps/web/src/components/assistant/ChatPanel.tsx index 0931c99..0e89950 100644 --- a/apps/web/src/components/assistant/ChatPanel.tsx +++ b/apps/web/src/components/assistant/ChatPanel.tsx @@ -39,6 +39,17 @@ interface Message { role: "user" | "assistant"; content: string; insights?: AssistantInsight[]; + approval?: AssistantApproval; +} + +interface AssistantApproval { + id: string; + status: "pending" | "approved" | "cancelled"; + conversationId: string; + toolName: string; + summary: string; + createdAt: string; + expiresAt: string; } interface AssistantInsightMetric { @@ -61,6 +72,19 @@ interface AssistantInsight { } const STORAGE_KEY = "capakraken-chat-messages"; +const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id"; + +function isAssistantApproval(value: unknown): value is AssistantApproval { + if (!value || typeof value !== "object") return false; + const approval = value as Partial; + return typeof approval.id === "string" + && (approval.status === "pending" || approval.status === "approved" || approval.status === "cancelled") + && typeof approval.conversationId === "string" + && typeof approval.toolName === "string" + && typeof approval.summary === "string" + && typeof approval.createdAt === "string" + && typeof approval.expiresAt === "string"; +} /** Load messages from sessionStorage (survives page reloads, clears on tab close). */ function loadPersistedMessages(): Message[] { @@ -81,6 +105,7 @@ function loadPersistedMessages(): Message[] { role: item.role, content: item.content, ...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}), + ...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}), })); } } @@ -91,17 +116,47 @@ function loadPersistedMessages(): Message[] { /** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */ let cachedMessages: Message[] | null = null; +function generateConversationId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `conversation-${Date.now()}`; +} + +function loadConversationId(): string { + if (typeof window === "undefined") return generateConversationId(); + try { + const raw = sessionStorage.getItem(CONVERSATION_ID_KEY)?.trim(); + if (raw) return raw; + } catch { + // ignore storage access errors + } + return generateConversationId(); +} + export function ChatPanel({ onClose }: { onClose: () => void }) { const pathname = usePathname(); const router = useRouter(); const utils = trpc.useUtils(); const [messages, setMessages] = useState(() => cachedMessages ?? loadPersistedMessages()); + const [conversationId, setConversationId] = useState(() => loadConversationId()); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [approvalNotice, setApprovalNotice] = useState(null); const scrollRef = useRef(null); const inputRef = useRef(null); const chatMutation = trpc.assistant.chat.useMutation(); + const pendingApprovalsQuery = trpc.assistant.listPendingApprovals.useQuery(undefined, { + refetchInterval: 30_000, + }); + const pendingApprovals = pendingApprovalsQuery.data ?? []; + const inlineApprovalIds = new Set( + messages + .map((message) => message.approval?.id) + .filter((approvalId): approvalId is string => typeof approvalId === "string"), + ); + const visiblePendingApprovals = pendingApprovals.filter((approval) => !inlineApprovalIds.has(approval.id)); // Sync to module-level cache + sessionStorage on every change useEffect(() => { @@ -109,6 +164,10 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ } }, [messages]); + useEffect(() => { + try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ } + }, [conversationId]); + // Auto-scroll to bottom on new messages useEffect(() => { const el = scrollRef.current; @@ -120,37 +179,58 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { inputRef.current?.focus(); }, []); - const sendMessage = useCallback(async () => { - const text = input.trim(); + const sendMessage = useCallback(async ( + overrideText?: string, + targetConversationId?: string, + options?: { persistInCurrentChat?: boolean }, + ) => { + const text = (overrideText ?? input).trim(); if (!text || isLoading) return; + const persistInCurrentChat = options?.persistInCurrentChat ?? true; + const activeConversationId = targetConversationId ?? conversationId; - setInput(""); + if (!overrideText) { + setInput(""); + } setError(null); + setApprovalNotice(null); const userMsg: Message = { role: "user", content: text }; - const updated = [...messages, userMsg]; - setMessages(updated); + const updated = persistInCurrentChat ? [...messages, userMsg] : messages; + if (persistInCurrentChat) { + setMessages(updated); + } setIsLoading(true); try { const reply = await chatMutation.mutateAsync({ - messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })), + messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({ + role: message.role, + content: message.content, + })), ...(pathname ? { pageContext: resolvePageContext(pathname) } : {}), + conversationId: activeConversationId, }); const typedReply = reply as { content: string; role: "assistant"; actions?: Array<{ type: string; url?: string; scope?: string[] }>; insights?: AssistantInsight[]; + approval?: AssistantApproval; }; - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: typedReply.content, - ...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}), - }, - ]); + if (persistInCurrentChat) { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: typedReply.content, + ...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}), + ...(isAssistantApproval(typedReply.approval) ? { approval: typedReply.approval } : {}), + }, + ]); + } else { + setApprovalNotice(typedReply.content); + } // Handle actions from the AI (navigation, data invalidation) const actions = typedReply.actions; @@ -172,13 +252,14 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { } } } + await utils.assistant.listPendingApprovals.invalidate(); } catch (err) { const msg = err instanceof Error ? err.message : "Something went wrong"; setError(msg); } finally { setIsLoading(false); } - }, [input, isLoading, messages, chatMutation, pathname]); + }, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils]); // Track user message history for up-arrow recall const userHistory = useRef([]); @@ -221,6 +302,8 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { const clearChat = () => { setMessages([]); setError(null); + setApprovalNotice(null); + setConversationId(generateConversationId()); cachedMessages = null; try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } }; @@ -269,6 +352,79 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { {/* Messages */}
+ {(visiblePendingApprovals.length > 0 || approvalNotice) && ( +
+
+
+ Open approvals +
+ {visiblePendingApprovals.length > 0 && ( + + {visiblePendingApprovals.length} + + )} +
+ {approvalNotice && ( +
+ {approvalNotice} +
+ )} + {visiblePendingApprovals.map((approval) => { + const belongsToCurrentConversation = approval.conversationId === conversationId; + return ( +
+
+
+
+ {approval.summary} +
+
+ {approval.toolName} + Expires {new Date(approval.expiresAt).toLocaleString()} +
+
+ + {belongsToCurrentConversation ? "This chat" : "Other chat"} + +
+
+ + +
+
+ ); + })} +
+ )} {messages.length === 0 && !isLoading && (
@@ -279,12 +435,56 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
)} {messages.map((msg, i) => ( - +
+ + {msg.role === "assistant" && msg.approval && ( +
+
+
+ + {msg.approval.status === "pending" ? "Approval pending" : msg.approval.status} + + + {msg.approval.toolName} + +
+
+ {msg.approval.summary} +
+
+ Created: {new Date(msg.approval.createdAt).toLocaleString()} + {msg.approval.status === "pending" && ( + Expires: {new Date(msg.approval.expiresAt).toLocaleString()} + )} +
+ {msg.approval.status === "pending" && ( +
+ + +
+ )} +
+
+ )} +
))} {isLoading && } {error && ( diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index da31a9d..7864a78 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -910,6 +910,7 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac