From a1f23b294a09988805459b6125871b5377ffe967 Mon Sep 17 00:00:00 2001 From: Qiu <2889459163@qq.com> Date: Mon, 19 Oct 2026 11:33:35 +0800 Subject: [PATCH] fix(frontend): preserve memory mutations against stale reads (#6959) * fix(frontend): preserve memory mutations against stale reads * test: register memory guidance in scoped inventory --------- Co-authored-by: Willem Jiang --- README.md | 4 + backend/tests/test_agent_guidance_check.py | 1 + frontend/src/core/memory/AGENTS.md | 6 + frontend/src/core/memory/hooks.ts | 15 +- .../tests/unit/core/memory/hooks.dom.test.tsx | 205 ++++++++++++++++++ 5 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 frontend/src/core/memory/AGENTS.md create mode 100644 frontend/tests/unit/core/memory/hooks.dom.test.tsx diff --git a/README.md b/README.md index 8912475f3c..d93ec0610c 100644 --- a/README.md +++ b/README.md @@ -2847,6 +2847,10 @@ become available in the next batch, when rejected additions can be retried. ### Long-Term Memory +Settings > Memory keeps successful clear, import, and fact edits visible even +when an older background read finishes afterward. Later reads still refresh +the view normally; failed actions preserve the existing cached memory. + DeerMem debounces pending updates independently per conversation, user, and agent. `memory.backend_config.debounce_seconds` sets the quiet period (default 30 seconds), and `max_wait_seconds` caps debounce deferral from the first pending update diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index fc1eb7b4b4..f0252235db 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -53,6 +53,7 @@ EXPECTED_GUIDANCE_PATHS = { "backend/packages/harness/deerflow/uploads/AGENTS.md", "backend/packages/harness/deerflow/utils/AGENTS.md", "frontend/src/AGENTS.md", + "frontend/src/core/memory/AGENTS.md", "scripts/AGENTS.md", "examples/deerflow-extension-agent-teams/AGENTS.md", "examples/deerflow-extension-workflows/AGENTS.md", diff --git a/frontend/src/core/memory/AGENTS.md b/frontend/src/core/memory/AGENTS.md new file mode 100644 index 0000000000..9530ba522a --- /dev/null +++ b/frontend/src/core/memory/AGENTS.md @@ -0,0 +1,6 @@ +# Memory queries + +Successful mutations cancel the exact `["memory"]` query before caching their +response; older reads must not undo confirmed edits. Preserve failed mutations +and later refreshes. Regressions use real QueryClient hooks in +`frontend/tests/unit/core/memory/hooks.dom.test.tsx`. diff --git a/frontend/src/core/memory/hooks.ts b/frontend/src/core/memory/hooks.ts index 36836ca5b5..1198cab440 100644 --- a/frontend/src/core/memory/hooks.ts +++ b/frontend/src/core/memory/hooks.ts @@ -27,7 +27,8 @@ export function useClearMemory() { return useMutation({ mutationFn: () => clearMemory(), - onSuccess: (memory) => { + onSuccess: async (memory) => { + await queryClient.cancelQueries({ queryKey: ["memory"], exact: true }); queryClient.setQueryData(["memory"], memory); }, }); @@ -38,7 +39,8 @@ export function useDeleteMemoryFact() { return useMutation({ mutationFn: (factId: string) => deleteMemoryFact(factId), - onSuccess: (memory) => { + onSuccess: async (memory) => { + await queryClient.cancelQueries({ queryKey: ["memory"], exact: true }); queryClient.setQueryData(["memory"], memory); }, }); @@ -49,7 +51,8 @@ export function useImportMemory() { return useMutation({ mutationFn: (memory: UserMemory) => importMemory(memory), - onSuccess: (memory) => { + onSuccess: async (memory) => { + await queryClient.cancelQueries({ queryKey: ["memory"], exact: true }); queryClient.setQueryData(["memory"], memory); }, }); @@ -60,7 +63,8 @@ export function useCreateMemoryFact() { return useMutation({ mutationFn: (input: MemoryFactInput) => createMemoryFact(input), - onSuccess: (memory) => { + onSuccess: async (memory) => { + await queryClient.cancelQueries({ queryKey: ["memory"], exact: true }); queryClient.setQueryData(["memory"], memory); }, }); @@ -77,7 +81,8 @@ export function useUpdateMemoryFact() { factId: string; input: MemoryFactPatchInput; }) => updateMemoryFact(factId, input), - onSuccess: (memory) => { + onSuccess: async (memory) => { + await queryClient.cancelQueries({ queryKey: ["memory"], exact: true }); queryClient.setQueryData(["memory"], memory); }, }); diff --git a/frontend/tests/unit/core/memory/hooks.dom.test.tsx b/frontend/tests/unit/core/memory/hooks.dom.test.tsx new file mode 100644 index 0000000000..45ec1a2785 --- /dev/null +++ b/frontend/tests/unit/core/memory/hooks.dom.test.tsx @@ -0,0 +1,205 @@ +import { afterEach, expect, test, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import * as api from "@/core/memory/api"; +import { + useClearMemory, + useCreateMemoryFact, + useDeleteMemoryFact, + useImportMemory, + useMemory, + useUpdateMemoryFact, +} from "@/core/memory/hooks"; +import type { UserMemory } from "@/core/memory/types"; + +rs.mock("@/core/memory/api", () => ({ + loadMemory: rs.fn(), + clearMemory: rs.fn(), + createMemoryFact: rs.fn(), + deleteMemoryFact: rs.fn(), + importMemory: rs.fn(), + updateMemoryFact: rs.fn(), +})); + +function memory(contents: string[], revision: number): UserMemory { + const section = { summary: "", updatedAt: "" }; + return { + version: "1.0", + revision, + lastUpdated: "2026-10-15T10:00:00Z", + user: { + workContext: section, + personalContext: section, + topOfMind: section, + cognitiveStyle: section, + }, + history: { + recentMonths: section, + earlierContext: section, + longTermBackground: section, + }, + facts: contents.map((content) => ({ + id: content, + content, + category: "context", + confidence: 0.8, + createdAt: "2026-10-15T10:00:00Z", + source: "manual", + })), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const clients: QueryClient[] = []; +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); + rs.resetAllMocks(); +}); + +const cases = [ + { action: "clear", contents: [] }, + { action: "delete", contents: [] }, + { action: "import", contents: ["imported fact"] }, + { action: "create", contents: ["old fact", "new fact"] }, + { action: "update", contents: ["edited fact"] }, +] as const; + +for (const { action, contents } of cases) { + test(`a delayed memory read cannot overwrite a confirmed ${action}`, async () => { + const before = memory(["old fact"], 1); + const confirmed = memory([...contents], 2); + const delayedRead = deferred(); + rs.mocked(api.loadMemory) + .mockResolvedValueOnce(before) + .mockReturnValueOnce(delayedRead.promise); + rs.mocked(api.clearMemory).mockResolvedValue(confirmed); + rs.mocked(api.deleteMemoryFact).mockResolvedValue(confirmed); + rs.mocked(api.importMemory).mockResolvedValue(confirmed); + rs.mocked(api.createMemoryFact).mockResolvedValue(confirmed); + rs.mocked(api.updateMemoryFact).mockResolvedValue(confirmed); + + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + clients.push(client); + function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + } + const { result } = renderHook( + () => ({ + query: useMemory(), + clear: useClearMemory(), + delete: useDeleteMemoryFact(), + import: useImportMemory(), + create: useCreateMemoryFact(), + update: useUpdateMemoryFact(), + }), + { wrapper }, + ); + await waitFor(() => expect(result.current.query.memory).toEqual(before)); + + // Model a focus/refetch GET that captured the old server snapshot before + // the user mutated memory, but whose response is delayed in transit. + let refetch!: Promise; + act(() => { + refetch = client.refetchQueries({ queryKey: ["memory"] }); + }); + expect(api.loadMemory).toHaveBeenCalledTimes(2); + + await act(async () => { + switch (action) { + case "clear": + await result.current.clear.mutateAsync(); + break; + case "delete": + await result.current.delete.mutateAsync("old fact"); + break; + case "import": + await result.current.import.mutateAsync(confirmed); + break; + case "create": + await result.current.create.mutateAsync({ + content: "new fact", + category: "context", + confidence: 0.8, + }); + break; + case "update": + await result.current.update.mutateAsync({ + factId: "old fact", + input: { content: "edited fact" }, + }); + break; + } + }); + expect(client.getQueryData(["memory"])).toEqual(confirmed); + + await act(async () => { + delayedRead.resolve(before); + await refetch; + }); + expect(client.getQueryData(["memory"])).toEqual(confirmed); + await waitFor(() => expect(result.current.query.memory).toEqual(confirmed)); + + // Cancelling the older read must not prevent subsequent server refreshes. + const refreshed = memory(["newer server fact"], 3); + rs.mocked(api.loadMemory).mockResolvedValueOnce(refreshed); + await act(async () => { + await client.refetchQueries({ queryKey: ["memory"] }); + }); + await waitFor(() => expect(result.current.query.memory).toEqual(refreshed)); + }); +} + +test("a failed clear preserves the cache and allows an in-flight read to finish", async () => { + const before = memory(["old fact"], 1); + const refreshed = memory(["newer server fact"], 2); + const delayedRead = deferred(); + rs.mocked(api.loadMemory).mockReturnValue(delayedRead.promise); + rs.mocked(api.clearMemory).mockRejectedValue(new Error("Clear failed")); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + clients.push(client); + client.setQueryData(["memory"], before); + function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + } + const { result } = renderHook( + () => ({ query: useMemory(), clear: useClearMemory() }), + { wrapper }, + ); + await waitFor(() => expect(api.loadMemory).toHaveBeenCalledTimes(1)); + + await act(async () => { + await expect(result.current.clear.mutateAsync()).rejects.toThrow( + "Clear failed", + ); + }); + expect(client.getQueryData(["memory"])).toEqual(before); + + await act(async () => { + delayedRead.resolve(refreshed); + }); + await waitFor(() => expect(result.current.query.memory).toEqual(refreshed)); +});