Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 3 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/commands/personas.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use tauri::{AppHandle, State};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;

use super::export_util::save_json_with_dialog;
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(())
}

Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/features/agents/lib/useAgentsDataRefresh.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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]);
}
217 changes: 217 additions & 0 deletions desktop/tests/e2e/persona-sync.spec.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<T> {
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<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (c: string, p?: Record<string, unknown>) => Promise<unknown>;
};
};
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<boolean>> {
// Inject a Promise into the page that resolves when the event fires.
await page.evaluate(() => {
(
window as Window & { __agentsDataChangedFired?: Promise<boolean> }
).__agentsDataChangedFired = new Promise<boolean>((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<boolean> })
.__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<Array<{ id: string }>>(
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<Array<{ id: string }>>(
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);
});
Loading