From 57e6222d98dfb2333b6a975cfe9d448d030e312c Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Tue, 6 Oct 2026 06:13:43 +0800 Subject: [PATCH] fix(studio): refresh memory history after writes --- desktop/frontend-next/src/ui/Memory.tsx | 34 ++- .../ui/memory-history.interaction.test.tsx | 252 ++++++++++++++++++ 2 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 desktop/frontend-next/src/ui/memory-history.interaction.test.tsx diff --git a/desktop/frontend-next/src/ui/Memory.tsx b/desktop/frontend-next/src/ui/Memory.tsx index c877ca3d37..dcdaa900e2 100644 --- a/desktop/frontend-next/src/ui/Memory.tsx +++ b/desktop/frontend-next/src/ui/Memory.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { t } from "../i18n"; import { tx } from "../i18n/rich"; import { reason } from "../i18n/kernel"; @@ -24,6 +24,7 @@ export function Memory({ port }: { port: AgentPort }) { const [edit, setEdit] = useState(null); const [past, setPast] = useState>({}); const [showPast, setShowPast] = useState(""); + const historyRequests = useRef>({}); const reload = () => { port @@ -72,6 +73,17 @@ export function Memory({ port }: { port: AgentPort }) { ); + const refreshMemory = (name: string) => { + delete historyRequests.current[name]; + setPast((p) => { + const next = { ...p }; + delete next[name]; + return next; + }); + setShowPast((shown) => shown === name ? "" : shown); + reload(); + }; + const save = async () => { if (!edit) return; setBusy(edit.name); @@ -79,7 +91,7 @@ export function Memory({ port }: { port: AgentPort }) { try { await port.saveMemory(edit); setEdit(null); - reload(); + refreshMemory(edit.name); } catch (e) { setError(reason(e)); } finally { @@ -92,12 +104,16 @@ export function Memory({ port }: { port: AgentPort }) { setShowPast(name); setError(""); if (past[name]) return; + const request = {}; + historyRequests.current[name] = request; try { const list = await port.memoryRevisions(name); + if (historyRequests.current[name] !== request) return; setPast((p) => ({ ...p, [name]: list })); } catch (e) { + if (historyRequests.current[name] !== request) return; setError(reason(e)); - setShowPast(""); + setShowPast((shown) => shown === name ? "" : shown); } }; @@ -106,15 +122,7 @@ export function Memory({ port }: { port: AgentPort }) { setError(""); try { await port.restoreMemory(name, revision); - // The restore wrote a new revision, so the cached list is now one short. - // Drop the key rather than emptying it — an empty array reads as cached. - setPast((p) => { - const next = { ...p }; - delete next[name]; - return next; - }); - setShowPast(""); - reload(); + refreshMemory(name); } catch (e) { setError(reason(e)); } finally { @@ -127,7 +135,7 @@ export function Memory({ port }: { port: AgentPort }) { setError(""); try { await port.forgetMemory(name); - reload(); + refreshMemory(name); } catch (e) { setError(reason(e)); } finally { diff --git a/desktop/frontend-next/src/ui/memory-history.interaction.test.tsx b/desktop/frontend-next/src/ui/memory-history.interaction.test.tsx new file mode 100644 index 0000000000..2d555c4f24 --- /dev/null +++ b/desktop/frontend-next/src/ui/memory-history.interaction.test.tsx @@ -0,0 +1,252 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { Memory } from "./Memory"; +import { MockPort } from "../port/mock"; +import type { AgentPort, MemoryEntry } from "../port/port"; + +afterEach(cleanup); +let names: string[]; + +function pending() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + return { promise, resolve, reject }; +} + +async function fixture(count = 1) { + const port = new MockPort() as unknown as AgentPort; + const originals = (await port.memories()).memories.slice(0, count); + names = originals.map((m) => m.name); + for (const [index, fact] of originals.entries()) { + await port.saveMemory({ name: fact.name, title: `fact-${index}`, description: fact.description ?? "", + body: `fact-${index} revision two`, activation: fact.activation }); + } + const read = port.memories.bind(port); + const catalog = vi.spyOn(port, "memories").mockImplementation(async () => { + const current = await read(); + return { ...current, memories: current.memories.filter((m) => names.includes(m.name)) }; + }); + const facts = (await read()).memories.filter((m) => originals.some((o) => o.name === m.name)); + const revisions = vi.spyOn(port, "memoryRevisions"); + const view = render(); + await screen.findByRole("button", { name: "fact-0" }); + return { port, originals, facts, catalog, revisions, view }; +} + +function row(index = 0) { + return Array.from(document.querySelectorAll(".memrow")) + .find((el) => el.querySelector('[data-action="memory.forget"]')?.dataset.target === names[index])!; +} + +async function expand(index = 0) { + if (!row(index).querySelector(".peek")) await userEvent.click(row(index).querySelector(".nm")!); +} + +async function history(revision: number, index = 0) { + await expand(index); + const collapse = within(row(index)).queryByRole("button", { name: "收起旧版本" }); + if (collapse) await userEvent.click(collapse); + await userEvent.click(within(row(index)).getByRole("button", { name: `第 ${revision} 版,查看历史` })); +} + +async function draft(body: string, index = 0) { + await expand(index); + await userEvent.click(within(row(index)).getByRole("button", { name: "编辑" })); + fireEvent.change(within(row(index)).getByRole("textbox", { name: "正文" }), { target: { value: body } }); +} + +async function save(body: string, index = 0) { + await draft(body, index); + await userEvent.click(within(row(index)).getByRole("button", { name: "保存" })); + await waitFor(() => expect(row(index).querySelector(".peek > pre")?.textContent).toBe(body)); +} + +function versions(index = 0) { + return Array.from(row(index).querySelectorAll(".histrow .rev"), (el) => el.textContent); +} + +async function restore(revision: number, index = 0) { + const entry = Array.from(row(index).querySelectorAll(".histrow")) + .find((el) => within(el).queryByText(`第 ${revision} 版`))!; + await userEvent.click(within(entry).getByRole("button", { name: "恢复此版本" })); +} + +it("offers every retained intermediate save and restores the selected revision by appending", async () => { + const { port, facts, revisions } = await fixture(); + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + await save("revision three"); + await save("revision four"); + await history(4); + await waitFor(() => expect(versions()).toEqual(["第 3 版", "第 2 版", "第 1 版"])); + const write = vi.spyOn(port, "restoreMemory"); + await restore(3); + await waitFor(() => expect(row().querySelector(".peek > pre")?.textContent).toBe("revision three")); + expect(write).toHaveBeenCalledExactlyOnceWith(facts[0].name, 3); + await history(5); + await waitFor(() => expect(versions()).toEqual(["第 4 版", "第 3 版", "第 2 版", "第 1 版"])); + expect(revisions).toHaveBeenCalledTimes(4); + expect((await port.memoryRevisions(facts[0].name)).map((m) => m.revision ?? 1)).toEqual([5, 4, 3, 2, 1]); +}); + +it.each(["before", "after"])("rejects pre-save history arriving %s the new history response", async (timing) => { + const { port, facts, revisions } = await fixture(); + const oldList = await port.memoryRevisions(facts[0].name); + revisions.mockClear(); + const old = pending(); + revisions.mockReturnValueOnce(old.promise); + await history(2); + await save("revision three"); + await save("revision four"); + if (timing === "before") await act(async () => old.resolve(oldList)); + await history(4); + await waitFor(() => expect(versions()).toEqual(["第 3 版", "第 2 版", "第 1 版"])); + if (timing === "after") await act(async () => old.resolve(oldList)); + expect(versions()).toEqual(["第 3 版", "第 2 版", "第 1 版"]); + expect(revisions).toHaveBeenCalledTimes(2); +}); + +it("keeps fresh history loading when a pre-save history request fails", async () => { + const { port, facts, revisions } = await fixture(); + const read = port.memoryRevisions.bind(port); + const old = pending(); + const fresh = pending(); + revisions.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise); + await history(2); + await save("revision three"); + await history(3); + await act(async () => old.reject(new Error("obsolete history refusal"))); + expect(within(row()).getByText("正在读取历史版本…")).toBeTruthy(); + expect(screen.queryByText("obsolete history refusal")).toBeNull(); + await act(async () => fresh.resolve(await read(facts[0].name))); + expect(versions()).toEqual(["第 2 版", "第 1 版"]); +}); + +it.each(["success", "failure"])("ignores a superseded history %s after restoring a revision", async (outcome) => { + const { port, facts, revisions } = await fixture(); + const oldList = await port.memoryRevisions(facts[0].name); + const old = pending(); + revisions.mockReturnValueOnce(old.promise); + await history(2); + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + await restore(1); + await waitFor(() => expect(row().querySelector(".peek > pre")?.textContent).toBe(oldList[1].body?.trim())); + await save("after restore"); + await history(4); + await waitFor(() => expect(versions()).toEqual(["第 3 版", "第 2 版", "第 1 版"])); + await act(async () => outcome === "success" ? old.resolve(oldList) : old.reject(new Error("obsolete restore history"))); + expect(versions()).toEqual(["第 3 版", "第 2 版", "第 1 版"]); + expect(screen.queryByText("obsolete restore history")).toBeNull(); +}); + +it("preserves a refused save's draft and cache, then retries the exact edit", async () => { + const { port, facts, revisions } = await fixture(); + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + const write = vi.spyOn(port, "saveMemory").mockRejectedValueOnce(new Error("save refused")); + await draft("retry body"); + await userEvent.click(screen.getByRole("button", { name: "保存" })); + await screen.findByText("save refused"); + expect(screen.getByRole("textbox", { name: "正文" }).value).toBe("retry body"); + await userEvent.click(screen.getByRole("button", { name: "取消" })); + await history(2); + expect(versions()).toEqual(["第 1 版"]); + expect(revisions).toHaveBeenCalledTimes(1); + await draft("retry body"); + await userEvent.click(screen.getByRole("button", { name: "保存" })); + expect(write.mock.calls).toEqual([0, 1].map(() => [{ name: facts[0].name, title: "fact-0", + description: facts[0].description ?? "", body: "retry body", activation: facts[0].activation }])); + await waitFor(() => expect(row().querySelector(".peek > pre")?.textContent).toBe("retry body")); + await history(3); + await waitFor(() => expect(versions()).toEqual(["第 2 版", "第 1 版"])); + expect(revisions).toHaveBeenCalledTimes(2); +}); + +it("keeps history and the exact restore target after refusal", async () => { + const { port, facts, revisions } = await fixture(); + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + const write = vi.spyOn(port, "restoreMemory").mockRejectedValueOnce(new Error("restore refused")); + await restore(1); + await screen.findByText("restore refused"); + expect(versions()).toEqual(["第 1 版"]); + expect(revisions).toHaveBeenCalledTimes(1); + await restore(1); + expect(write.mock.calls).toEqual([[facts[0].name, 1], [facts[0].name, 1]]); + await waitFor(() => expect(row().querySelector(".peek > pre")?.textContent).not.toBe("fact-0 revision two")); + await history(3); + await waitFor(() => expect(versions()).toEqual(["第 2 版", "第 1 版"])); +}); + +it("keeps a refused forget's cached history and retries the same fact", async () => { + const { port, facts, revisions } = await fixture(); + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + const write = vi.spyOn(port, "forgetMemory").mockRejectedValueOnce(new Error("forget refused")); + await userEvent.click(within(row()).getByRole("button", { name: "忘记" })); + await screen.findByText("forget refused"); + expect(versions()).toEqual(["第 1 版"]); + expect(revisions).toHaveBeenCalledTimes(1); + await userEvent.click(within(row()).getByRole("button", { name: "忘记" })); + expect(await screen.findByText("暂无记录。")).toBeTruthy(); + expect(write.mock.calls).toEqual([[facts[0].name], [facts[0].name]]); +}); + +it("does not surface an obsolete history refusal after forgetting its fact", async () => { + const { revisions } = await fixture(2); + const old = pending(); + revisions.mockReturnValueOnce(old.promise); + await history(2); + await userEvent.click(within(row()).getByRole("button", { name: "忘记" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "fact-0" })).toBeNull()); + await act(async () => old.reject(new Error("forgotten history refusal"))); + expect(screen.queryByText("forgotten history refusal")).toBeNull(); + expect(screen.getByRole("button", { name: "fact-1" })).toBeTruthy(); +}); + +it.each(["save", "restore", "forget"])("preserves another fact's in-flight history across %s", async (action) => { + const { port, facts, revisions } = await fixture(2); + const otherList = await port.memoryRevisions(facts[1].name); + revisions.mockClear(); + const other = pending(); + if (action === "restore") { + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + const write = pending(); + const originalRestore = port.restoreMemory.bind(port); + vi.spyOn(port, "restoreMemory").mockImplementationOnce(async (name, revision) => { + await write.promise; + await originalRestore(name, revision); + }); + await restore(1); + revisions.mockReturnValueOnce(other.promise); + await history(2, 1); + await act(async () => write.resolve()); + } else { + revisions.mockReturnValueOnce(other.promise); + await history(2, 1); + if (action === "save") { await save("fact zero saved"); await expand(1); } + else await userEvent.click(within(row()).getByRole("button", { name: "忘记" })); + } + await act(async () => other.resolve(otherList)); + expect(versions(1)).toEqual(["第 1 版"]); + expect(revisions.mock.calls.filter(([name]) => name === facts[1].name)).toHaveLength(1); +}); + +it("reuses unchanged history across rerenders, collapsing and a cancelled edit", async () => { + const { port, revisions, view } = await fixture(); + await history(2); + await waitFor(() => expect(versions()).toEqual(["第 1 版"])); + view.rerender(); + await draft("cancelled body"); + await userEvent.click(screen.getByRole("button", { name: "取消" })); + await history(2); + expect(versions()).toEqual(["第 1 版"]); + expect(revisions).toHaveBeenCalledTimes(1); + expect(row().querySelector(".peek > pre")?.textContent).toBe("fact-0 revision two"); +});