diff --git a/tests/integration/v3_test_helper.go b/tests/integration/v3_test_helper.go index 11bcf5fc..12016275 100644 --- a/tests/integration/v3_test_helper.go +++ b/tests/integration/v3_test_helper.go @@ -106,6 +106,20 @@ func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) { // Cleanup after test — delete in FK order (children first, parents last). t.Cleanup(func() { + // Knowledge stores — must run BEFORE agent_teams is deleted. vault_documents.team_id + // is ON DELETE SET NULL with a BEFORE UPDATE trigger that force-sets scope='personal', + // which violates vault_documents_scope_consistency for team-scoped docs whose agent_id + // is NULL. Deleting the rows outright avoids the trigger entirely. + // vault_links has no tenant_id column (it links vault_documents by from/to_doc_id) and + // cascades automatically via vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE, so no + // explicit delete is needed here. + db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID) + // Team-related (deepest children first) db.Exec("DELETE FROM team_task_comments WHERE tenant_id = $1", tenantID) db.Exec("DELETE FROM team_task_events WHERE tenant_id = $1", tenantID) @@ -121,15 +135,6 @@ func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) { db.Exec("DELETE FROM cron_run_logs WHERE job_id IN (SELECT id FROM cron_jobs WHERE tenant_id = $1)", tenantID) db.Exec("DELETE FROM cron_jobs WHERE tenant_id = $1", tenantID) - // Knowledge stores - db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID) - // Sessions db.Exec("DELETE FROM sessions WHERE tenant_id = $1", tenantID) @@ -156,7 +161,9 @@ func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) { db.Exec("DELETE FROM agent_context_files WHERE agent_id = $1", agentID) db.Exec("DELETE FROM user_context_files WHERE agent_id = $1", agentID) db.Exec("DELETE FROM user_agent_overrides WHERE agent_id = $1", agentID) - db.Exec("DELETE FROM agent_user_profiles WHERE agent_id = $1", agentID) + // Note: agent_user_profiles does not exist in the current schema (the table is + // user_agent_profiles, scoped by tenant_id + agent_id; there is nothing per-agent + // to clean here beyond what tenant-scoped deletes below already cover). db.Exec("DELETE FROM agent_evolution_suggestions WHERE agent_id = $1", agentID) db.Exec("DELETE FROM agent_evolution_metrics WHERE agent_id = $1", agentID) db.Exec("DELETE FROM agents WHERE id = $1", agentID) diff --git a/tests/invariants/helpers_test.go b/tests/invariants/helpers_test.go index bda317c7..da5108df 100644 --- a/tests/invariants/helpers_test.go +++ b/tests/invariants/helpers_test.go @@ -173,6 +173,21 @@ func assertNotEmpty(t *testing.T, result any, msg string) { // cleanupTenant removes all tenant data in FK order. func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) { + // Knowledge stores — must run BEFORE agent_teams is deleted. vault_documents.team_id + // is ON DELETE SET NULL with a BEFORE UPDATE trigger that force-sets scope='personal', + // which violates vault_documents_scope_consistency for team-scoped docs whose agent_id + // is NULL. Deleting the rows outright avoids the trigger entirely. + // vault_links has no tenant_id column (it links vault_documents by from/to_doc_id) and + // cascades automatically via vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE, so no + // explicit delete is needed here. + db.Exec("DELETE FROM episodic_summaries WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID) + db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID) + // Team-related db.Exec("DELETE FROM team_task_comments WHERE tenant_id = $1", tenantID) db.Exec("DELETE FROM team_task_events WHERE tenant_id = $1", tenantID) @@ -181,16 +196,6 @@ func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) { db.Exec("DELETE FROM agent_team_members WHERE tenant_id = $1", tenantID) db.Exec("DELETE FROM agent_teams WHERE tenant_id = $1", tenantID) - // Knowledge stores - db.Exec("DELETE FROM episodic_summaries WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID) - db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID) - // Sessions db.Exec("DELETE FROM sessions WHERE tenant_id = $1", tenantID) @@ -218,7 +223,9 @@ func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) { db.Exec("DELETE FROM agent_context_files WHERE agent_id = $1", agentID) db.Exec("DELETE FROM user_context_files WHERE agent_id = $1", agentID) db.Exec("DELETE FROM user_agent_overrides WHERE agent_id = $1", agentID) - db.Exec("DELETE FROM agent_user_profiles WHERE agent_id = $1", agentID) + // Note: agent_user_profiles does not exist in the current schema (the table is + // user_agent_profiles, agent_id REFERENCES agents(id) ON DELETE CASCADE — cleaned up + // automatically when the agents row below is deleted). db.Exec("DELETE FROM agent_evolution_suggestions WHERE agent_id = $1", agentID) db.Exec("DELETE FROM agent_evolution_metrics WHERE agent_id = $1", agentID) db.Exec("DELETE FROM agents WHERE id = $1", agentID) diff --git a/tests/invariants/tenant_isolation_test.go b/tests/invariants/tenant_isolation_test.go index 85f1b18f..92105920 100644 --- a/tests/invariants/tenant_isolation_test.go +++ b/tests/invariants/tenant_isolation_test.go @@ -347,7 +347,8 @@ func TestTenantIsolation_VaultStore(t *testing.T) { t.Fatalf("create vault doc: %v", err) } t.Cleanup(func() { - db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantA) + // vault_links has no tenant_id column; it cascades via + // vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE when vault_documents is deleted. db.Exec("DELETE FROM vault_documents WHERE id = $1", docID) }) diff --git a/ui/web/src/main.tsx b/ui/web/src/main.tsx index 6266bc1b..051062d3 100644 --- a/ui/web/src/main.tsx +++ b/ui/web/src/main.tsx @@ -3,6 +3,31 @@ import { createRoot } from "react-dom/client"; import "./i18n"; import App from "./App"; import "./index.css"; +import { ApiError } from "@/api/errors"; + +/** + * Global safety net for unhandled promise rejections from API calls. + * + * Every data-fetching hook in this app (see src/pages/*\/hooks/*.ts) already + * catches `ApiError`s, shows a `toast.error(...)` with a user-friendly message, + * and re-throws so the calling component can react locally (e.g. keep a form + * open, avoid resetting "dirty" state on failure). Some call sites forget to + * `catch` that re-thrown promise, which produces a noisy, user-invisible + * "Uncaught (in promise) ApiError" in the console even though the user already + * saw a toast for the underlying failure. + * + * This handler does NOT show its own toast (that would double up with the one + * already shown by the originating hook) — it only suppresses the redundant + * browser-level "unhandled rejection" noise for errors known to originate from + * our API layer, which have already been surfaced to the user via toast. It + * never calls `preventDefault()` for non-`ApiError` rejections, so genuine + * unexpected bugs still surface normally (console + any error-reporting tooling). + */ +window.addEventListener("unhandledrejection", (event) => { + if (event.reason instanceof ApiError) { + event.preventDefault(); + } +}); const LOADER_MIN_MS = 800; const loaderStart = performance.now(); diff --git a/ui/web/src/pages/config/sections/behavior-section.tsx b/ui/web/src/pages/config/sections/behavior-section.tsx index 41177cac..25e5e762 100644 --- a/ui/web/src/pages/config/sections/behavior-section.tsx +++ b/ui/web/src/pages/config/sections/behavior-section.tsx @@ -96,7 +96,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) { tools: { scrub_credentials: security.scrub_credentials }, sessions, channels: { pending_compaction: pendingCompaction }, - }); + }).catch(() => {}); }; return ( diff --git a/ui/web/src/pages/config/sections/bindings-section.tsx b/ui/web/src/pages/config/sections/bindings-section.tsx index e864e00b..07293813 100644 --- a/ui/web/src/pages/config/sections/bindings-section.tsx +++ b/ui/web/src/pages/config/sections/bindings-section.tsx @@ -147,7 +147,7 @@ export function BindingsSection({ data, onSave, saving }: Props) { {dirty && (