Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/acceptance/2026-10-28-memory-retention/README.md
Original file line number Diff line number Diff line change
@@ -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`。编辑框只有日期精度,保存时会将到期时间调整为所选本地日期的日终;已有记忆不会自动迁移。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 64 additions & 1 deletion web/src/components/context-continuity-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 13 additions & 2 deletions web/src/components/context-continuity-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")}</button>
<button className="compact-command" disabled={!client.hasControl || updateMemory.isPending}
Expand Down Expand Up @@ -521,7 +521,18 @@ function ProjectInstructionsSection({ client, query, refreshing, refreshError, o
}

function retentionTimestamp(value: string): string | undefined {
return value ? new Date(`${value}T23:59:59.000Z`).toISOString() : undefined;
if (!value) return undefined;
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString();
}

function retentionDateValue(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}

function memoryReferenceList(value: string): string[] {
Expand Down
Loading