116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
|
|
|
|
/**
|
|
* Regression for https://github.com/bytedance/deer-flow/issues/2746.
|
|
*
|
|
* On a brand-new chat, the LangGraph SDK's useStream eagerly fetches
|
|
* `/threads/{id}/history` the moment it receives a thread id, and the
|
|
* frontend's own `useThreadRuns` fires `GET /threads/{id}/runs` for the same
|
|
* reason. Both endpoints assume the thread already exists on the backend;
|
|
* if the frontend forwards the (client-generated) thread id before
|
|
* `POST /runs/stream` has actually created the thread, both calls 404 in
|
|
* production. This test pins the request ordering so the regression cannot
|
|
* re-appear silently.
|
|
*/
|
|
test.describe("Chat: thread API request ordering on first send", () => {
|
|
test("does not call /history or GET /runs before /runs/stream is initiated", async ({
|
|
page,
|
|
}) => {
|
|
type EventLog = {
|
|
phase: "sent" | "done";
|
|
url: string;
|
|
method: string;
|
|
seq: number;
|
|
};
|
|
const events: EventLog[] = [];
|
|
// Monotonic sequence number — Date.now() is millisecond-resolution and
|
|
// would let two requests share a timestamp, which would defeat the
|
|
// strict-ordering assertions below.
|
|
let nextSeq = 0;
|
|
|
|
page.on("request", (req) => {
|
|
events.push({
|
|
phase: "sent",
|
|
url: req.url(),
|
|
method: req.method(),
|
|
seq: nextSeq++,
|
|
});
|
|
});
|
|
page.on("requestfinished", (req) => {
|
|
events.push({
|
|
phase: "done",
|
|
url: req.url(),
|
|
method: req.method(),
|
|
seq: nextSeq++,
|
|
});
|
|
});
|
|
|
|
mockLangGraphAPI(page);
|
|
|
|
// Slow down /runs/stream so any pre-create /history or /runs request
|
|
// would land well before the stream returns metadata, widening the
|
|
// race window the bug used to exploit.
|
|
await page.route(
|
|
"**/api/langgraph/threads/*/runs/stream",
|
|
async (route) => {
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
return handleRunStream(route);
|
|
},
|
|
);
|
|
await page.route("**/api/langgraph/runs/stream", async (route) => {
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
return handleRunStream(route);
|
|
});
|
|
|
|
await page.goto("/workspace/chats/new");
|
|
|
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
|
await textarea.fill("Hello");
|
|
await textarea.press("Enter");
|
|
|
|
// Wait for streaming response so all init requests have a chance to fire.
|
|
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
const isHistory = (url: string) =>
|
|
/\/api\/langgraph\/threads\/[^/]+\/history/.test(url);
|
|
const isRunsList = (url: string, method: string) =>
|
|
method === "GET" &&
|
|
/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/.test(url);
|
|
const isRunsStream = (url: string, method: string) =>
|
|
method === "POST" && /\/runs\/stream(\?|$)/.test(url);
|
|
|
|
const runsStreamSent = events.find(
|
|
(e) => e.phase === "sent" && isRunsStream(e.url, e.method),
|
|
);
|
|
expect(
|
|
runsStreamSent,
|
|
"Expected POST /runs/stream to be issued during send",
|
|
).toBeDefined();
|
|
|
|
const earlyHistory = events.filter(
|
|
(e) =>
|
|
e.phase === "sent" && isHistory(e.url) && e.seq < runsStreamSent!.seq,
|
|
);
|
|
const earlyRunsList = events.filter(
|
|
(e) =>
|
|
e.phase === "sent" &&
|
|
isRunsList(e.url, e.method) &&
|
|
e.seq < runsStreamSent!.seq,
|
|
);
|
|
|
|
expect(
|
|
earlyHistory.map((e) => e.url),
|
|
"GET /history must not be issued before POST /runs/stream — see issue #2746",
|
|
).toEqual([]);
|
|
expect(
|
|
earlyRunsList.map((e) => e.url),
|
|
"GET /runs must not be issued before POST /runs/stream — see issue #2746",
|
|
).toEqual([]);
|
|
});
|
|
});
|