diff --git a/desktop/src/features/agents/ui/usePersonaActions.test.mjs b/desktop/src/features/agents/ui/usePersonaActions.test.mjs index 2a93e23bf3..f435c16ff9 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.test.mjs +++ b/desktop/src/features/agents/ui/usePersonaActions.test.mjs @@ -1,10 +1,29 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { after, before, test } from "node:test"; +import { JSDOM } from "jsdom"; import { toast } from "sonner"; import { resolveLatestPersonaToEdit } from "./usePersonaActions.ts"; import { runAgentSaveCoordinator } from "./agentSaveCoordinator.ts"; +// JSDOM globals for the one mounted-hook test below. The pure resolver/seam +// tests need no DOM; installing these is inert for them. +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + localStorage: dom.window.localStorage, + }); +}); + +after(() => dom.window.close()); + // ── P1-2 seam: Agents-library edit route must rebind ctx to the latest persona ─ // // Thufir's IMPORTANT finding: the drift guard lives in the coordinator, but on @@ -190,3 +209,132 @@ test("test_agents_route_no_concurrent_edit_proceeds_to_write", async () => { cap.restore(); } }); + +// ── Mounted-hook pin: the memo-to-return wiring is what advances ctx ────────── +// +// The seam tests above call resolveLatestPersonaToEdit() directly, so they pass +// even if the production `useMemo`/return-field wiring in usePersonaActions is +// reverted (the clobber path Thufir restored under both mutations). This test +// renders the REAL hook: it selects at T0, replaces the persona query data with +// a same-ID T1, and asserts the value the hook RETURNS as `personaToEdit` +// advances to T1 — then feeds that returned value into the real coordinator and +// keeps the drift-abort assertion. Reverting either the memo or its returned +// field makes `personaToEdit` stay pinned at the T0 snapshot, so both the +// advance assertion and the coordinator abort fail. + +async function renderPersonaActions(seedPersona) { + const { createElement } = await import("react"); + const { renderHook } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + ); + + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + // Seed every query the hook reads so no Tauri command fires. With no + // community in localStorage the catalog query is disabled and its live + // subscription is a no-op, so relayClient is never touched. + qc.setQueryData(["personas"], [seedPersona]); + qc.setQueryData(["identity"], { pubkey: "a".repeat(64) }); + qc.setQueryData(["acp-runtimes"], []); + + const rendered = renderHook( + () => { + const { usePersonaActions } = hookModule; + return usePersonaActions(); + }, + { + wrapper: ({ children }) => + createElement( + QueryClientProvider, + { client: qc }, + createElement(CommunitiesProvider, null, children), + ), + }, + ); + return { qc, ...rendered }; +} + +let hookModule; +before(async () => { + hookModule = await import("./usePersonaActions.ts"); +}); + +test("test_hook_returns_rebound_persona_and_coordinator_aborts_on_same_id_refresh", async () => { + const { act } = await import("@testing-library/react"); + const cap = captureToasts(); + try { + const snapshot = makeDefinition({ updatedAt: "2025-01-01T00:00:00Z" }); + const seededUpdatedAt = snapshot.updatedAt; // captured at open time + + const { qc, result, unmount } = await renderPersonaActions(snapshot); + + // User clicks the persona in the Agents library at T0. + act(() => result.current.openEdit(snapshot)); + assert.equal( + result.current.personaToEdit?.updatedAt, + seededUpdatedAt, + "precondition: hook returns the open-time snapshot", + ); + + // A concurrent writer revises the SAME definition; personasQuery refreshes. + const refreshed = makeDefinition({ updatedAt: "2025-06-01T00:00:00Z" }); + await act(async () => { + qc.setQueryData(["personas"], [refreshed]); + await Promise.resolve(); + }); + + // The value the hook RETURNS must advance to the live revision — this is + // the memo-to-return wiring the direct-resolver seam tests cannot pin. + assert.equal( + result.current.personaToEdit?.updatedAt, + "2025-06-01T00:00:00Z", + "the hook's returned personaToEdit must rebind to the refreshed revision", + ); + + // Feed exactly that returned value into the coordinator as ctx.definition. + let definitionWrites = 0; + const saveResult = await runAgentSaveCoordinator({ + ctx: { + kind: "definition-only", + definition: result.current.personaToEdit, + }, + personaInput: makePersonaInput(), + agentInput: null, + policySets: [], + expectedDefinitionUpdatedAt: seededUpdatedAt, + updatePersona: async () => { + definitionWrites++; + }, + updatePersonaAndPublish: async () => { + definitionWrites++; + return { publicationStatus: "published" }; + }, + updateManagedAgent: async () => ({ agent: null, profileSyncError: null }), + setAutoRestart: async () => {}, + setStartOnAppLaunch: async () => {}, + refetchStores: async () => ({ persona: refreshed, agent: null }), + onDone: () => {}, + }); + + assert.equal(saveResult, false, "drift must abort the save"); + assert.equal( + definitionWrites, + 0, + "ZERO definition writes when the hook rebinds ctx past the seed revision", + ); + const errors = cap.captured.filter((c) => c.kind === "error"); + assert.equal(errors.length, 1, "one concurrent-edit error toast"); + assert.match(errors[0].message, /changed while you were editing/i); + + unmount(); + } finally { + cap.restore(); + } +});