diff --git a/docs/acceptance/2026-10-28-memory-retention/README.md b/docs/acceptance/2026-10-28-memory-retention/README.md new file mode 100644 index 00000000..05a0ce4a --- /dev/null +++ b/docs/acceptance/2026-10-28-memory-retention/README.md @@ -0,0 +1,11 @@ +# 记忆保留日期验收 + +2026-10-28 使用香港时区的 Microsoft Edge,在隔离数据目录中运行真实 Go API 和两个生产前端构建。修改前为主线 `fb1170fb2947813bd86a141d07a430b522c5f7a4`,修改后为 PR 代码 `a148abac9e4d03a233b54e45e9e12bb0ed1a43e0` 合入该主线后的构建。两次使用同一测试记忆,未替换 API 响应或页面内容;截图为实际页面中的记忆编辑区域。 + +测试记忆的原始到期时间为 `2030-03-14T16:30:00Z`,即香港时间 2030 年 3 月 15 日 00:30。列表显示 3 月 15 日,修改前编辑框却回填 3 月 14 日;修改后回填 3 月 15 日。 + +| 修改前 | 修改后 | +| --- | --- | +| ![修改前回填 3 月 14 日](before.png) | ![修改后回填 3 月 15 日](after.png) | + +通过实际页面保存该编辑后,接口返回 `2030-03-15T15:59:59.999Z`,即香港时间 3 月 15 日日终。通过页面新建并选择 2030 年 2 月 28 日后,接口返回 `2030-02-28T15:59:59.999Z`。编辑框只有日期精度,保存时会将到期时间调整为所选本地日期的日终;已有记忆不会自动迁移。 diff --git a/docs/acceptance/2026-10-28-memory-retention/after.png b/docs/acceptance/2026-10-28-memory-retention/after.png new file mode 100644 index 00000000..b5e6d5f3 Binary files /dev/null and b/docs/acceptance/2026-10-28-memory-retention/after.png differ diff --git a/docs/acceptance/2026-10-28-memory-retention/before.png b/docs/acceptance/2026-10-28-memory-retention/before.png new file mode 100644 index 00000000..4b827b99 Binary files /dev/null and b/docs/acceptance/2026-10-28-memory-retention/before.png differ diff --git a/web/src/components/context-continuity-panel.test.tsx b/web/src/components/context-continuity-panel.test.tsx index f93ea30e..a71ad48d 100644 --- a/web/src/components/context-continuity-panel.test.tsx +++ b/web/src/components/context-continuity-panel.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { APIClient } from "../api/client"; import { downloadTextFile } from "../lib/download"; @@ -82,6 +82,69 @@ describe("ContextContinuityPanel", () => { "application/json", `${JSON.stringify(exported, null, 2)}\n`)); }); + it.each([ + ["Asia/Hong_Kong", "2030-02-28T15:59:59.999Z"], + ["America/Los_Angeles", "2030-03-01T07:59:59.999Z"], + ])("retains memory through the selected local day in %s", async (timezone, expected) => { + const previousTimezone = process.env.TZ; + process.env.TZ = timezone; + try { + const user = userEvent.setup(); + const get = vi.fn().mockImplementation((path: string) => { + if (path.includes("project-instructions")) return Promise.resolve(projectInstructionState()); + if (path === "/memories") return Promise.resolve([memoryView()]); + if (path === "/memory-candidates") return Promise.resolve([]); + if (path.includes("/tree")) return Promise.resolve(sessionTree()); + return Promise.reject(new Error(`unexpected read ${path}`)); + }); + const postControl = vi.fn().mockResolvedValue(memoryView()); + renderPanel({ hasControl: true, get, postControl, patchControl: vi.fn(), + deleteControl: vi.fn() } as unknown as APIClient); + + await screen.findByText("Test convention"); + await user.type(screen.getByLabelText("Memory title"), "Release checklist"); + await user.type(screen.getByLabelText("Memory content"), "Check the release notes."); + fireEvent.change(screen.getByLabelText("Retain until"), { target: { value: "2030-02-28" } }); + await user.click(screen.getByRole("button", { name: "Create memory" })); + + await waitFor(() => expect(postControl).toHaveBeenCalledWith("/memories", + expect.objectContaining({ title: "Release checklist", content: "Check the release notes.", + retention_until: expected }), + expect.stringMatching(/^web-memory-create-/))); + } finally { + if (previousTimezone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimezone; + } + }); + + it.each([ + ["Asia/Hong_Kong", "2030-03-14T16:30:00Z", "2030-03-15"], + ["America/Los_Angeles", "2030-03-15T06:30:00Z", "2030-03-14"], + ])("opens the editor on the retained local day in %s", async (timezone, retentionUntil, expected) => { + const previousTimezone = process.env.TZ; + process.env.TZ = timezone; + try { + const user = userEvent.setup(); + const get = vi.fn().mockImplementation((path: string) => { + if (path.includes("project-instructions")) return Promise.resolve(projectInstructionState()); + if (path === "/memories") return Promise.resolve([{ ...memoryView(), retention_until: retentionUntil }]); + if (path === "/memory-candidates") return Promise.resolve([]); + if (path.includes("/tree")) return Promise.resolve(sessionTree()); + return Promise.reject(new Error(`unexpected read ${path}`)); + }); + renderPanel({ hasControl: true, get, postControl: vi.fn(), patchControl: vi.fn(), + deleteControl: vi.fn() } as unknown as APIClient); + + await screen.findByText("Test convention"); + await user.click(screen.getByRole("button", { name: "Edit" })); + const article = screen.getByText("Test convention").closest("article"); + expect(within(article as HTMLElement).getByLabelText("Retain until")).toHaveValue(expected); + } finally { + if (previousTimezone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimezone; + } + }); + it("keeps mutation controls disabled on a read-only connection", async () => { renderPanel({ hasControl: false, get: vi.fn().mockImplementation((path: string) => path.includes("project-instructions") diff --git a/web/src/components/context-continuity-panel.tsx b/web/src/components/context-continuity-panel.tsx index da048e9c..bd17d865 100644 --- a/web/src/components/context-continuity-panel.tsx +++ b/web/src/components/context-continuity-panel.tsx @@ -339,7 +339,7 @@ export function ContextContinuityPanel({ client, runID, sessionID, workspaceID, setEditingMemoryID(memory.id); setEditDraft({ title: memory.title, content: memory.content, sourceRef: memory.source_ref ?? "", references: memory.references.join("\n"), - retention: memory.retention_until?.slice(0, 10) ?? "", + retention: retentionDateValue(memory.retention_until), redactSensitive: memory.redacted }); }} type="button">{t("编辑", "Edit")}