Merge remote-tracking branch 'origin/main' into codex/im-channel-connections

# Conflicts:
#	backend/app/channels/discord.py
#	backend/app/channels/manager.py
#	backend/app/channels/slack.py
#	backend/app/channels/telegram.py
This commit is contained in:
taohe
2026-06-10 21:13:02 +08:00
85 changed files with 5575 additions and 253 deletions
@@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
import { AUTH_DISABLED_USER } from "../../src/core/auth/auth-disabled-user";
const APP = "http://localhost:3000";
test.describe("auth-disabled contract (real backend)", () => {
test("gateway /auth/me returns the frontend synthetic user without a cookie", async ({
context,
}) => {
const resp = await context.request.get(`${APP}/api/v1/auth/me`);
expect(resp.status(), await resp.text()).toBe(200);
await expect(resp.json()).resolves.toEqual(AUTH_DISABLED_USER);
});
});
@@ -101,10 +101,11 @@ test.describe("real backend render (replay, no API key)", () => {
EXPECTED_SUGGESTION,
"fixture should contain a suggestions turn (re-record; the record spec waits for /suggestions)",
).not.toBe("");
await expect(page.getByText(EXPECTED_TITLE)).toBeVisible({
const chat = page.locator("#chat");
await expect(chat.getByText(EXPECTED_TITLE)).toBeVisible({
timeout: 60_000,
});
await expect(page.getByText(EXPECTED_SUGGESTION)).toBeVisible({
await expect(chat.getByText(EXPECTED_SUGGESTION)).toBeVisible({
timeout: 30_000,
});
+200
View File
@@ -12,6 +12,7 @@ test.describe("Chat workspace", () => {
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: /load more/i })).toBeHidden();
});
test("can type a message in the input box", async ({ page }) => {
@@ -24,6 +25,61 @@ test.describe("Chat workspace", () => {
await expect(textarea).toHaveValue("Hello, DeerFlow!");
});
test("suggests matching skills after a leading slash", async ({ page }) => {
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("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await expect(
page.getByRole("option", { name: /disabled-skill/i }),
).toBeHidden();
await textarea.press("Enter");
await expect(textarea).toHaveValue("/data-analysis ");
});
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
page,
}) => {
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("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Shift+Enter");
await expect(textarea).toHaveValue("/dat\n");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("does not suggest skills for slash text away from the prompt start", async ({
page,
}) => {
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("please /dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("sending a message triggers API call and shows response", async ({
page,
}) => {
@@ -49,6 +105,150 @@ test.describe("Chat workspace", () => {
});
});
test("slash skill command is submitted as normal chat text", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze uploads/foo.csv";
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
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(slashCommand);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("slash skill command with attachment preserves command text and file metadata", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze report.docx";
let uploadCalled = false;
let submittedText: string | undefined;
let submittedFiles:
| Array<{ filename?: string; path?: string; status?: string }>
| undefined;
await page.route("**/api/threads/*/uploads", async (route) => {
uploadCalled = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: {
messages?: Array<{
content?: unknown;
additional_kwargs?: {
files?: Array<{
filename?: string;
path?: string;
status?: string;
}>;
};
}>;
};
};
const message = body.input?.messages?.at(-1);
const content = message?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
submittedFiles = message?.additional_kwargs?.files;
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 page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await textarea.fill(slashCommand);
await textarea.press("Enter");
await expect.poll(() => uploadCalled, { timeout: 10_000 }).toBeTruthy();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect
.poll(() => submittedFiles, { timeout: 10_000 })
.toEqual([
{
filename: "report.docx",
size: 12,
path: "/mnt/user-data/uploads/report.docx",
status: "uploaded",
},
]);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("keeps attachments visible while upload submit is pending", async ({
page,
}) => {
+79
View File
@@ -18,6 +18,7 @@ const THREADS = [
updated_at: "2025-06-02T12:00:00Z",
},
];
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
test.describe("Thread history", () => {
test("sidebar shows existing threads", async ({ page }) => {
@@ -61,6 +62,84 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 });
});
test("mock thread does not load real backend run history", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: DEMO_THREAD_ID,
title: "Forecasting 2026 Trends and Opportunities",
updated_at: "2025-06-01T12:00:00Z",
messages: [
{
type: "human",
id: `run-human-${DEMO_THREAD_ID}`,
content: [
{
type: "text",
text: "This run-message endpoint should not be called.",
},
],
},
],
},
],
});
const backendRunHistoryUrls: string[] = [];
await page.route(
/\/api\/langgraph\/threads\/[^/]+\/runs(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route
.request()
.url()
.includes(`/api/langgraph/threads/${DEMO_THREAD_ID}/runs`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real runs",
}),
});
}
return route.fallback();
},
);
await page.route(
/\/api\/threads\/[^/]+\/runs\/[^/]+\/messages(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route.request().url().includes(`/api/threads/${DEMO_THREAD_ID}/runs/`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real run messages",
}),
});
}
return route.fallback();
},
);
await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`);
await expect(
page.getByText("What might be the trends and opportunities in 2026?"),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("I've created a modern, minimalist website"),
).toBeVisible();
expect(backendRunHistoryUrls).toEqual([]);
});
test("chats list page shows all threads", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS });
+43
View File
@@ -35,11 +35,41 @@ export type MockAgent = {
system_prompt?: string;
};
export type MockSkill = {
name: string;
description: string;
category?: string;
license?: string | null;
enabled?: boolean;
};
export type MockAPIOptions = {
threads?: MockThread[];
agents?: MockAgent[];
skills?: MockSkill[];
};
const DEFAULT_SKILLS: MockSkill[] = [
{
name: "data-analysis",
description: "Analyze structured data and produce charts.",
category: "public",
enabled: true,
},
{
name: "frontend-design",
description: "Create polished frontend interfaces.",
category: "public",
enabled: true,
},
{
name: "disabled-skill",
description: "Hidden from slash autocomplete.",
category: "public",
enabled: false,
},
];
// ---------------------------------------------------------------------------
// mockLangGraphAPI
// ---------------------------------------------------------------------------
@@ -52,6 +82,7 @@ export type MockAPIOptions = {
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
const threads = options?.threads ?? [];
const agents = options?.agents ?? [];
const skills = options?.skills ?? DEFAULT_SKILLS;
// Thread search — sidebar thread list & chats list page
void page.route("**/api/langgraph/threads/search", (route) => {
@@ -259,6 +290,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
// Skills list — settings page and slash autocomplete
void page.route("**/api/skills", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ skills }),
});
}
return route.fallback();
});
// Follow-up suggestions — input box auto-suggest after AI response
void page.route("**/api/threads/*/suggestions", (route) => {
if (route.request().method() === "POST") {
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
vi.mock("next/headers", () => ({
@@ -10,6 +11,8 @@ vi.mock("next/headers", () => ({
const ENV_KEYS = [
"DEER_FLOW_AUTH_DISABLED",
"DEER_FLOW_ENV",
"ENVIRONMENT",
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
] as const;
@@ -51,6 +54,8 @@ describe("getServerSideUser", () => {
beforeEach(() => {
saved = snapshotEnv();
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
setEnv("DEER_FLOW_ENV", undefined);
setEnv("ENVIRONMENT", undefined);
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
});
@@ -74,4 +79,30 @@ describe("getServerSideUser", () => {
});
expect(fetchSpy).not.toHaveBeenCalled();
});
test("bypasses gateway auth in auth-disabled mode", async () => {
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
const fetchSpy = vi.fn(() => {
throw new Error("fetch should not be called in auth-disabled mode");
});
vi.stubGlobal("fetch", fetchSpy);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "authenticated",
user: AUTH_DISABLED_USER,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
test("does not enable auth-disabled mode in explicit production environments", async () => {
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
setEnv("DEER_FLOW_ENV", "production");
const { isAuthDisabledMode } =
await import("@/core/auth/auth-disabled-user");
expect(isAuthDisabledMode()).toBe(false);
});
});
+617 -1
View File
@@ -1,11 +1,18 @@
import { afterEach, expect, test, vi } from "vitest";
import { writeTextToClipboard } from "@/core/clipboard";
import {
installClipboardFallback,
writeTextToClipboard,
} from "@/core/clipboard";
const originalNavigator = globalThis.navigator;
const hadOriginalNavigator = "navigator" in globalThis;
const originalDocument = globalThis.document;
const hadOriginalDocument = "document" in globalThis;
const originalClipboardItemDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"ClipboardItem",
);
afterEach(() => {
vi.restoreAllMocks();
@@ -26,6 +33,16 @@ afterEach(() => {
value: originalDocument,
});
}
if (!originalClipboardItemDescriptor) {
Reflect.deleteProperty(globalThis, "ClipboardItem");
} else {
Object.defineProperty(
globalThis,
"ClipboardItem",
originalClipboardItemDescriptor,
);
}
});
test("writes text with the Clipboard API when available", async () => {
@@ -90,6 +107,95 @@ test("falls back to execCommand when Clipboard API is unavailable", async () =>
expect(textarea.remove).toHaveBeenCalled();
});
test("falls back to parent removal when textarea.remove is unavailable", async () => {
const parentNode = {
removeChild: vi.fn(),
};
const textarea = {
parentNode,
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
expect(parentNode.removeChild).toHaveBeenCalledWith(textarea);
});
test("does not fail cleanup when textarea removal APIs are unavailable", async () => {
const textarea = {
parentNode: {},
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn().mockReturnValue(true),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
});
test("cleans up the textarea when selecting text fails", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(() => {
throw new Error("selection failed");
}),
setAttribute: vi.fn(),
style: {},
value: "",
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn(),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when execCommand fallback fails", async () => {
const textarea = {
remove: vi.fn(),
@@ -118,6 +224,24 @@ test("returns false when execCommand fallback fails", async () => {
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when execCommand fallback cannot create an element", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
execCommand: vi.fn(),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("returns false when navigator is unavailable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
@@ -144,3 +268,495 @@ test("returns false when Clipboard API rejects", async () => {
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("installs a writeText fallback when Clipboard API is unavailable", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const appendChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild,
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
installClipboardFallback();
await expect(globalThis.navigator.clipboard.writeText("hello")).resolves.toBe(
undefined,
);
expect(textarea.value).toBe("hello");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(textarea.select).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(textarea.remove).toHaveBeenCalled();
});
test("installed writeText fallback rejects instead of throwing synchronously", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
const result = globalThis.navigator.clipboard.writeText("hello");
expect(result).toBeInstanceOf(Promise);
await expect(result).rejects.toThrow("Clipboard DOM fallback not available");
});
test("installed writeText fallback converts thrown DOM failures to rejections", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn(() => {
throw new Error("dom unavailable");
}),
execCommand: vi.fn(),
},
});
installClipboardFallback();
const result = globalThis.navigator.clipboard.writeText("hello");
expect(result).toBeInstanceOf(Promise);
await expect(result).rejects.toThrow("dom unavailable");
});
test("installed writeText fallback distinguishes copy command failure", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue({
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
}),
execCommand: vi.fn().mockReturnValue(false),
},
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.writeText("hello"),
).rejects.toThrow("Clipboard copy command failed");
});
test("installs a write fallback for ClipboardItem text/plain payloads", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
const item = new globalThis.ClipboardItem({
"text/html": new Blob(["<table></table>"], { type: "text/html" }),
"text/plain": "| A |\n| B |",
});
await expect(globalThis.navigator.clipboard.write([item])).resolves.toBe(
undefined,
);
expect(textarea.value).toBe("| A |\n| B |");
expect(execCommand).toHaveBeenCalledWith("copy");
});
test("installed write fallback rejects when ClipboardItem lacks text/plain", async () => {
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue({
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
}),
execCommand,
},
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
const item = new globalThis.ClipboardItem({
"text/html": new Blob(["<table></table>"], { type: "text/html" }),
});
await expect(globalThis.navigator.clipboard.write([item])).rejects.toThrow(
"Clipboard item is missing text/plain data",
);
expect(execCommand).not.toHaveBeenCalled();
});
test("installed write fallback rejects when getType cannot provide text/plain", async () => {
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue({
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
}),
execCommand,
},
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
getType: vi.fn().mockRejectedValue(new Error("missing")),
types: ["text/plain"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("missing");
expect(execCommand).not.toHaveBeenCalled();
});
test("installed write fallback rejects before getType when item types exclude text/plain", async () => {
const getType = vi.fn().mockResolvedValue(new Blob(["ignored"]));
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
getType,
types: ["text/html"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("Clipboard item is missing text/plain data");
expect(getType).not.toHaveBeenCalled();
});
test("installed write fallback rejects when getType is missing", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
types: ["text/plain"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("Clipboard item cannot read text/plain data");
});
test("installed write fallback rejects when getType returns a non-Blob", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
getType: vi.fn().mockResolvedValue("plain text"),
types: ["text/plain"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("Clipboard item text/plain data is not a Blob");
});
test("installed write fallback preserves existing clipboard prototype methods", async () => {
const readText = vi.fn().mockResolvedValue("existing");
const clipboard = Object.create({
readText,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard,
},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
expect(globalThis.navigator.clipboard).toBe(clipboard);
await expect(globalThis.navigator.clipboard.readText()).resolves.toBe(
"existing",
);
expect(readText).toHaveBeenCalled();
await expect(
globalThis.navigator.clipboard.writeText("hello"),
).rejects.toThrow("Clipboard DOM fallback not available");
});
test("installClipboardFallback does not replace existing clipboard methods when only ClipboardItem is missing", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(undefined);
const clipboard = {
write,
writeText,
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard,
},
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
expect(globalThis.navigator.clipboard).toBe(clipboard);
expect(Reflect.get(globalThis.navigator.clipboard, "write")).toBe(write);
expect(Reflect.get(globalThis.navigator.clipboard, "writeText")).toBe(
writeText,
);
expect(typeof globalThis.ClipboardItem).toBe("function");
});
test("installClipboardFallback is idempotent for the same navigator", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
const clipboard = globalThis.navigator.clipboard;
const ClipboardItemFallback = globalThis.ClipboardItem;
installClipboardFallback();
expect(globalThis.navigator.clipboard).toBe(clipboard);
expect(globalThis.ClipboardItem).toBe(ClipboardItemFallback);
});
test("installClipboardFallback can recover when the same navigator loses fallback globals", async () => {
const navigator = {};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
Reflect.deleteProperty(globalThis, "ClipboardItem");
Reflect.deleteProperty(navigator, "clipboard");
installClipboardFallback();
expect(typeof globalThis.navigator.clipboard.writeText).toBe("function");
expect(typeof globalThis.ClipboardItem).toBe("function");
});
test("installClipboardFallback defines writable fallback methods", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
expect(
Object.getOwnPropertyDescriptor(globalThis.navigator.clipboard, "write")
?.writable,
).toBe(true);
expect(
Object.getOwnPropertyDescriptor(globalThis.navigator.clipboard, "writeText")
?.writable,
).toBe(true);
});
test("installClipboardFallback skips missing clipboard on non-extensible navigator while installing ClipboardItem", async () => {
const navigator = {};
Object.preventExtensions(navigator);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
expect("clipboard" in globalThis.navigator).toBe(false);
expect(typeof globalThis.ClipboardItem).toBe("function");
});
test("installClipboardFallback handles non-object navigator.clipboard values", async () => {
const navigator = {};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: "locked",
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
expect(typeof globalThis.navigator.clipboard.writeText).toBe("function");
await expect(
globalThis.navigator.clipboard.writeText("hello"),
).rejects.toThrow("Clipboard DOM fallback not available");
});
test("installClipboardFallback does not throw when ClipboardItem cannot be defined", async () => {
const originalDefineProperty = Object.defineProperty;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
vi.spyOn(Object, "defineProperty").mockImplementation(
(target, property, descriptor) => {
if (target === globalThis && property === "ClipboardItem") {
throw new Error("locked global");
}
return originalDefineProperty(target, property, descriptor);
},
);
expect(() => installClipboardFallback()).not.toThrow();
expect(typeof globalThis.navigator.clipboard.writeText).toBe("function");
expect("ClipboardItem" in globalThis).toBe(false);
});
test("installs ClipboardItem fallback when the global property exists but is unusable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
write: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(undefined),
},
},
});
Object.defineProperty(globalThis, "ClipboardItem", {
configurable: true,
value: undefined,
});
installClipboardFallback();
expect(typeof globalThis.ClipboardItem).toBe("function");
});
@@ -11,6 +11,7 @@ import {
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
stripUploadedFilesTag,
} from "@/core/messages/utils";
function aiMessage(content: string): Message {
@@ -173,6 +174,38 @@ describe("inline <think> tag splitting", () => {
});
});
describe("human message internal context stripping", () => {
test("strips slash skill activation context from display content", () => {
const content =
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task";
expect(stripUploadedFilesTag(content)).toBe("real user task");
});
test("hides leaked slash skill activation messages with no user text", () => {
const messages = [
{
id: "slash-activation",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>",
},
{
id: "ai-1",
type: "ai",
content: "Public answer",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["assistant"]);
expect(
groups.flatMap((group) => group.messages).map((message) => message.id),
).toEqual(["ai-1"]);
});
});
test("hides internal todo reminder messages from message groups", () => {
const messages = [
{
@@ -260,6 +260,22 @@ describe("formatThreadAsJSON", () => {
expect(raw).toContain("real user text");
});
it("strips <slash_skill_activation> as defence in depth", () => {
// Slash activation normally rides in a hidden HumanMessage. If a replay
// or state merge loses the flag, export must still not leak full SKILL.md
// content into a user-visible transcript.
const leaky = human("real user task", {
id: "leak-slash-skill",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md\nUse internal source.</skill_content>\n</slash_skill_activation>\nreal user task",
} as unknown as Partial<Message>);
const raw = formatThreadAsJSON(makeThread(), [leaky]);
expect(raw).not.toContain("<slash_skill_activation>");
expect(raw).not.toContain("Secret SKILL.md");
expect(raw).not.toContain("internal source");
expect(raw).toContain("real user task");
});
it("sanitises tool message content when includeToolMessages is true", () => {
const message = {
id: "t-leak",