fix(studio): preserve memory drafts and pending writes

This commit is contained in:
xiexian committed 2026-10-06 18:18:05 +08:00
1 parent 722ceb4017
commit 6386cd91c9
2 files changed
+268 -13

No files matched your search

+13 -13
View File
@@ -25,7 +25,7 @@ function MemoryPanel({ port }: { port: AgentPort }) {
const [unread, setUnread] = useState("");
const [query, setQuery] = useState("");
const [open, setOpen] = useState("");
const [busy, setBusy] = useState("");
const [busy, setBusy] = useState<string[]>([]);
const [error, setError] = useState("");
const [edit, setEdit] = useState<MemoryEdit | null>(null);
const [past, setPast] = useState<Record<string, MemoryEntry[]>>({});
@@ -92,16 +92,16 @@ function MemoryPanel({ port }: { port: AgentPort }) {
const save = async () => {
if (!edit) return;
setBusy(edit.name);
setBusy((names) => [...names, edit.name]);
setError("");
try {
await port.saveMemory(edit);
setEdit(null);
setEdit((current) => current === edit ? null : current);
refreshMemory(edit.name);
} catch (e) {
setError(reason(e));
} finally {
setBusy("");
setBusy((names) => names.filter((name) => name !== edit.name));
}
};
@@ -124,7 +124,7 @@ function MemoryPanel({ port }: { port: AgentPort }) {
};
const restore = async (name: string, revision: number) => {
setBusy(name);
setBusy((names) => [...names, name]);
setError("");
try {
await port.restoreMemory(name, revision);
@@ -132,12 +132,12 @@ function MemoryPanel({ port }: { port: AgentPort }) {
} catch (e) {
setError(reason(e));
} finally {
setBusy("");
setBusy((names) => names.filter((target) => target !== name));
}
};
const forget = async (name: string) => {
setBusy(name);
setBusy((names) => [...names, name]);
setError("");
try {
await port.forgetMemory(name);
@@ -145,7 +145,7 @@ function MemoryPanel({ port }: { port: AgentPort }) {
} catch (e) {
setError(reason(e));
} finally {
setBusy("");
setBusy((names) => names.filter((target) => target !== name));
}
};
@@ -180,8 +180,8 @@ function MemoryPanel({ port }: { port: AgentPort }) {
{m.expired && <i className="stale">{t("已过期")}</i>}
<span className="sc">{t(SCOPE[m.scope ?? ""] ?? m.scope ?? "")}</span>
<span className="at">{m.updatedAt || m.createdAt}</span>
<button className="act ghost" data-action="memory.forget" data-target={m.name} disabled={busy === m.name} onClick={() => void forget(m.name)}>
{t(busy === m.name ? "…" : "忘记")}
<button className="act ghost" data-action="memory.forget" data-target={m.name} disabled={busy.includes(m.name)} onClick={() => void forget(m.name)}>
{t(busy.includes(m.name) ? "…" : "忘记")}
</button>
</div>
{m.usedLastTurn && m.why && <div className="why-used">{t("上一轮因「{why}」被检索到", { why: m.why })}</div>}
@@ -209,8 +209,8 @@ function MemoryPanel({ port }: { port: AgentPort }) {
</select>
</label>
<div className="row">
<button className="act" data-action="memory.save" data-target={m.name} disabled={busy === m.name} onClick={() => void save()}>
{t(busy === m.name ? "正在保存…" : "保存")}
<button className="act" data-action="memory.save" data-target={m.name} disabled={busy.includes(m.name)} onClick={() => void save()}>
{t(busy.includes(m.name) ? "正在保存…" : "保存")}
</button>
<button className="act ghost" onClick={() => setEdit(null)}>{t("取消")}</button>
{/* Saving writes a new revision rather than overwriting, which is
@@ -239,7 +239,7 @@ function MemoryPanel({ port }: { port: AgentPort }) {
{showPast === m.name && <History
list={past[m.name]}
current={m.revision ?? 1}
busy={busy === m.name}
busy={busy.includes(m.name)}
onRestore={(rev) => void restore(m.name, rev)}
/>}
</>
@@ -0,0 +1,255 @@
// @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, MemoryEdit, MemoryEntry } from "../port/port";
afterEach(cleanup);
function pending() {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
}
async function fixture() {
const port = new MockPort() as unknown as AgentPort;
const originals = (await port.memories()).memories.slice(0, 2);
for (const [index, fact] of originals.entries()) {
await port.saveMemory({ name: fact.name, title: `fact-${index}`, description: fact.description ?? "",
body: `fact-${index} initial`, activation: fact.activation });
}
const read = port.memories.bind(port);
vi.spyOn(port, "memories").mockImplementation(async () => {
const catalog = await read();
return { ...catalog, memories: catalog.memories.filter((m) => originals.some((o) => o.name === m.name)) };
});
const facts = (await read()).memories.filter((m) => originals.some((o) => o.name === m.name));
const view = render(<Memory port={port} />);
await screen.findByRole("button", { name: "fact-0" });
return { port, facts, view };
}
function row(fact: MemoryEntry) {
return Array.from(document.querySelectorAll<HTMLButtonElement>('[data-action="memory.forget"]'))
.find((button) => button.dataset.target === fact.name)!.closest<HTMLElement>(".memrow")!;
}
function control(fact: MemoryEntry, action: "save" | "restore" | "forget") {
return row(fact).querySelector<HTMLButtonElement>(`[data-action="memory.${action}"]`)!;
}
async function open(fact: MemoryEntry) {
if (!row(fact).querySelector(".peek")) await userEvent.click(row(fact).querySelector<HTMLButtonElement>(".nm")!);
}
async function draft(fact: MemoryEntry, body: string) {
await open(fact);
await userEvent.click(within(row(fact)).getByRole("button", { name: "编辑" }));
changeBody(fact, body);
}
function changeBody(fact: MemoryEntry, body: string) {
fireEvent.change(within(row(fact)).getByRole("textbox", { name: "正文" }), { target: { value: body } });
}
function body(fact: MemoryEntry) {
return within(row(fact)).getByRole<HTMLTextAreaElement>("textbox", { name: "正文" }).value;
}
function edit(fact: MemoryEntry, value: string): MemoryEdit {
return { name: fact.name, title: fact.title ?? "", description: fact.description ?? "",
body: value, activation: fact.activation };
}
function hold(port: AgentPort, action: "save" | "restore" | "forget", name: string) {
const gate = pending();
if (action === "save") {
const write = vi.isMockFunction(port.saveMemory)
? vi.mocked(port.saveMemory).getMockImplementation()!
: port.saveMemory.bind(port);
const spy = vi.spyOn(port, "saveMemory");
spy.mockImplementation(async (entry) => {
if (entry.name === name) await gate.promise;
await write(entry);
});
} else if (action === "restore") {
const write = vi.isMockFunction(port.restoreMemory)
? vi.mocked(port.restoreMemory).getMockImplementation()!
: port.restoreMemory.bind(port);
const spy = vi.spyOn(port, "restoreMemory");
spy.mockImplementation(async (target, revision) => {
if (target === name) await gate.promise;
await write(target, revision);
});
} else {
const write = vi.isMockFunction(port.forgetMemory)
? vi.mocked(port.forgetMemory).getMockImplementation()!
: port.forgetMemory.bind(port);
const spy = vi.spyOn(port, "forgetMemory");
spy.mockImplementation(async (target) => {
if (target === name) await gate.promise;
await write(target);
});
}
return gate;
}
async function start(fact: MemoryEntry, action: "save" | "restore" | "forget", value = "submitted") {
if (action === "save") await draft(fact, value);
else if (action === "restore") {
await open(fact);
await userEvent.click(within(row(fact)).getByRole("button", { name: "第 2 版,查看历史" }));
await within(row(fact)).findByRole("button", { name: "恢复此版本" });
}
await userEvent.click(control(fact, action));
}
it("retains edits made during a save, then submits the latest exact payload", async () => {
const { port, facts: [a], view } = await fixture();
const gate = hold(port, "save", a.name);
await start(a, "save");
changeBody(a, "still writing");
const activation = a.activation === "pinned" ? "relevant" : "pinned";
fireEvent.change(within(row(a)).getByRole("combobox", { name: "生效时机" }), { target: { value: activation } });
view.rerender(<Memory port={port} />);
expect(control(a, "save").disabled).toBe(true);
await act(async () => gate.resolve());
expect(body(a)).toBe("still writing");
expect(within(row(a)).getByRole<HTMLSelectElement>("combobox", { name: "生效时机" }).value).toBe(activation);
expect(control(a, "save").disabled).toBe(false);
await userEvent.click(control(a, "save"));
await waitFor(() => expect(row(a).querySelector(".peek > pre")?.textContent).toBe("still writing"));
expect(vi.mocked(port.saveMemory).mock.calls).toEqual([[edit(a, "submitted")], [{ ...edit(a, "still writing"), activation }]]);
const current = (await port.memories()).memories.find((m) => m.name === a.name)!;
expect(current.body).toBe("still writing");
expect(current.revision).toBe(4);
});
it("retains another fact's draft when an earlier save completes", async () => {
const { port, facts: [a, b] } = await fixture();
const gate = hold(port, "save", a.name);
await start(a, "save");
await draft(b, "other draft");
await act(async () => gate.resolve());
expect(body(b)).toBe("other draft");
expect(control(b, "save").disabled).toBe(false);
expect(vi.mocked(port.saveMemory).mock.calls).toEqual([[edit(a, "submitted")]]);
await userEvent.click(control(b, "save"));
await waitFor(() => expect(row(b).querySelector(".peek > pre")?.textContent).toBe("other draft"));
expect(vi.mocked(port.saveMemory).mock.calls).toEqual([[edit(a, "submitted")], [edit(b, "other draft")]]);
});
it("preserves a newly reopened draft even when it equals the submitted payload", async () => {
const { port, facts: [a] } = await fixture();
const gate = hold(port, "save", a.name);
await start(a, "save");
await userEvent.click(within(row(a)).getByRole("button", { name: "取消" }));
await draft(a, "submitted");
await act(async () => gate.resolve());
expect(body(a)).toBe("submitted");
expect(control(a, "save").disabled).toBe(false);
expect(vi.mocked(port.saveMemory)).toHaveBeenCalledExactlyOnceWith(edit(a, "submitted"));
});
it("closes an unchanged submitted draft after an ordinary rerender and save", async () => {
const { port, facts: [a], view } = await fixture();
const gate = hold(port, "save", a.name);
await start(a, "save");
view.rerender(<Memory port={port} />);
await act(async () => gate.resolve());
expect(within(row(a)).queryByRole("textbox", { name: "正文" })).toBeNull();
expect(row(a).querySelector(".peek > pre")?.textContent).toBe("submitted");
expect(control(a, "forget").disabled).toBe(false);
});
it("keeps a cancelled editor closed when its pending save settles", async () => {
const { port, facts: [a] } = await fixture();
const gate = hold(port, "save", a.name);
await start(a, "save");
await userEvent.click(within(row(a)).getByRole("button", { name: "取消" }));
await act(async () => gate.resolve());
expect(within(row(a)).queryByRole("textbox", { name: "正文" })).toBeNull();
expect(row(a).querySelector(".peek > pre")?.textContent).toBe("submitted");
});
it("keeps a refused draft editable and retries its exact payload", async () => {
const { port, facts: [a] } = await fixture();
const gate = pending();
const write = vi.spyOn(port, "saveMemory").mockImplementationOnce(() => gate.promise);
await start(a, "save");
await act(async () => gate.reject(new Error("save refused")));
expect(screen.getByText("save refused")).toBeTruthy();
expect(body(a)).toBe("submitted");
expect(control(a, "save").disabled).toBe(false);
await userEvent.click(control(a, "save"));
await waitFor(() => expect(row(a).querySelector(".peek > pre")?.textContent).toBe("submitted"));
expect(write.mock.calls).toEqual([[edit(a, "submitted")], [edit(a, "submitted")]]);
});
it("preserves another draft and frees only the failed fact's controls", async () => {
const { port, facts: [a, b] } = await fixture();
const gate = hold(port, "save", a.name);
await start(a, "save");
await draft(b, "other draft");
await act(async () => gate.reject(new Error("earlier save refused")));
expect(screen.getByText("earlier save refused")).toBeTruthy();
expect(body(b)).toBe("other draft");
expect(control(b, "save").disabled).toBe(false);
expect(control(a, "forget").disabled).toBe(false);
});
it.each(["save", "restore", "forget"] as const)("keeps the second save pending after the first %s succeeds", async (action) => {
const { port, facts: [a, b] } = await fixture();
const first = hold(port, action, a.name);
await start(a, action);
const second = hold(port, "save", b.name);
await start(b, "save", "second submitted");
expect(control(a, "forget").disabled).toBe(true);
expect(control(b, "save").disabled).toBe(true);
await act(async () => first.resolve());
expect(body(b)).toBe("second submitted");
expect(control(b, "save").disabled).toBe(true);
expect(control(b, "forget").disabled).toBe(true);
await userEvent.click(control(b, "save"));
expect(vi.mocked(port.saveMemory).mock.calls.filter(([value]) => value.name === b.name)).toHaveLength(1);
await act(async () => second.resolve());
expect(row(b).querySelector(".peek > pre")?.textContent).toBe("second submitted");
expect(control(b, "forget").disabled).toBe(false);
});
it.each(["save", "restore", "forget"] as const)("keeps the second save pending after the first %s fails", async (action) => {
const { port, facts: [a, b] } = await fixture();
const first = hold(port, action, a.name);
await start(a, action);
const second = hold(port, "save", b.name);
await start(b, "save", "second submitted");
await act(async () => first.reject(new Error("first write refused")));
expect(screen.getByText("first write refused")).toBeTruthy();
expect(body(b)).toBe("second submitted");
expect(control(b, "save").disabled).toBe(true);
expect(control(b, "forget").disabled).toBe(true);
expect(control(a, "forget").disabled).toBe(false);
await act(async () => second.resolve());
expect(row(b).querySelector(".peek > pre")?.textContent).toBe("second submitted");
});
it.each(["save", "restore", "forget"] as const)("keeps the first save pending when a later %s completes first", async (action) => {
const { port, facts: [a, b] } = await fixture();
const first = hold(port, "save", a.name);
await start(a, "save");
const second = hold(port, action, b.name);
await start(b, action, "second submitted");
await act(async () => second.resolve());
expect(control(a, "forget").disabled).toBe(true);
await userEvent.click(control(a, "forget"));
const forgotten = (await port.memories()).memories.every((m) => m.name !== a.name);
expect(forgotten).toBe(false);
await act(async () => first.resolve());
expect(control(a, "forget").disabled).toBe(false);
expect((await port.memories()).memories.find((m) => m.name === a.name)?.body).toBe("submitted");
});