diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c431eba7..08cc992bb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -70,6 +70,7 @@ export default defineConfig({ "**/sidebar-relay-card.spec.ts", "**/tokens.spec.ts", "**/persona-env-vars.spec.ts", + "**/persona-sync.spec.ts", "**/mesh-compute.spec.ts", ], use: { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 79cd0e664..b53d265f8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -52,8 +52,9 @@ const overrides = new Map([ // (reconcile_inbound_tombstone), the two apply_inbound_* fns, the // event_d_tag/parse_deletion_coordinate helpers, and the preserve/overwrite + // secret-injection + tombstone test coverage. Load-bearing feature growth, - // queued to split with the list. - ["src-tauri/src/commands/personas.rs", 1271], + // queued to split with the list. The two `agents-data-changed` emits (live + // UI refresh on inbound reconcile + tombstone) add the latest growth. + ["src-tauri/src/commands/personas.rs", 1279], ["src-tauri/src/managed_agents/persona_card.rs", 1050], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 8dc77eaa5..a3b921ae7 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; use super::export_util::save_json_with_dialog; @@ -442,6 +442,10 @@ pub fn reconcile_inbound_persona_event( } try_regenerate_nest(&app); + // Signal the live UI to refetch agents data — inbound relay events otherwise + // land on disk silently, leaving the Agents tab stale until restart. + let _ = app.emit("agents-data-changed", ()); + Ok(()) } @@ -539,6 +543,10 @@ fn reconcile_inbound_tombstone( } try_regenerate_nest(app); + // Refresh the live UI on inbound deletion — a removal is as user-visible as + // an upsert and the Agents tab must drop the tombstoned record without restart. + let _ = app.emit("agents-data-changed", ()); + Ok(()) } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 56de527ef..91df13fcc 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -40,6 +40,7 @@ import { import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { usePresenceSession, @@ -139,6 +140,7 @@ export function AppShell() { identityQuery.data?.pubkey, ); usePersonaSync(identityQuery.data?.pubkey); + useAgentsDataRefresh(); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts new file mode 100644 index 000000000..83aa99547 --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -0,0 +1,45 @@ +import { listen } from "@tauri-apps/api/event"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; + +import { + managedAgentsQueryKey, + personasQueryKey, + relayAgentsQueryKey, + teamsQueryKey, +} from "@/features/agents/hooks"; + +// Trailing-coalesce window: a backfill burst (up to 500 inbound events fed +// one-by-one through reconcile) fires one `agents-data-changed` per event. +// Collapsing them into a single invalidate after the burst settles keeps the +// refetch off React Query's implicit in-flight dedup and avoids redundant +// disk-read IPC. +const COALESCE_MS = 200; + +// Invalidate the live Agents-tab queries when the backend signals that inbound +// relay events changed the on-disk agents data. Mounted once at the app root +// with empty deps — invalidation is global and has no reason to be +// pubkey-scoped, so it must NOT live inside the pubkey-keyed `usePersonaSync` +// (re-registering per identity switch would leak a listener each time). +export function useAgentsDataRefresh(): void { + const queryClient = useQueryClient(); + + useEffect(() => { + let timer: ReturnType | undefined; + + const unlisten = listen("agents-data-changed", () => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, COALESCE_MS); + }); + + return () => { + if (timer !== undefined) clearTimeout(timer); + void unlisten.then((fn) => fn()); + }; + }, [queryClient]); +} diff --git a/desktop/tests/e2e/persona-sync.spec.ts b/desktop/tests/e2e/persona-sync.spec.ts new file mode 100644 index 000000000..5dfa7e1a1 --- /dev/null +++ b/desktop/tests/e2e/persona-sync.spec.ts @@ -0,0 +1,217 @@ +import { expect, test } from "@playwright/test"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { finalizeEvent } from "nostr-tools/pure"; + +import { installMockBridge } from "../helpers/bridge"; + +// Tyler's test identity — from TEST_IDENTITIES.tyler in tests/helpers/bridge.ts +const TYLER_PRIVATE_KEY = hexToBytes( + "3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03", +); +const TYLER_PUBKEY = + "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"; + +const D_TAG = "sync-test-persona"; +const KIND_PERSONA = 30175; +const KIND_DELETION = 5; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +async function gotoApp(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForInvokeBridge(page); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); +} + +async function waitForInvokeBridge(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => { + const w = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + __TAURI_INTERNALS__?: { invoke?: unknown }; + }; + return ( + typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" || + typeof w.__TAURI_INTERNALS__?.invoke === "function" + ); + }, + null, + { timeout: 5_000 }, + ); +} + +async function invokeTauri( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +): Promise { + await waitForInvokeBridge(page); + return page.evaluate( + async ({ command: c, payload: p }) => { + const w = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + c: string, + p?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: (c: string, p?: Record) => Promise; + }; + }; + const invoke = + w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke; + if (!invoke) throw new Error("Mock invoke bridge is unavailable."); + return (await invoke(c, p)) as T; + }, + { command, payload }, + ); +} + +/** + * Register a one-shot `agents-data-changed` listener BEFORE the reconcile call + * and wait up to 500 ms for it to fire (covers the 200 ms debounce coalesce). + * Returns true if the event fired, false on timeout. + * + * Must be called before the invokeTauri that triggers the emit so the listener + * is registered before the event fires — no race. + */ +async function listenForAgentsDataChanged( + page: import("@playwright/test").Page, +): Promise<() => Promise> { + // Inject a Promise into the page that resolves when the event fires. + await page.evaluate(() => { + ( + window as Window & { __agentsDataChangedFired?: Promise } + ).__agentsDataChangedFired = new Promise((resolve) => { + const internals = ( + window as Window & { + __TAURI_INTERNALS__?: { + listen?: (event: string, cb: () => void) => Promise<() => void>; + }; + } + ).__TAURI_INTERNALS__; + if (!internals?.listen) { + resolve(false); + return; + } + void internals.listen("agents-data-changed", () => resolve(true)); + // Timeout guard: 500 ms covers the 200 ms debounce coalesce with margin. + setTimeout(() => resolve(false), 500); + }); + }); + + // Return a thunk the caller invokes after the reconcile to await the result. + return () => + page.evaluate( + () => + (window as Window & { __agentsDataChangedFired?: Promise }) + .__agentsDataChangedFired ?? Promise.resolve(false), + ); +} + +test("upsert round-trip: reconcile_inbound_persona_event writes record and emits agents-data-changed", async ({ + page, +}) => { + await gotoApp(page); + + const createdAt = Math.floor(Date.now() / 1000); + + // Build + sign the kind:30175 persona event using nostr-tools. + const personaEvent = finalizeEvent( + { + kind: KIND_PERSONA, + content: JSON.stringify({ + display_name: "Sync Test Persona", + system_prompt: "You are a sync test.", + }), + tags: [["d", D_TAG]], + created_at: createdAt, + }, + TYLER_PRIVATE_KEY, + ); + + // Register the listener BEFORE the reconcile call — no race. + const awaitFired = await listenForAgentsDataChanged(page); + + // Drive the inbound reconcile path. + await invokeTauri(page, "reconcile_inbound_persona_event", { + eventJson: JSON.stringify(personaEvent), + }); + + // Assert the record landed on disk. + const personas = await invokeTauri< + Array<{ id: string; display_name: string }> + >(page, "list_personas"); + const record = personas.find((p) => p.id === D_TAG); + expect(record?.display_name).toBe("Sync Test Persona"); + + // Assert the emit fired (debounce settles within 500 ms timeout guard). + const fired = await awaitFired(); + expect(fired).toBe(true); +}); + +test("tombstone round-trip: reconcile_inbound_persona_event removes record and emits agents-data-changed", async ({ + page, +}) => { + await gotoApp(page); + + const upsertCreatedAt = Math.floor(Date.now() / 1000); + + // Step 1: upsert the persona first. + const personaEvent = finalizeEvent( + { + kind: KIND_PERSONA, + content: JSON.stringify({ + display_name: "Sync Test Persona", + system_prompt: "You are a sync test.", + }), + tags: [["d", D_TAG]], + created_at: upsertCreatedAt, + }, + TYLER_PRIVATE_KEY, + ); + + await invokeTauri(page, "reconcile_inbound_persona_event", { + eventJson: JSON.stringify(personaEvent), + }); + + // Step 2: confirm it landed. + const afterUpsert = await invokeTauri>( + page, + "list_personas", + ); + expect(afterUpsert.some((p) => p.id === D_TAG)).toBe(true); + + // Step 3: build + sign a kind:5 deletion event. created_at must be strictly + // after the upsert so the retention db does not skip it. + const tombstoneEvent = finalizeEvent( + { + kind: KIND_DELETION, + content: "", + tags: [["a", `${KIND_PERSONA}:${TYLER_PUBKEY}:${D_TAG}`]], + created_at: upsertCreatedAt + 1, + }, + TYLER_PRIVATE_KEY, + ); + + // Register the listener BEFORE the tombstone reconcile call — no race. + const awaitFired = await listenForAgentsDataChanged(page); + + await invokeTauri(page, "reconcile_inbound_persona_event", { + eventJson: JSON.stringify(tombstoneEvent), + }); + + // Step 4: assert the record is gone. + const afterTombstone = await invokeTauri>( + page, + "list_personas", + ); + expect(afterTombstone.some((p) => p.id === D_TAG)).toBe(false); + + // Step 5: assert the emit fired for the tombstone path too. + const fired = await awaitFired(); + expect(fired).toBe(true); +});