mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
eed7fa8f05
commit
a1f23b294a
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
6
frontend/src/core/memory/AGENTS.md
Normal file
6
frontend/src/core/memory/AGENTS.md
Normal 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`.
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
205
frontend/tests/unit/core/memory/hooks.dom.test.tsx
Normal file
205
frontend/tests/unit/core/memory/hooks.dom.test.tsx
Normal 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));
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user