evOo-local/apps/web/e2e/knowledge-panel.spec.ts
Claude 4875753d9c
feat(memory): remember appends dated notes instead of replacing memory
The remember tool rewrote the bot's whole MEMORY.md with each call, so every
new fact erased the earlier ones unless the model re-sent the full document.

- MemoryCommitRequest gains mode "append" (the store joins the note onto the
  current content inside the same serializable transaction and still writes a
  revision row) and every commit is capped at 16 KB per document.
- remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date)
  by default and rewrites only with mode "replace"; a full document returns an
  error that tells the bot to condense it. Retries of the same tool call stay
  single appends through the existing effect record.
- Scripted runtime memory entries can append too; the scripted "remember"
  script now appends instead of overwriting the bot's memory.
- When memory does not fit the run's 32 KB budget, the prompt keeps a
  document's newest lines instead of its oldest.
- memory.update maps an oversized save to BAD_REQUEST.
- docs/PRODUCT.md records that UserFact is the cross-space user model and the
  space MEMORY.md stays space notes (no migration).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:10:25 +00:00

238 lines
9.3 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { expect, test } from "@playwright/test";
import type { MemoryDocument } from "@rakazo/contracts";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
openUserSettings,
rpc,
signup,
} from "./helpers";
test("memory and skills are readable and editable in the app", async ({ page }, testInfo) => {
const stamp = Date.now();
const userName = `Knowledge ${stamp}`;
await signup(page, `knowledge-${stamp}@rakazo.test`, "password12", userName);
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
// Space-wide documents live in Settings → Memory. Open that before bot
// settings so the Knowledge Memory tab cannot steal this click.
await openUserSettings(page, "memory");
await expect(page.getByLabel("Close memory settings")).toBeVisible();
const spaceDocs = page.getByTestId("space-memory-documents");
await expect(spaceDocs.getByText("Shared documents")).toBeVisible();
const memoryRow = spaceDocs.getByRole("button", { name: /MEMORY\.md/ });
await expect(memoryRow).toBeVisible();
await memoryRow.click();
const docEditor = spaceDocs.locator("textarea");
const marker = `Edited in e2e ${stamp}`;
await docEditor.fill(`# Memory\n\n${marker}\n`);
await captureScreenshot(page, testInfo, "83-space-memory-editor");
let releaseSave!: () => void;
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
await page.route(
"**/rpc/memory/update",
async (route) => {
await saveGate;
await route.continue();
},
{ times: 1 },
);
await spaceDocs.getByRole("button", { name: "Save", exact: true }).click();
try {
await expect(docEditor).toBeDisabled();
await expect(memoryRow).toBeDisabled();
await expect(spaceDocs.getByRole("button", { name: "Cancel", exact: true })).toBeDisabled();
} finally {
releaseSave();
}
await expect(spaceDocs.getByText("rev 2")).toBeVisible();
// The save persisted: reopen the document and find the marker.
await memoryRow.click();
await expect(docEditor).toHaveValue(new RegExp(marker));
await captureScreenshot(page, testInfo, "84-space-memory-saved");
const sharedDocuments = await rpc<MemoryDocument[]>(page, "memory/list", { scope: "user" });
expect(sharedDocuments).toContainEqual(
expect.objectContaining({ content: `# Memory\n\n${marker}\n` }),
);
expect(
sharedDocuments.find((doc) => doc.content.includes(marker))?.revision,
).toBeGreaterThanOrEqual(2);
// Export must fetch fresh content and exclude the bot's private document.
const sharedDocument = sharedDocuments.find((doc) => doc.path === "MEMORY.md")!;
const latestMarker = `Latest shared memory ${stamp}`;
await rpc(page, "memory/update", { documentId: sharedDocument.id, content: latestMarker });
const downloadPromise = page.waitForEvent("download");
await spaceDocs.getByRole("button", { name: "Download as markdown" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("space-memory.md");
const exported = await readFile((await download.path())!, "utf8");
expect(exported).toContain(latestMarker);
expect(exported).not.toContain(marker);
expect(exported).not.toContain("# Chief");
await page.getByLabel("Close memory settings").click();
await expect(page.getByLabel("Close memory settings")).toHaveCount(0);
// The bot's Knowledge section lives under Advanced in its settings panel.
await page
.locator("main")
.getByRole("button", { name: /^Chief/ })
.click();
const settings = page.getByTestId("bot-settings");
await expect(settings.getByRole("button", { name: "Save", exact: true })).toBeVisible();
await settings.getByText("Advanced", { exact: true }).click();
const knowledge = settings.getByTestId("bot-knowledge");
await expect(knowledge).toBeVisible();
await expect(knowledge.getByRole("tablist", { name: "Knowledge" })).toBeVisible();
// Bot creation seeds MEMORY.md (`# Chief`); edit it and assert the revision bumps.
const botMemory = knowledge.getByTestId("bot-knowledge-memory");
const botMemoryRow = botMemory.getByRole("button", { name: /MEMORY\.md/ });
await expect(botMemoryRow).toBeVisible();
await botMemoryRow.click();
const botDocEditor = botMemory.locator("textarea");
await expect(botDocEditor).toHaveValue(/# Chief/);
const botMarker = `Bot memory e2e ${stamp}`;
await botDocEditor.fill(`# Chief\n\n${botMarker}\n`);
await botMemory.getByRole("button", { name: "Save", exact: true }).scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "80-knowledge-bot-memory");
await botMemory.getByRole("button", { name: "Save", exact: true }).click();
await expect(botMemory.getByText("rev 2")).toBeVisible();
expect(
await rpc<MemoryDocument[]>(page, "memory/list", {
botId: activeBotId(page),
scope: "bot",
}),
).toContainEqual(expect.objectContaining({ content: `# Chief\n\n${botMarker}\n`, revision: 2 }));
await botMemoryRow.click();
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
// Remembering appends a dated note and keeps the edit above.
await rpc(page, "threads/send", {
botId: activeBotId(page),
text: `remember that the tester ${stamp} prefers tea`,
});
await expect
.poll(
async () =>
(
await rpc<MemoryDocument[]>(page, "memory/list", {
botId: activeBotId(page),
scope: "bot",
})
).find((doc) => doc.path === "MEMORY.md")?.content,
{ timeout: 60_000 },
)
.toMatch(
new RegExp(
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
),
);
await expect
.poll(
async () =>
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
null,
{ timeout: 60_000 },
)
.toBeNull();
// Skills: create one through the editor, reopen it, edit, then delete it.
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
await knowledge.getByRole("button", { name: "New skill", exact: true }).click();
const editor = knowledge.locator("textarea");
await editor.fill(
[
"---",
"name: greet-politely",
"description: Say hello before anything else.",
"---",
"",
"Always open with a greeting.",
].join("\n"),
);
await captureScreenshot(page, testInfo, "81-knowledge-skill-editor");
await knowledge.getByRole("button", { name: "Save", exact: true }).click();
const skillRow = knowledge.getByRole("button", { name: /greet-politely/ });
await expect(skillRow).toBeVisible();
await expect(knowledge.getByText("Say hello before anything else.")).toBeVisible();
await captureScreenshot(page, testInfo, "82-knowledge-skill-listed");
const composer = page.getByRole("combobox", { name: /^Message/ });
await composer.fill("/");
await expect(
page.getByRole("button", { name: "Skill greet-politely", exact: true }),
).toBeVisible();
await composer.fill("");
// A provider-owned skill uses the same viewer without mutation controls.
await page.route(
"**/rpc/agentSkills/get",
async (route) => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({
response,
json: { ...body, json: { ...body.json, readOnly: true, source: "plugin" } },
});
},
{ times: 1 },
);
await skillRow.click();
await expect(editor).toHaveAttribute("readonly", "");
await expect(knowledge.getByRole("button", { name: "Save", exact: true })).toHaveCount(0);
await expect(knowledge.getByRole("button", { name: "Delete", exact: true })).toHaveCount(0);
await knowledge.getByRole("button", { name: "Close", exact: true }).click();
await skillRow.click();
await expect(editor).toHaveValue(/Always open with a greeting/);
await editor.fill(
[
"---",
"name: greet-politely",
"description: Say hello before anything else.",
"---",
"",
"Open with a warm greeting.",
].join("\n"),
);
let releaseSkillRefresh!: () => void;
const skillRefreshGate = new Promise<void>((resolve) => {
releaseSkillRefresh = resolve;
});
await page.route(
"**/rpc/agentSkills/list",
async (route) => {
await skillRefreshGate;
await route.continue();
},
{ times: 1 },
);
await knowledge.getByRole("button", { name: "Save", exact: true }).click();
try {
await expect(editor).toBeHidden();
await expect(skillRow).toBeDisabled();
await captureScreenshot(page, testInfo, "85-skill-refresh-pending");
} finally {
releaseSkillRefresh();
}
await skillRow.click();
await expect(editor).toHaveValue(/warm greeting/);
await knowledge.getByRole("button", { name: "Delete", exact: true }).click();
await knowledge.getByRole("button", { name: "Confirm delete", exact: true }).click();
await expect(skillRow).toBeHidden();
await composer.fill("/");
await expect(page.getByRole("button", { name: "Skill greet-politely", exact: true })).toHaveCount(
0,
);
expect(await rpc<Array<{ name: string }>>(page, "agentSkills/list", {})).not.toContainEqual(
expect.objectContaining({ name: "greet-politely" }),
);
});