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 <willem.jiang@gmail.com>
This commit is contained in:
Qiu 2026-10-19 11:33:35 +08:00 committed by GitHub
parent eed7fa8f05
commit a1f23b294a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 226 additions and 5 deletions

View File

@ -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

View File

@ -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",

View File

@ -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`.

View File

@ -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<UserMemory>(["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<UserMemory>(["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<UserMemory>(["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<UserMemory>(["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<UserMemory>(["memory"], memory);
},
});

View File

@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<UserMemory>();
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 (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
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<void>;
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<UserMemory>();
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 (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
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));
});