mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-12 10:25:58 +00:00
Merge branch 'main' into codex/im-channel-connections
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
import {
|
||||
mockLangGraphAPI,
|
||||
@@ -19,6 +19,9 @@ const THREADS = [
|
||||
},
|
||||
];
|
||||
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
||||
const SVG_PROMPT_THREAD_ID = "00000000-0000-0000-0000-000000000777";
|
||||
const SVG_PROMPT_MARKER = "LEAK-STRICT-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||||
const OPTIMISTIC_PROMPT_MARKER = "LEAK-OPTIMISTIC-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||||
|
||||
test.describe("Thread history", () => {
|
||||
test("sidebar shows existing threads", async ({ page }) => {
|
||||
@@ -62,6 +65,109 @@ test.describe("Thread history", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("new chat does not show previous thread messages after client-side navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: SVG_PROMPT_THREAD_ID,
|
||||
title: "SVG artifact prompt",
|
||||
updated_at: "2025-06-03T12:00:00Z",
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-svg-prompt",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `请严格执行:\n1. 使用 write_file 创建 /mnt/user-data/outputs/shared.svg,内容包含 ${SVG_PROMPT_MARKER}\n2. 最终回复只输出 Markdown 图片。`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-svg-prompt",
|
||||
content: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${SVG_PROMPT_THREAD_ID}`);
|
||||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await page.getByRole("link", { name: /new chat/i }).click();
|
||||
await page.waitForURL("**/workspace/chats/new");
|
||||
|
||||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
|
||||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("new chat does not show previous optimistic user message after client-side navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID_2,
|
||||
title: "Destination conversation",
|
||||
updated_at: "2025-06-04T12:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const metadataOnlyStream = async (route: Route) => {
|
||||
const body = [
|
||||
{
|
||||
event: "metadata",
|
||||
data: {
|
||||
run_id: "00000000-0000-0000-0000-000000000778",
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
},
|
||||
},
|
||||
{ event: "end", data: {} },
|
||||
]
|
||||
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
||||
.join("");
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
await page.route("**/api/langgraph/runs/stream", metadataOnlyStream);
|
||||
await page.route(
|
||||
"**/api/langgraph/threads/*/runs/stream",
|
||||
metadataOnlyStream,
|
||||
);
|
||||
|
||||
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(
|
||||
`请严格执行:使用 write_file 创建 shared.svg,内容包含 ${OPTIMISTIC_PROMPT_MARKER}。`,
|
||||
);
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toBeVisible();
|
||||
|
||||
await page.getByText("Destination conversation").click();
|
||||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
|
||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||
|
||||
await page.getByRole("link", { name: /new chat/i }).click();
|
||||
await page.waitForURL("**/workspace/chats/new");
|
||||
|
||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("mock thread does not load real backend run history", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD,
|
||||
OFFLINE_BANNER_RETRY_INTERVAL_MS,
|
||||
classifyProbe,
|
||||
decideProbeAction,
|
||||
shouldShowOfflineBanner,
|
||||
} from "@/components/workspace/gateway-offline-banner-helpers";
|
||||
import type { User } from "@/core/auth/types";
|
||||
|
||||
const fakeUser: User = {
|
||||
id: "u1",
|
||||
email: "user@example.com",
|
||||
system_role: "user",
|
||||
needs_setup: false,
|
||||
};
|
||||
|
||||
function makeResponse(status: number, ok = status >= 200 && status < 300) {
|
||||
return { status, ok } as Response;
|
||||
}
|
||||
|
||||
describe("shouldShowOfflineBanner", () => {
|
||||
it("hides when the gateway is reachable", () => {
|
||||
expect(shouldShowOfflineBanner(null, false)).toBe(false);
|
||||
expect(shouldShowOfflineBanner(fakeUser, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows when the gateway is unavailable and the client has no user yet", () => {
|
||||
expect(shouldShowOfflineBanner(null, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides as soon as the client recovers an authenticated user", () => {
|
||||
expect(shouldShowOfflineBanner(fakeUser, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OFFLINE_BANNER_RETRY_INTERVAL_MS", () => {
|
||||
it("is a positive finite number", () => {
|
||||
expect(OFFLINE_BANNER_RETRY_INTERVAL_MS).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(OFFLINE_BANNER_RETRY_INTERVAL_MS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD", () => {
|
||||
it("is an integer greater than 1 so a single transient 401 cannot expire the session", () => {
|
||||
expect(Number.isInteger(OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD)).toBe(true);
|
||||
expect(OFFLINE_BANNER_AUTH_FAILURE_THRESHOLD).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyProbe", () => {
|
||||
it("returns transient when fetch errored", () => {
|
||||
expect(classifyProbe(null, true)).toEqual({ kind: "transient" });
|
||||
});
|
||||
|
||||
it("returns transient when response is null with no error flag", () => {
|
||||
expect(classifyProbe(null, false)).toEqual({ kind: "transient" });
|
||||
});
|
||||
|
||||
it("returns ok with parsed user for a 2xx response with body", () => {
|
||||
expect(classifyProbe(makeResponse(200), false, fakeUser)).toEqual({
|
||||
kind: "ok",
|
||||
user: fakeUser,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns transient for a 2xx response whose body failed to parse", () => {
|
||||
// Defensive: a 200 with malformed JSON / schema mismatch should not be
|
||||
// treated as 'ok' because the caller has no user to apply.
|
||||
expect(classifyProbe(makeResponse(200), false, null)).toEqual({
|
||||
kind: "transient",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unauthorized for a 401 response", () => {
|
||||
expect(classifyProbe(makeResponse(401), false)).toEqual({
|
||||
kind: "unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns transient for 5xx responses", () => {
|
||||
expect(classifyProbe(makeResponse(503), false)).toEqual({
|
||||
kind: "transient",
|
||||
});
|
||||
expect(classifyProbe(makeResponse(500), false)).toEqual({
|
||||
kind: "transient",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns transient for unexpected non-401 4xx responses", () => {
|
||||
expect(classifyProbe(makeResponse(429), false)).toEqual({
|
||||
kind: "transient",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideProbeAction", () => {
|
||||
it("returns apply-user with the body on a 2xx response", () => {
|
||||
expect(decideProbeAction(0, { kind: "ok", user: fakeUser })).toEqual({
|
||||
type: "apply-user",
|
||||
user: fakeUser,
|
||||
});
|
||||
// Even if we'd accumulated some 401s, a 200 wins immediately.
|
||||
expect(decideProbeAction(2, { kind: "ok", user: fakeUser })).toEqual({
|
||||
type: "apply-user",
|
||||
user: fakeUser,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a single 401 as transient noise and only bumps the counter", () => {
|
||||
expect(decideProbeAction(0, { kind: "unauthorized" })).toEqual({
|
||||
type: "noop",
|
||||
nextFailureCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats consecutive 401s below the threshold as still transient", () => {
|
||||
expect(decideProbeAction(1, { kind: "unauthorized" })).toEqual({
|
||||
type: "noop",
|
||||
nextFailureCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates to refreshUser as 'session-expired' once 401s reach the threshold", () => {
|
||||
expect(decideProbeAction(2, { kind: "unauthorized" })).toEqual({
|
||||
type: "delegate-refresh",
|
||||
reason: "session-expired",
|
||||
});
|
||||
});
|
||||
|
||||
it("honours a custom threshold (parameterised for safer tests)", () => {
|
||||
expect(decideProbeAction(0, { kind: "unauthorized" }, 2)).toEqual({
|
||||
type: "noop",
|
||||
nextFailureCount: 1,
|
||||
});
|
||||
expect(decideProbeAction(1, { kind: "unauthorized" }, 2)).toEqual({
|
||||
type: "delegate-refresh",
|
||||
reason: "session-expired",
|
||||
});
|
||||
});
|
||||
|
||||
it("decrements (not resets) the auth-failure streak on a transient outcome", () => {
|
||||
// Was 2 → 1, so a flapping gateway (401↔5xx) still converges on the
|
||||
// threshold instead of indefinitely masking session expiry.
|
||||
expect(decideProbeAction(2, { kind: "transient" })).toEqual({
|
||||
type: "noop",
|
||||
nextFailureCount: 1,
|
||||
});
|
||||
// Floored at 0; never goes negative.
|
||||
expect(decideProbeAction(0, { kind: "transient" })).toEqual({
|
||||
type: "noop",
|
||||
nextFailureCount: 0,
|
||||
});
|
||||
expect(decideProbeAction(1, { kind: "transient" })).toEqual({
|
||||
type: "noop",
|
||||
nextFailureCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("convergence: alternating 401/transient still triggers session-expired", () => {
|
||||
// Simulate the exact scenario from #3493 CR: flapping gateway alternates
|
||||
// 401 (session gone) and 503 (overloaded). With decrement-by-1, the
|
||||
// counter still nets +1 per 401/transient pair and reaches threshold.
|
||||
let count = 0;
|
||||
const seq: Array<"unauthorized" | "transient"> = [
|
||||
"unauthorized", // count -> 1
|
||||
"transient", // count -> 0
|
||||
"unauthorized", // count -> 1
|
||||
"unauthorized", // count -> 2
|
||||
"transient", // count -> 1
|
||||
"unauthorized", // count -> 2
|
||||
];
|
||||
for (const kind of seq) {
|
||||
const action = decideProbeAction(count, { kind });
|
||||
expect(action.type).toBe("noop");
|
||||
if (action.type === "noop") count = action.nextFailureCount;
|
||||
}
|
||||
// Next 401 should trip the wire (2 -> 3 == threshold).
|
||||
expect(decideProbeAction(count, { kind: "unauthorized" })).toEqual({
|
||||
type: "delegate-refresh",
|
||||
reason: "session-expired",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,3 +106,65 @@ describe("getServerSideUser", () => {
|
||||
expect(isAuthDisabledMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerSideUser — gateway_unavailable contract (issue #3493)", () => {
|
||||
let saved: EnvSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
saved = snapshotEnv();
|
||||
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
||||
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv(saved);
|
||||
vi.unstubAllGlobals();
|
||||
vi.doUnmock("next/headers");
|
||||
});
|
||||
|
||||
test("returns gateway_unavailable when /auth/me fetch rejects (e.g. AbortError)", async () => {
|
||||
vi.doMock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) =>
|
||||
name === "access_token" ? { value: "stub-token" } : undefined,
|
||||
})),
|
||||
}));
|
||||
const abortErr = new DOMException("Aborted", "AbortError");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(abortErr)),
|
||||
);
|
||||
|
||||
const { getServerSideUser } = await loadFreshServerAuth();
|
||||
|
||||
await expect(getServerSideUser()).resolves.toEqual({
|
||||
tag: "gateway_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns gateway_unavailable when /auth/me responds with a 5xx", async () => {
|
||||
vi.doMock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) =>
|
||||
name === "access_token" ? { value: "stub-token" } : undefined,
|
||||
})),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response("upstream error", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { getServerSideUser } = await loadFreshServerAuth();
|
||||
|
||||
await expect(getServerSideUser()).resolves.toEqual({
|
||||
tag: "gateway_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user