mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 05:56:18 +00:00
* feat(memory): add cognitiveStyle for collaboration preferences Extend cross-session memory with user.cognitiveStyle and cognitive facts, backward-compatible normalization, prompt injection as Thinking Style, and settings UI. Add design notes and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(memory): normalize legacy memory imports on frontend Add normalizeMemoryPayload aligned with backend normalize_memory_data, use it for Settings import and API reads, and add unit/regression tests. Document schema-evolution steps and sync FACT_EXTRACTION_PROMPT cognitive category. * fix(memory): harden legacy memory normalization * fix(memory): keep import envelope validation strict * fix(memory): keep unknown fields through frontend import normalization normalizeMemoryPayload rebuilt the memory document from a section whitelist, so every key it did not name was dropped. Because readMemoryResponse routes all memory API responses through it, the loss showed up on read, export, and import alike: revision vanished, and any future backend field would silently break lossless export/import. The Gateway models are extra="allow" and deermem normalizes additively, which left the frontend as the only lossy hop in the chain. Spread before overriding at each nesting level so normalization only validates and defaults contract-owned fields, leaving the envelope check as strict as before. Covered by a unit test on the strict import path, two backend tests for extension preservation and caller immutability, and an E2E round-trip that asserts on the captured import request body. * docs(memory): state the normalization invariant that actually holds The docstring cited `display` as a backend-driven field alongside `revision`, but #4546 was closed unmerged and no such field exists in the contract. It also promised lossless export/import, which the Gateway's `extra="ignore"` models preclude for any undeclared field. Replace both with the invariant the code is really defending: the frontend must not be narrower than the Gateway contract, using the declared top-level `revision` as the concrete example. * fix(memory): preserve legacy migration and reject lossy imports * fix(memory): align legacy fact normalization defaults * test(memory): use authenticated app for import regression --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
import { mockLangGraphAPI } from "./utils/mock-api";
|
|
|
|
const EMPTY_MEMORY = {
|
|
version: "1.0",
|
|
lastUpdated: "",
|
|
user: {
|
|
workContext: { summary: "", updatedAt: "" },
|
|
personalContext: { summary: "", updatedAt: "" },
|
|
topOfMind: { summary: "", updatedAt: "" },
|
|
cognitiveStyle: { summary: "", updatedAt: "" },
|
|
},
|
|
history: {
|
|
recentMonths: { summary: "", updatedAt: "" },
|
|
earlierContext: { summary: "", updatedAt: "" },
|
|
longTermBackground: { summary: "", updatedAt: "" },
|
|
},
|
|
facts: [],
|
|
};
|
|
|
|
const LEGACY_MEMORY_WITHOUT_COGNITIVE_STYLE = {
|
|
version: "1.0",
|
|
lastUpdated: "2026-01-01T00:00:00Z",
|
|
user: {
|
|
workContext: { summary: "Works on DeerFlow", updatedAt: "" },
|
|
personalContext: { summary: "", updatedAt: "" },
|
|
topOfMind: { summary: "Memory import compatibility", updatedAt: "" },
|
|
},
|
|
history: {
|
|
recentMonths: { summary: "", updatedAt: "" },
|
|
earlierContext: { summary: "", updatedAt: "" },
|
|
longTermBackground: { summary: "", updatedAt: "" },
|
|
},
|
|
facts: [
|
|
{
|
|
content: "User prefers conclusions first.",
|
|
category: "cognitive",
|
|
},
|
|
],
|
|
};
|
|
|
|
async function openMemorySettings(page: Page) {
|
|
mockLangGraphAPI(page);
|
|
await page.route(/\/api\/memory$/, async (route) => {
|
|
if (route.request().method() === "GET") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(EMPTY_MEMORY),
|
|
});
|
|
return;
|
|
}
|
|
await route.fallback();
|
|
});
|
|
|
|
await page.goto("/workspace/chats/new");
|
|
const sidebar = page.locator("[data-sidebar='sidebar']");
|
|
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
|
|
await page.getByRole("menuitem", { name: "Settings" }).click();
|
|
|
|
const settingsDialog = page.getByRole("dialog", { name: "Settings" });
|
|
await expect(settingsDialog).toBeVisible();
|
|
await settingsDialog.getByRole("button", { name: "Memory" }).click();
|
|
await expect(
|
|
settingsDialog.getByRole("button", { name: "Import memory" }),
|
|
).toBeVisible();
|
|
return settingsDialog;
|
|
}
|
|
|
|
async function selectMemoryFile(
|
|
settingsDialog: ReturnType<Page["getByRole"]>,
|
|
fileName: string,
|
|
payload: unknown,
|
|
) {
|
|
await settingsDialog.locator('input[type="file"]').setInputFiles({
|
|
name: fileName,
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(JSON.stringify(payload)),
|
|
});
|
|
}
|
|
|
|
const invalidImports = [
|
|
{
|
|
name: "facts-only JSON",
|
|
fileName: "facts-only.json",
|
|
payload: { facts: [] },
|
|
},
|
|
{
|
|
name: "missing metadata",
|
|
fileName: "missing-metadata.json",
|
|
payload: { user: {}, history: {}, facts: [] },
|
|
},
|
|
{
|
|
name: "non-object user/history",
|
|
fileName: "invalid-sections.json",
|
|
payload: {
|
|
version: "1.0",
|
|
lastUpdated: "2026-07-17T00:00:00Z",
|
|
user: "not-an-object",
|
|
history: 123,
|
|
facts: [],
|
|
},
|
|
},
|
|
];
|
|
|
|
test.describe("Memory settings import validation", () => {
|
|
for (const invalidImport of invalidImports) {
|
|
test(`does not enable confirmation for ${invalidImport.name}`, async ({
|
|
page,
|
|
}) => {
|
|
const settingsDialog = await openMemorySettings(page);
|
|
|
|
await selectMemoryFile(
|
|
settingsDialog,
|
|
invalidImport.fileName,
|
|
invalidImport.payload,
|
|
);
|
|
|
|
await expect(
|
|
page.getByText(
|
|
"Failed to read the selected memory file. Please choose a valid JSON export.",
|
|
),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("dialog", { name: "Import memory?" }),
|
|
).toHaveCount(0);
|
|
});
|
|
}
|
|
|
|
test("keeps confirmation available for a legacy export missing cognitiveStyle", async ({
|
|
page,
|
|
}) => {
|
|
const settingsDialog = await openMemorySettings(page);
|
|
|
|
await selectMemoryFile(
|
|
settingsDialog,
|
|
"legacy-without-cognitive-style.json",
|
|
LEGACY_MEMORY_WITHOUT_COGNITIVE_STYLE,
|
|
);
|
|
|
|
const confirmDialog = page.getByRole("dialog", { name: "Import memory?" });
|
|
await expect(confirmDialog).toBeVisible();
|
|
await expect(confirmDialog).toContainText(
|
|
"legacy-without-cognitive-style.json",
|
|
);
|
|
await expect(
|
|
confirmDialog.getByRole("button", { name: "Import" }),
|
|
).toBeEnabled();
|
|
});
|
|
|
|
test("round-trips unknown fields through the import request", async ({
|
|
page,
|
|
}) => {
|
|
const memoryWithExtensions = {
|
|
...LEGACY_MEMORY_WITHOUT_COGNITIVE_STYLE,
|
|
revision: 4,
|
|
display: { title: "Memory export" },
|
|
data: { future: true },
|
|
};
|
|
const importedPayloads: unknown[] = [];
|
|
await page.route(/\/api\/memory\/import$/, async (route) => {
|
|
if (route.request().method() === "POST") {
|
|
importedPayloads.push(route.request().postDataJSON());
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(memoryWithExtensions),
|
|
});
|
|
return;
|
|
}
|
|
await route.fallback();
|
|
});
|
|
|
|
const settingsDialog = await openMemorySettings(page);
|
|
await selectMemoryFile(
|
|
settingsDialog,
|
|
"with-extensions.json",
|
|
memoryWithExtensions,
|
|
);
|
|
|
|
const confirmDialog = page.getByRole("dialog", { name: "Import memory?" });
|
|
await expect(confirmDialog).toBeVisible();
|
|
await confirmDialog.getByRole("button", { name: "Import" }).click();
|
|
|
|
await expect.poll(() => importedPayloads.length).toBe(1);
|
|
expect(importedPayloads[0]).toMatchObject({
|
|
revision: 4,
|
|
display: { title: "Memory export" },
|
|
data: { future: true },
|
|
user: { cognitiveStyle: { summary: "", updatedAt: "" } },
|
|
});
|
|
});
|
|
});
|