mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 01:45:58 +00:00
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:
@@ -9,6 +9,8 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
".next",
|
||||
"playwright-report",
|
||||
"test-results",
|
||||
"src/components/ui/**",
|
||||
"src/components/ai-elements/**",
|
||||
"*.js",
|
||||
|
||||
@@ -7,8 +7,9 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* so the mock-based suite is untouched.
|
||||
*
|
||||
* Two webServers are started: the replay gateway (:8011) and the frontend
|
||||
* (:3000, pointed at the gateway). Auth uses a throwaway test account the spec
|
||||
* registers at runtime — no secrets.
|
||||
* (:3000, pointed at the gateway). Auth-disabled mode is enabled on both
|
||||
* servers so the no-cookie e2e contract is covered; specs that need session
|
||||
* cookies still register a throwaway test account at runtime.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e-real-backend",
|
||||
@@ -38,7 +39,10 @@ export default defineConfig({
|
||||
// Mount the test-only run/message seeder used by multi-run-order.spec.ts
|
||||
// (#3352). The endpoint exists only on this replay gateway, never in the
|
||||
// production app.
|
||||
env: { DEERFLOW_ENABLE_TEST_SEED: "1" },
|
||||
env: {
|
||||
DEERFLOW_ENABLE_TEST_SEED: "1",
|
||||
DEER_FLOW_AUTH_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "pnpm build && pnpm start",
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { ClipboardSafeStreamdown } from "./streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
@@ -302,11 +303,13 @@ export const MessageBranchPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
export type MessageResponseProps = ComponentProps<
|
||||
typeof ClipboardSafeStreamdown
|
||||
>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
|
||||
@@ -881,6 +881,7 @@ export type PromptInputTextareaProps = ComponentProps<
|
||||
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
className,
|
||||
placeholder = "What would you like to know?",
|
||||
...props
|
||||
@@ -891,6 +892,10 @@ export const PromptInputTextarea = ({
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
if (isIMEComposing(e, isComposing)) {
|
||||
return;
|
||||
|
||||
@@ -10,9 +10,9 @@ import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { ClipboardSafeStreamdown } from "./streamdown";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
@@ -178,7 +178,9 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
<ClipboardSafeStreamdown {...reasoningPlugins}>
|
||||
{children}
|
||||
</ClipboardSafeStreamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { installClipboardFallback } from "@/core/clipboard";
|
||||
|
||||
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
// Only patch browser globals in client context; skip during SSR
|
||||
if (typeof document !== "undefined") {
|
||||
installClipboardFallback();
|
||||
}
|
||||
|
||||
export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) {
|
||||
return <Streamdown {...props} />;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
Artifact,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
ArtifactHeader,
|
||||
ArtifactTitle,
|
||||
} from "@/components/ai-elements/artifact";
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
import { Select, SelectItem } from "@/components/ui/select";
|
||||
import {
|
||||
SelectContent,
|
||||
@@ -400,13 +400,13 @@ export function ArtifactFilePreview({
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className="size-full px-4">
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
</ClipboardSafeStreamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
@@ -59,6 +60,8 @@ import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -86,6 +89,48 @@ import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const MAX_SKILL_SUGGESTIONS = 6;
|
||||
|
||||
function getLeadingSlashSkillQuery(value: string): string | null {
|
||||
if (!value.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = value.slice(1);
|
||||
if (query.includes("/") || /\s/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function getMatchingSkillSuggestions(skills: Skill[], query: string): Skill[] {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return skills
|
||||
.map((skill, index) => ({
|
||||
skill,
|
||||
index,
|
||||
name: skill.name.toLowerCase(),
|
||||
}))
|
||||
.filter(({ skill, name }) => {
|
||||
if (!skill.enabled) {
|
||||
return false;
|
||||
}
|
||||
return !normalizedQuery || name.includes(normalizedQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = a.name.startsWith(normalizedQuery);
|
||||
const bStartsWith = b.name.startsWith(normalizedQuery);
|
||||
if (aStartsWith !== bStartsWith) {
|
||||
return aStartsWith ? -1 : 1;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.slice(0, MAX_SKILL_SUGGESTIONS)
|
||||
.map(({ skill }) => skill);
|
||||
}
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
@@ -153,11 +198,17 @@ export function InputBox({
|
||||
const { models } = useModels();
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const { skills } = useSkills();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
const [followupsLoading, setFollowupsLoading] = useState(false);
|
||||
const [textareaFocused, setTextareaFocused] = useState(false);
|
||||
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
||||
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
||||
useState<string | null>(null);
|
||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||
const wasStreamingRef = useRef(false);
|
||||
const messagesRef = useRef(thread.messages);
|
||||
@@ -347,9 +398,98 @@ export function InputBox({
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const slashSkillQuery = useMemo(
|
||||
() => getLeadingSlashSkillQuery(textInput.value ?? ""),
|
||||
[textInput.value],
|
||||
);
|
||||
const skillSuggestions = useMemo(
|
||||
() =>
|
||||
slashSkillQuery === null
|
||||
? []
|
||||
: getMatchingSkillSuggestions(skills, slashSkillQuery),
|
||||
[skills, slashSkillQuery],
|
||||
);
|
||||
const showSkillSuggestions =
|
||||
!disabled &&
|
||||
textareaFocused &&
|
||||
slashSkillQuery !== null &&
|
||||
skillSuggestions.length > 0 &&
|
||||
dismissedSkillSuggestionValue !== textInput.value;
|
||||
|
||||
useEffect(() => {
|
||||
setSkillSuggestionIndex(0);
|
||||
}, [slashSkillQuery, skillSuggestions.length]);
|
||||
|
||||
const applySkillSuggestion = useCallback(
|
||||
(skill: Skill) => {
|
||||
const nextValue = `/${skill.name} `;
|
||||
textInput.setInput(nextValue);
|
||||
setDismissedSkillSuggestionValue(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextValue.length, nextValue.length);
|
||||
});
|
||||
},
|
||||
[textInput],
|
||||
);
|
||||
|
||||
const handleSkillSuggestionKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!showSkillSuggestions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setSkillSuggestionIndex(
|
||||
(index) => (index + 1) % skillSuggestions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setSkillSuggestionIndex(
|
||||
(index) =>
|
||||
(index - 1 + skillSuggestions.length) % skillSuggestions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const selectedSkill = skillSuggestions[skillSuggestionIndex];
|
||||
if (selectedSkill) {
|
||||
applySkillSuggestion(selectedSkill);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setDismissedSkillSuggestionValue(textInput.value);
|
||||
}
|
||||
},
|
||||
[
|
||||
applySkillSuggestion,
|
||||
showSkillSuggestions,
|
||||
skillSuggestionIndex,
|
||||
skillSuggestions,
|
||||
textInput.value,
|
||||
],
|
||||
);
|
||||
|
||||
const showFollowups =
|
||||
!disabled &&
|
||||
!isWelcomeMode &&
|
||||
!showSkillSuggestions &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
@@ -478,6 +618,48 @@ export function InputBox({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showSkillSuggestions && (
|
||||
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
|
||||
<div
|
||||
aria-label="Skill suggestions"
|
||||
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
|
||||
role="listbox"
|
||||
>
|
||||
{skillSuggestions.map((skill, index) => {
|
||||
const selected = index === skillSuggestionIndex;
|
||||
return (
|
||||
<button
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
|
||||
)}
|
||||
key={skill.name}
|
||||
onClick={() => applySkillSuggestion(skill)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setSkillSuggestionIndex(index)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
/{skill.name}
|
||||
</span>
|
||||
{skill.description && (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{skill.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
|
||||
@@ -506,6 +688,10 @@ export function InputBox({
|
||||
placeholder={t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={initialValue}
|
||||
onBlur={() => setTextareaFocused(false)}
|
||||
onFocus={() => setTextareaFocused(true)}
|
||||
onKeyDown={handleSkillSuggestionKeyDown}
|
||||
ref={textareaRef}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="flex">
|
||||
@@ -860,11 +1046,13 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInput>
|
||||
|
||||
{isWelcomeMode && searchParams.get("mode") !== "skill" && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<SuggestionList />
|
||||
</div>
|
||||
)}
|
||||
{isWelcomeMode &&
|
||||
searchParams.get("mode") !== "skill" &&
|
||||
!showSkillSuggestions && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<SuggestionList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShineBorder } from "@/components/ui/shine-border";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -126,12 +126,12 @@ export function SubtaskCard({
|
||||
{task.prompt && (
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
{...streamdownPluginsWithWordAnimation}
|
||||
components={{ a: CitationLink }}
|
||||
>
|
||||
{task.prompt}
|
||||
</Streamdown>
|
||||
</ClipboardSafeStreamdown>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Streamdown } from "streamdown";
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
|
||||
import { aboutMarkdown } from "./about-content";
|
||||
|
||||
export function AboutSettingsPage() {
|
||||
return <Streamdown>{aboutMarkdown}</Streamdown>;
|
||||
return <ClipboardSafeStreamdown>{aboutMarkdown}</ClipboardSafeStreamdown>;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useDeferredValue, useId, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -639,12 +639,12 @@ export function MemorySettingsPage() {
|
||||
<div className="text-muted-foreground mb-4 text-sm">
|
||||
{summaryReadOnly}
|
||||
</div>
|
||||
<Streamdown
|
||||
<ClipboardSafeStreamdown
|
||||
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
{...streamdownPlugins}
|
||||
>
|
||||
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
||||
</Streamdown>
|
||||
</ClipboardSafeStreamdown>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { User } from "./types";
|
||||
|
||||
export const AUTH_DISABLED_USER: User = {
|
||||
id: "e2e-user",
|
||||
email: "e2e@test.local",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
};
|
||||
|
||||
const PRODUCTION_ENV_VALUES = new Set(["prod", "production"]);
|
||||
|
||||
function isExplicitProductionEnvironment() {
|
||||
return ["DEER_FLOW_ENV", "ENVIRONMENT"].some((name) =>
|
||||
PRODUCTION_ENV_VALUES.has((process.env[name] ?? "").trim().toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAuthDisabledMode() {
|
||||
return (
|
||||
process.env.DEER_FLOW_AUTH_DISABLED === "1" &&
|
||||
!isExplicitProductionEnvironment()
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
|
||||
|
||||
import { isStaticWebsiteOnly } from "../static-mode";
|
||||
|
||||
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
|
||||
import { getGatewayConfig } from "./gateway-config";
|
||||
import { STATIC_WEBSITE_USER } from "./static-user";
|
||||
import { type AuthResult, userSchema } from "./types";
|
||||
@@ -20,15 +21,10 @@ export async function getServerSideUser(): Promise<AuthResult> {
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.DEER_FLOW_AUTH_DISABLED === "1") {
|
||||
if (isAuthDisabledMode()) {
|
||||
return {
|
||||
tag: "authenticated",
|
||||
user: {
|
||||
id: "e2e-user",
|
||||
email: "e2e@test.local",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
},
|
||||
user: AUTH_DISABLED_USER,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+246
-19
@@ -1,3 +1,47 @@
|
||||
type ClipboardItemLike = {
|
||||
types?: readonly string[];
|
||||
getType?: (type: string) => Promise<Blob>;
|
||||
items?: Record<string, Blob | string>;
|
||||
};
|
||||
|
||||
function copyTextWithExecCommand(text: string): boolean {
|
||||
const document = globalThis.document;
|
||||
if (
|
||||
typeof document?.createElement !== "function" ||
|
||||
typeof document.body?.appendChild !== "function" ||
|
||||
typeof document.execCommand !== "function"
|
||||
) {
|
||||
throw new Error("Clipboard DOM fallback not available");
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
|
||||
let copied = false;
|
||||
let appended = false;
|
||||
try {
|
||||
document.body.appendChild(textarea);
|
||||
appended = true;
|
||||
textarea.select();
|
||||
copied = document.execCommand("copy");
|
||||
} finally {
|
||||
if (appended) {
|
||||
const parentNode = textarea.parentNode;
|
||||
if (typeof textarea.remove === "function") {
|
||||
textarea.remove();
|
||||
} else if (typeof parentNode?.removeChild === "function") {
|
||||
parentNode.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
const clipboard = globalThis.navigator?.clipboard;
|
||||
@@ -6,26 +50,209 @@ export async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
const document = globalThis.document;
|
||||
if (!document?.body?.appendChild || !document.execCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
return copyTextWithExecCommand(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackWriteText(text: string): Promise<void> {
|
||||
try {
|
||||
if (!copyTextWithExecCommand(text)) {
|
||||
return Promise.reject(new Error("Clipboard copy command failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function hasUsableClipboardItem(): boolean {
|
||||
return typeof globalThis.ClipboardItem === "function";
|
||||
}
|
||||
|
||||
async function readPlainTextFromClipboardItem(
|
||||
item: ClipboardItemLike,
|
||||
): Promise<string> {
|
||||
const plainText = item.items?.["text/plain"];
|
||||
if (typeof plainText === "string") {
|
||||
return plainText;
|
||||
}
|
||||
if (plainText instanceof Blob) {
|
||||
return await plainText.text();
|
||||
}
|
||||
|
||||
if (item.types && !item.types.includes("text/plain")) {
|
||||
throw new Error("Clipboard item is missing text/plain data");
|
||||
}
|
||||
|
||||
if (typeof item.getType !== "function") {
|
||||
throw new Error("Clipboard item cannot read text/plain data");
|
||||
}
|
||||
|
||||
const blob = await item.getType("text/plain");
|
||||
if (blob instanceof Blob) {
|
||||
return await blob.text();
|
||||
}
|
||||
|
||||
throw new Error("Clipboard item text/plain data is not a Blob");
|
||||
}
|
||||
|
||||
function canDefineNavigatorClipboard(
|
||||
navigator: Navigator,
|
||||
descriptor: PropertyDescriptor | undefined,
|
||||
): boolean {
|
||||
if (descriptor) {
|
||||
return descriptor.configurable === true;
|
||||
}
|
||||
return Object.isExtensible(navigator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs browser clipboard fallbacks for Streamdown copy controls by patching
|
||||
* missing navigator.clipboard methods and ClipboardItem when the host permits it.
|
||||
*/
|
||||
export function installClipboardFallback(): void {
|
||||
const navigator = globalThis.navigator;
|
||||
if (!navigator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawClipboard = navigator.clipboard;
|
||||
const clipboard =
|
||||
typeof rawClipboard === "object" && rawClipboard !== null
|
||||
? (rawClipboard as Partial<Clipboard>)
|
||||
: undefined;
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"clipboard",
|
||||
);
|
||||
const hasWriteText = typeof clipboard?.writeText === "function";
|
||||
const hasWrite = typeof clipboard?.write === "function";
|
||||
const hasClipboardItem = hasUsableClipboardItem();
|
||||
|
||||
if (hasWriteText && hasWrite && hasClipboardItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const writeText = hasWriteText
|
||||
? clipboard.writeText!.bind(clipboard)
|
||||
: fallbackWriteText;
|
||||
const write = hasWrite
|
||||
? clipboard.write!.bind(clipboard)
|
||||
: (items: ClipboardItemLike[]) => {
|
||||
const firstItem = items[0];
|
||||
if (!firstItem) {
|
||||
return Promise.reject(new Error("Clipboard item not available"));
|
||||
}
|
||||
|
||||
return readPlainTextFromClipboardItem(firstItem).then(writeText);
|
||||
};
|
||||
|
||||
const fallbackClipboard = clipboard ?? {};
|
||||
|
||||
try {
|
||||
const missingMethods: PropertyDescriptorMap = {};
|
||||
if (!hasWrite) {
|
||||
missingMethods.write = {
|
||||
configurable: true,
|
||||
value: write,
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
if (!hasWriteText) {
|
||||
missingMethods.writeText = {
|
||||
configurable: true,
|
||||
value: writeText,
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
|
||||
Object.defineProperties(fallbackClipboard, missingMethods);
|
||||
|
||||
if (
|
||||
!clipboard &&
|
||||
canDefineNavigatorClipboard(navigator, clipboardDescriptor)
|
||||
) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: fallbackClipboard,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!canDefineNavigatorClipboard(navigator, clipboardDescriptor)) {
|
||||
// The ClipboardItem fallback below is independent from navigator.clipboard.
|
||||
if (hasClipboardItem) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const replacement = Object.create(clipboard ?? null);
|
||||
for (const methodName of ["read", "readText"] as const) {
|
||||
const method = clipboard?.[methodName];
|
||||
if (typeof method === "function") {
|
||||
Object.defineProperty(replacement, methodName, {
|
||||
configurable: true,
|
||||
value: method.bind(clipboard),
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperties(replacement, {
|
||||
write: {
|
||||
configurable: true,
|
||||
value: write,
|
||||
writable: true,
|
||||
},
|
||||
writeText: {
|
||||
configurable: true,
|
||||
value: writeText,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: replacement,
|
||||
});
|
||||
} catch {
|
||||
// The ClipboardItem fallback below is independent from navigator.clipboard.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasClipboardItem) {
|
||||
class ClipboardItemFallback {
|
||||
items: Record<string, Blob | string>;
|
||||
types: string[];
|
||||
|
||||
constructor(items: Record<string, Blob | string>) {
|
||||
this.items = items;
|
||||
this.types = Object.keys(items);
|
||||
}
|
||||
|
||||
getType(type: string): Promise<Blob> {
|
||||
const value = this.items[type];
|
||||
if (value instanceof Blob) {
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return Promise.resolve(new Blob([value], { type }));
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(`Clipboard item is missing ${type} data`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, "ClipboardItem", {
|
||||
configurable: true,
|
||||
value: ClipboardItemFallback,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,10 +469,14 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
}
|
||||
|
||||
export function isHiddenFromUIMessage(message: Message) {
|
||||
const content = extractTextFromMessage(message);
|
||||
return (
|
||||
message.additional_kwargs?.hide_from_ui === true ||
|
||||
(typeof message.name === "string" &&
|
||||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name))
|
||||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
|
||||
(message.type === "human" &&
|
||||
content.includes("<slash_skill_activation>") &&
|
||||
stripUploadedFilesTag(content).length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,12 +492,13 @@ export interface FileInMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip <uploaded_files> tag from message content.
|
||||
* Returns the content with the tag removed.
|
||||
* Strip backend-injected human context tags from message content.
|
||||
* Kept under its historical name because callers use it for uploaded-file
|
||||
* display cleanup.
|
||||
*/
|
||||
export function stripUploadedFilesTag(content: string): string {
|
||||
return content
|
||||
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
|
||||
.replace(/<(uploaded_files|slash_skill_activation)>[\s\S]*?<\/\1>/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -504,6 +509,7 @@ export function stripUploadedFilesTag(content: string): string {
|
||||
* These markers are *not* user copy — they come from:
|
||||
*
|
||||
* - ``UploadsMiddleware`` → ``<uploaded_files>``
|
||||
* - ``SkillActivationMiddleware`` → ``<slash_skill_activation>``
|
||||
* - ``DynamicContextMiddleware`` → ``<system-reminder>`` (carrying
|
||||
* ``<memory>`` / ``<current_date>`` inside)
|
||||
* - ``TodoListMiddleware`` / ``LoopDetectionMiddleware`` style reminders
|
||||
@@ -517,6 +523,7 @@ export function stripUploadedFilesTag(content: string): string {
|
||||
*/
|
||||
export const INTERNAL_MARKER_TAGS = [
|
||||
"uploaded_files",
|
||||
"slash_skill_activation",
|
||||
"system-reminder",
|
||||
"memory",
|
||||
"current_date",
|
||||
|
||||
@@ -364,7 +364,7 @@ export function useThreadStream({
|
||||
loadMore: loadMoreHistory,
|
||||
loading: isHistoryLoading,
|
||||
appendMessages,
|
||||
} = useThreadHistory(onStreamThreadId ?? "");
|
||||
} = useThreadHistory(onStreamThreadId ?? "", { enabled: !isMock });
|
||||
|
||||
// Keep listeners ref updated with latest callbacks
|
||||
useEffect(() => {
|
||||
@@ -854,8 +854,15 @@ export function useThreadStream({
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function useThreadHistory(threadId: string) {
|
||||
const runs = useThreadRuns(threadId);
|
||||
type ThreadHistoryOptions = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function useThreadHistory(
|
||||
threadId: string,
|
||||
{ enabled = true }: ThreadHistoryOptions = {},
|
||||
) {
|
||||
const runs = useThreadRuns(threadId, { enabled });
|
||||
const threadIdRef = useRef(threadId);
|
||||
const runsRef = useRef(runs.data ?? []);
|
||||
const indexRef = useRef(-1);
|
||||
@@ -864,10 +871,15 @@ export function useThreadHistory(threadId: string) {
|
||||
const loadingRunIdRef = useRef<string | null>(null);
|
||||
const loadedRunIdsRef = useRef<Set<string>>(new Set());
|
||||
const runBeforeSeqRef = useRef<Map<string, number>>(new Map());
|
||||
const loadGenerationRef = useRef(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const loadGeneration = loadGenerationRef.current;
|
||||
if (loadingRef.current) {
|
||||
const pendingRunIndex = findLatestUnloadedRunIndex(
|
||||
runsRef.current,
|
||||
@@ -921,12 +933,15 @@ export function useThreadHistory(threadId: string) {
|
||||
}).then((res) => {
|
||||
return res.json();
|
||||
});
|
||||
if (
|
||||
loadGenerationRef.current !== loadGeneration ||
|
||||
threadIdRef.current !== requestThreadId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const _messages = result.data
|
||||
.filter((m) => !m.metadata.caller?.startsWith("middleware:"))
|
||||
.map((m) => m.content);
|
||||
if (threadIdRef.current !== requestThreadId) {
|
||||
return;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
dedupeMessagesByIdentity([..._messages, ...prev]),
|
||||
);
|
||||
@@ -961,16 +976,19 @@ export function useThreadHistory(threadId: string) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
loadingRunIdRef.current = null;
|
||||
setLoading(false);
|
||||
if (loadGenerationRef.current === loadGeneration) {
|
||||
loadingRef.current = false;
|
||||
loadingRunIdRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
useEffect(() => {
|
||||
const threadChanged = threadIdRef.current !== threadId;
|
||||
threadIdRef.current = threadId;
|
||||
|
||||
if (threadChanged) {
|
||||
if (!enabled || threadChanged) {
|
||||
loadGenerationRef.current += 1;
|
||||
runsRef.current = [];
|
||||
indexRef.current = -1;
|
||||
pendingLoadRef.current = false;
|
||||
@@ -982,6 +1000,10 @@ export function useThreadHistory(threadId: string) {
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (runs.data && runs.data.length > 0) {
|
||||
runsRef.current = runs.data ?? [];
|
||||
indexRef.current = findLatestUnloadedRunIndex(
|
||||
@@ -992,14 +1014,15 @@ export function useThreadHistory(threadId: string) {
|
||||
loadMessages().catch(() => {
|
||||
toast.error("Failed to load thread history.");
|
||||
});
|
||||
}, [threadId, runs.data, loadMessages]);
|
||||
}, [enabled, threadId, runs.data, loadMessages]);
|
||||
|
||||
const appendMessages = useCallback((_messages: Message[]) => {
|
||||
setMessages((prev) => {
|
||||
return dedupeMessagesByIdentity([...prev, ..._messages]);
|
||||
});
|
||||
}, []);
|
||||
const hasMore = indexRef.current >= 0 || !runs.data;
|
||||
const hasMore =
|
||||
enabled && Boolean(threadId) && (indexRef.current >= 0 || !runs.data);
|
||||
return {
|
||||
runs: runs.data,
|
||||
messages,
|
||||
@@ -1077,7 +1100,10 @@ export function useThreads(
|
||||
});
|
||||
}
|
||||
|
||||
export function useThreadRuns(threadId?: string) {
|
||||
export function useThreadRuns(
|
||||
threadId?: string,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
) {
|
||||
const apiClient = getAPIClient();
|
||||
return useQuery<Run[]>({
|
||||
queryKey: ["thread", threadId],
|
||||
@@ -1088,6 +1114,7 @@ export function useThreadRuns(threadId?: string) {
|
||||
const response = await apiClient.runs.list(threadId);
|
||||
return response;
|
||||
},
|
||||
enabled: enabled && Boolean(threadId),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user