mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 11:52:41 +00:00
* fix(frontend): preserve live subtask steps during history backfill * test(deermem): keep updater eviction fixtures fresh --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import { createServer, type ServerResponse } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
import { expect, test, type Route } from "@playwright/test";
|
|
|
|
import {
|
|
mockLangGraphAPI,
|
|
MOCK_RUN_ID,
|
|
MOCK_THREAD_ID,
|
|
} from "./utils/mock-api";
|
|
|
|
const TASK_ID = "call-backfill-subtask";
|
|
const TASK_PROMPT = "Research the recovered subtask timeline";
|
|
const LIVE_SUFFIX = "Complete live reasoning survives the history response.";
|
|
const LIVE_TEXT = `${"Live reasoning. ".repeat(600)}${LIVE_SUFFIX}`;
|
|
const EARLIER_TEXT = "Earlier reasoning recovered from persisted history.";
|
|
const messages = [
|
|
{ type: "human", id: "human-backfill", content: "Continue the research" },
|
|
{
|
|
type: "ai",
|
|
id: "ai-backfill",
|
|
run_id: MOCK_RUN_ID,
|
|
content: "",
|
|
tool_calls: [
|
|
{
|
|
id: TASK_ID,
|
|
name: "task",
|
|
args: { subagent_type: "general-purpose", prompt: TASK_PROMPT },
|
|
type: "tool_call",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
async function startSubtaskStream() {
|
|
let response: ServerResponse | undefined;
|
|
const send = (event: string, data: unknown) => {
|
|
if (!response) throw new Error("The observer stream has not joined");
|
|
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
};
|
|
const server = createServer((_request, stream) => {
|
|
response = stream;
|
|
stream.writeHead(200, {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cache-Control": "no-cache",
|
|
"Content-Type": "text/event-stream",
|
|
});
|
|
send("metadata", { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID });
|
|
send("values", { messages });
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
return {
|
|
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/stream`,
|
|
joined: () => response !== undefined,
|
|
send,
|
|
async close() {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
test("late subtask history preserves the full live step at the same index", async ({
|
|
page,
|
|
}) => {
|
|
const stream = await startSubtaskStream();
|
|
let historyRequest: Route | undefined;
|
|
const run = {
|
|
run_id: MOCK_RUN_ID,
|
|
thread_id: MOCK_THREAD_ID,
|
|
assistant_id: "lead_agent",
|
|
status: "running",
|
|
metadata: {},
|
|
kwargs: {},
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
mockLangGraphAPI(page, {
|
|
threads: [
|
|
{ thread_id: MOCK_THREAD_ID, title: "Subtask backfill", messages },
|
|
],
|
|
});
|
|
await page.route(
|
|
new RegExp(`/api/langgraph/threads/${MOCK_THREAD_ID}/runs(?:\\?|$)`),
|
|
(route) => route.fulfill({ json: [run] }),
|
|
);
|
|
await page.route(
|
|
new RegExp(
|
|
`/api/langgraph/threads/${MOCK_THREAD_ID}/runs/${MOCK_RUN_ID}(?:\\?|$)`,
|
|
),
|
|
(route) => route.fulfill({ json: run }),
|
|
);
|
|
await page.route(
|
|
`**/api/langgraph/threads/${MOCK_THREAD_ID}/runs/${MOCK_RUN_ID}/stream*`,
|
|
(route) => route.continue({ url: stream.url }),
|
|
);
|
|
await page.route(
|
|
`**/api/threads/${MOCK_THREAD_ID}/runs/${MOCK_RUN_ID}/events*`,
|
|
(route) => {
|
|
historyRequest = route;
|
|
},
|
|
);
|
|
|
|
try {
|
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
|
await expect.poll(stream.joined).toBe(true);
|
|
await page.getByRole("button", { name: new RegExp(TASK_PROMPT) }).click();
|
|
await expect.poll(() => historyRequest !== undefined).toBe(true);
|
|
const query = new URL(historyRequest!.request().url()).searchParams;
|
|
expect(query.get("task_id")).toBe(TASK_ID);
|
|
expect(query.get("event_types")).toBe("subagent.step");
|
|
|
|
stream.send("custom", {
|
|
type: "task_running",
|
|
task_id: TASK_ID,
|
|
message_index: 2,
|
|
message: { type: "ai", id: "subtask-live", content: LIVE_TEXT },
|
|
});
|
|
// The card clamps long reasoning visually. Check its rendered text so
|
|
// truncation by the history payload cannot hide behind the layout clamp.
|
|
const fullStep = page.getByText(LIVE_TEXT, { exact: true });
|
|
await expect(fullStep).toHaveCount(1);
|
|
|
|
await historyRequest!.fulfill({
|
|
json: [
|
|
{ seq: 1, text: EARLIER_TEXT, truncated: false },
|
|
{ seq: 2, text: LIVE_TEXT.slice(0, 8192), truncated: true },
|
|
].map(({ seq, text, truncated }) => ({
|
|
seq,
|
|
event_type: "subagent.step",
|
|
content: {
|
|
task_id: TASK_ID,
|
|
message_index: seq,
|
|
kind: "ai",
|
|
text,
|
|
truncated,
|
|
},
|
|
})),
|
|
});
|
|
historyRequest = undefined;
|
|
// The newly recovered earlier step proves the response has been applied.
|
|
await expect(page.getByText(EARLIER_TEXT, { exact: true })).toHaveCount(1);
|
|
await expect(fullStep).toHaveCount(1);
|
|
await expect(
|
|
page.getByText(LIVE_TEXT.slice(0, 8192), { exact: true }),
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await historyRequest?.abort();
|
|
await stream.close();
|
|
}
|
|
});
|