diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index ed777bb24..6346891c3 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -25,7 +25,11 @@ import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings, useThreadSettings } from "@/core/settings"; -import { useThreadStream, useThreadTokenUsage } from "@/core/threads/hooks"; +import { + useThreadMetadata, + useThreadStream, + useThreadTokenUsage, +} from "@/core/threads/hooks"; import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; @@ -54,6 +58,10 @@ export default function AgentChatPage() { isNewThread || isMock ? undefined : threadId, { enabled: tokenUsageEnabled && !isMock }, ); + const threadMetadata = useThreadMetadata(threadId, { + enabled: !isNewThread && !isMock, + isMock, + }); const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data); const { showNotification } = useNotification(); @@ -106,6 +114,34 @@ export default function AgentChatPage() { }, }); + const hasThreadMessages = thread.messages.length > 0; + + useEffect(() => { + if ( + !isNewThread && + !isMock && + threadMetadata.data === null && + !threadMetadata.isLoading && + !threadMetadata.isFetching && + !isHistoryLoading && + !hasMoreHistory && + !hasThreadMessages + ) { + router.replace(`/workspace/agents/${agent_name}/chats/new`); + } + }, [ + agent_name, + hasMoreHistory, + hasThreadMessages, + isHistoryLoading, + isMock, + isNewThread, + router, + threadMetadata.data, + threadMetadata.isFetching, + threadMetadata.isLoading, + ]); + const handleSubmit = useCallback( (message: PromptInputMessage) => { const sendPromise = sendMessage(threadId, message, { agent_name }); diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index f20c1c228..0c8ed87ef 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; @@ -24,7 +25,11 @@ import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings, useThreadSettings } from "@/core/settings"; -import { useThreadStream, useThreadTokenUsage } from "@/core/threads/hooks"; +import { + useThreadMetadata, + useThreadStream, + useThreadTokenUsage, +} from "@/core/threads/hooks"; import { threadTokenUsageToTokenUsage } from "@/core/threads/token-usage"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; @@ -32,6 +37,7 @@ import { cn } from "@/lib/utils"; export default function ChatPage() { const { t } = useI18n(); + const router = useRouter(); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); // `isNewThread` tracks whether the backend has the thread yet — gates the @@ -47,6 +53,10 @@ export default function ChatPage() { isNewThread || isMock ? undefined : threadId, { enabled: tokenUsageEnabled && !isMock }, ); + const threadMetadata = useThreadMetadata(threadId, { + enabled: !isNewThread && !isMock, + isMock, + }); const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data); const mountedRef = useRef(false); useSpecificChatMode(); @@ -108,6 +118,33 @@ export default function ChatPage() { }, }); + const hasThreadMessages = thread.messages.length > 0; + + useEffect(() => { + if ( + !isNewThread && + !isMock && + threadMetadata.data === null && + !threadMetadata.isLoading && + !threadMetadata.isFetching && + !isHistoryLoading && + !hasMoreHistory && + !hasThreadMessages + ) { + router.replace("/workspace/chats/new"); + } + }, [ + hasMoreHistory, + hasThreadMessages, + isHistoryLoading, + isMock, + isNewThread, + router, + threadMetadata.data, + threadMetadata.isFetching, + threadMetadata.isLoading, + ]); + const handleSubmit = useCallback( (message: PromptInputMessage) => { const sendPromise = sendMessage(threadId, message); diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index 06773d0e1..edda9d93b 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -5,6 +5,25 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { uuid } from "@/core/utils/uuid"; +export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset"; + +type ThreadChatResetDetail = { + deletedThreadId: string; + nextPath: string; + force?: boolean; +}; + +export function resetThreadChatAfterDelete(detail: ThreadChatResetDetail) { + if (typeof window === "undefined") { + return; + } + window.dispatchEvent( + new CustomEvent(THREAD_CHAT_RESET_EVENT, { + detail, + }), + ); +} + export function useThreadChat() { const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const pathname = usePathname(); @@ -30,6 +49,13 @@ export function useThreadChat() { () => threadIdFromPath === "new", ); + const resetToNewThread = useCallback(() => { + const nextThreadId = uuid(); + newThreadIdRef.current = nextThreadId; + setIsNewThreadState(true); + setThreadIdState(nextThreadId); + }, []); + useEffect(() => { if (isNewPath) { const nextThreadId = newThreadIdRef.current ?? uuid(); @@ -51,6 +77,35 @@ export function useThreadChat() { setThreadIdState(threadIdFromPath); }, [isNewPath, threadIdFromPath]); + useEffect(() => { + const handleReset = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.nextPath) { + return; + } + + const currentPathname = window.location.pathname; + const isDeletingCurrentThread = + detail.force === true || + detail.deletedThreadId === threadId || + detail.deletedThreadId === threadIdFromPath || + currentPathname.endsWith(`/${detail.deletedThreadId}`); + + if (!isDeletingCurrentThread) { + return; + } + + // URL replacement is owned by the caller's Next router action; this hook + // only resets local chat state so the router state and browser URL stay + // in sync. + resetToNewThread(); + }; + + window.addEventListener(THREAD_CHAT_RESET_EVENT, handleReset); + return () => + window.removeEventListener(THREAD_CHAT_RESET_EVENT, handleReset); + }, [resetToNewThread, threadId, threadIdFromPath]); + const setThreadId = useCallback((nextThreadId: string) => { newThreadIdRef.current = null; setThreadIdState(nextThreadId); diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 5b14eea10..cc8ad0a13 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -42,6 +42,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat"; import { getAPIClient } from "@/core/api"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; @@ -112,24 +113,41 @@ export function RecentChatList() { const [renameValue, setRenameValue] = useState(""); const handleDelete = useCallback( - (threadId: string) => { - deleteThread({ threadId }); - if (threadId === threadIdFromPath) { - const threadIndex = threads.findIndex((t) => t.thread_id === threadId); - let nextThreadPath = pathOfThread("new", { - agent_name: agentNameFromPath, - }); - if (threadIndex > -1) { - if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); - } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); - } - } - void router.push(nextThreadPath); - } + (thread: AgentThread) => { + const currentPathname = + typeof window === "undefined" ? pathname : window.location.pathname; + const threadPath = pathOfThread(thread); + const nextThreadPath = pathOfThread("new", { + agent_name: agentNameFromPath, + }); + const isNewThreadPath = currentPathname === nextThreadPath; + const isCurrentThread = + thread.thread_id === threadIdFromPath || + threadPath === currentPathname || + (isNewThreadPath && threads[0]?.thread_id === thread.thread_id); + + deleteThread({ + threadId: thread.thread_id, + onRemoteDeleted: isCurrentThread + ? () => { + resetThreadChatAfterDelete({ + deletedThreadId: thread.thread_id, + nextPath: nextThreadPath, + force: true, + }); + void router.replace(nextThreadPath); + } + : undefined, + }); }, - [agentNameFromPath, deleteThread, router, threadIdFromPath, threads], + [ + agentNameFromPath, + deleteThread, + pathname, + router, + threadIdFromPath, + threads, + ], ); const handleRenameClick = useCallback( @@ -302,7 +320,7 @@ export function RecentChatList() { handleDelete(thread.thread_id)} + onSelect={() => handleDelete(thread)} > {t.common.delete} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 77032caef..9becead7a 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -399,6 +399,34 @@ function getStreamErrorMessage(error: unknown): string { return "Request failed."; } +function getHttpStatus(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + + const status = Reflect.get(error, "status"); + if (typeof status === "number") { + return status; + } + + const response = Reflect.get(error, "response"); + if (typeof response === "object" && response !== null) { + const responseStatus = Reflect.get(response, "status"); + if (typeof responseStatus === "number") { + return responseStatus; + } + } + + return undefined; +} + +function isThreadMissingError(error: unknown): boolean { + const status = getHttpStatus(error); + // Treat 403 like 404 here to avoid disclosing whether an inaccessible thread + // exists; callers redirect stale/inaccessible URLs back to a blank chat. + return status === 403 || status === 404; +} + export function useThreadStream({ threadId, displayThreadId, @@ -1193,12 +1221,22 @@ export function useThreadHistory( return dedupeMessagesByIdentity([...prev, ..._messages]); }); }, []); + const hasThreadId = Boolean(threadId); + const hasUnloadedRuns = Boolean( + runs.data?.some((run) => !loadedRunIdsRef.current.has(run.run_id)), + ); + const isRunsLoading = + enabled && + hasThreadId && + (runs.isLoading || (runs.isFetching && !runs.data)); + const isRunsUnresolved = + enabled && hasThreadId && !runs.data && !runs.isError; const hasMore = - enabled && Boolean(threadId) && (indexRef.current >= 0 || !runs.data); + enabled && hasThreadId && (indexRef.current >= 0 || hasUnloadedRuns); return { runs: runs.data, messages, - loading, + loading: loading || isRunsLoading || isRunsUnresolved, appendMessages, hasMore, loadMore: loadMessages, @@ -1313,6 +1351,36 @@ export function useThreadRuns( }); } +export function useThreadMetadata( + threadId?: string | null, + { + enabled = true, + isMock = false, + }: { enabled?: boolean; isMock?: boolean } = {}, +) { + const apiClient = getAPIClient(isMock); + return useQuery({ + queryKey: ["thread", "metadata", threadId, isMock], + queryFn: async () => { + if (!threadId) { + return null; + } + try { + const response = await apiClient.threads.get(threadId); + return response as AgentThread; + } catch (error) { + if (isThreadMissingError(error)) { + return null; + } + throw error; + } + }, + enabled: enabled && Boolean(threadId), + retry: false, + refetchOnWindowFocus: false, + }); +} + export function useThreadTokenUsage( threadId?: string | null, { enabled = true }: { enabled?: boolean } = {}, @@ -1347,8 +1415,15 @@ export function useDeleteThread() { const queryClient = useQueryClient(); const apiClient = getAPIClient(); return useMutation({ - mutationFn: async ({ threadId }: { threadId: string }) => { + mutationFn: async ({ + threadId, + onRemoteDeleted, + }: { + threadId: string; + onRemoteDeleted?: () => void; + }) => { await apiClient.threads.delete(threadId); + onRemoteDeleted?.(); const response = await fetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 25b91fb7a..95b6e94c7 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -65,6 +65,36 @@ test.describe("Thread history", () => { ).toBeVisible({ timeout: 15_000 }); }); + test("deleting an inactive chat keeps the current chat open", async ({ + page, + }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect( + page.getByText("Response in thread First conversation"), + ).toBeVisible({ timeout: 15_000 }); + + const sidebar = page.locator("[data-sidebar='sidebar']"); + const inactiveThreadItem = sidebar + .locator("[data-sidebar='menu-item']") + .filter({ + has: page.getByRole("button", { name: /more/i }), + hasText: "Second conversation", + }) + .first(); + await expect(inactiveThreadItem).toBeVisible(); + await inactiveThreadItem.hover(); + await inactiveThreadItem.getByRole("button", { name: /more/i }).click(); + await page.getByRole("menuitem", { name: /delete/i }).click(); + + await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID)); + await expect( + page.getByText("Response in thread First conversation"), + ).toBeVisible(); + await expect(sidebar.getByText("Second conversation")).toHaveCount(0); + }); + test("new chat does not show previous thread messages after client-side navigation", async ({ page, }) => { @@ -100,7 +130,9 @@ test.describe("Thread history", () => { timeout: 15_000, }); - await page.getByRole("link", { name: /new chat/i }).click(); + await page + .locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']") + .click(); await page.waitForURL("**/workspace/chats/new"); await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden(); @@ -161,13 +193,64 @@ test.describe("Thread history", () => { await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`); await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0); - await page.getByRole("link", { name: /new chat/i }).click(); + await page + .locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']") + .click(); await page.waitForURL("**/workspace/chats/new"); await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0); await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible(); }); + test("deleting the active newly created chat returns to the new chat screen", async ({ + page, + }) => { + mockLangGraphAPI(page); + await page.route(/\/api\/threads\/[^/]+$/, (route) => { + if (route.request().method() === "DELETE") { + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ detail: "Local cleanup failed" }), + }); + } + return route.fallback(); + }); + + 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("What should disappear after deletion?"); + await textarea.press("Enter"); + + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 15_000, + }); + + const sidebar = page.locator("[data-sidebar='sidebar']"); + const recentThreadItem = sidebar + .locator("[data-sidebar='menu-item']") + .filter({ + has: page.getByRole("button", { name: /more/i }), + hasText: "New Chat", + }) + .first(); + await expect(recentThreadItem).toBeVisible(); + await recentThreadItem.hover(); + await recentThreadItem.getByRole("button", { name: /more/i }).click(); + await page.getByRole("menuitem", { name: /delete/i }).click(); + + await expect(page).toHaveURL(/\/workspace\/chats\/new$/); + await expect(page.getByText("Previous question")).toHaveCount(0); + await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0); + await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible(); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await page.waitForURL("**/workspace/chats/new"); + await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0); + await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible(); + }); + test("mock thread does not load real backend run history", async ({ page, }) => { diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index b476be1ab..1338ea327 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -16,6 +16,13 @@ export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001"; export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002"; export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099"; +const MOCK_AUTH_USER = { + id: "default", + email: "default@test.local", + system_role: "admin", + needs_setup: false, +}; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -71,6 +78,21 @@ const DEFAULT_SKILLS: MockSkill[] = [ }, ]; +function mockStreamMessages() { + return [ + { + type: "human", + id: "msg-human-1", + content: [{ type: "text", text: "Hello" }], + }, + { + type: "ai", + id: "msg-ai-1", + content: "Hello from DeerFlow!", + }, + ]; +} + // --------------------------------------------------------------------------- // mockLangGraphAPI // --------------------------------------------------------------------------- @@ -81,23 +103,84 @@ const DEFAULT_SKILLS: MockSkill[] = [ * for a real backend. */ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { - const threads = options?.threads ?? []; + let threads = [...(options?.threads ?? [])]; const agents = options?.agents ?? []; const skills = options?.skills ?? DEFAULT_SKILLS; + const upsertThread = (thread: MockThread) => { + threads = [ + thread, + ...threads.filter((existing) => existing.thread_id !== thread.thread_id), + ]; + }; + + const threadSearchResult = (thread: MockThread) => ({ + thread_id: thread.thread_id, + created_at: "2025-01-01T00:00:00Z", + updated_at: thread.updated_at ?? "2025-01-01T00:00:00Z", + metadata: { + ...(thread.metadata ?? {}), + ...(thread.agent_name ? { agent_name: thread.agent_name } : {}), + }, + status: "idle", + values: { title: thread.title ?? "Untitled" }, + }); + + // Auth — keep workspace tests independent from a real gateway session. + void page.route("**/api/v1/auth/me", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_AUTH_USER), + }); + } + return route.fallback(); + }); + + void page.route("**/api/v1/auth/setup-status", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ needs_setup: false }), + }); + } + return route.fallback(); + }); + + void page.route("**/api/v1/auth/logout", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ status: 204 }); + } + return route.fallback(); + }); + + void page.route("**/api/channels/providers", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ enabled: false, providers: [] }), + }); + } + return route.fallback(); + }); + + void page.route("**/api/channels/connections", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ connections: [] }), + }); + } + return route.fallback(); + }); + // Thread search — sidebar thread list & chats list page void page.route("**/api/langgraph/threads/search", async (route) => { - const body = threads.map((t) => ({ - thread_id: t.thread_id, - created_at: "2025-01-01T00:00:00Z", - updated_at: t.updated_at ?? "2025-01-01T00:00:00Z", - metadata: { - ...(t.metadata ?? {}), - ...(t.agent_name ? { agent_name: t.agent_name } : {}), - }, - status: "idle", - values: { title: t.title ?? "Untitled" }, - })); + const body = threads.map(threadSearchResult); let limit: number | undefined; let offset = 0; @@ -131,6 +214,12 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { // Thread create — called when user sends first message in a new chat void page.route("**/api/langgraph/threads", (route) => { if (route.request().method() === "POST") { + upsertThread({ + thread_id: MOCK_THREAD_ID, + title: "New Chat", + updated_at: new Date().toISOString(), + messages: mockStreamMessages(), + }); return route.fulfill({ status: 200, contentType: "application/json", @@ -149,6 +238,26 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { // Thread update (PATCH) — metadata update after creation void page.route("**/api/langgraph/threads/*", (route) => { + const threadId = decodeURIComponent( + new URL(route.request().url()).pathname.split("/").at(-1) ?? "", + ); + const matchingThread = threads.find( + (thread) => thread.thread_id === threadId, + ); + if (route.request().method() === "GET") { + if (!matchingThread) { + return route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "Thread not found" }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(threadSearchResult(matchingThread)), + }); + } if (route.request().method() === "PATCH") { return route.fulfill({ status: 200, @@ -156,6 +265,21 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { body: JSON.stringify({ thread_id: MOCK_THREAD_ID }), }); } + if (route.request().method() === "DELETE") { + threads = threads.filter((thread) => thread.thread_id !== threadId); + return route.fulfill({ + status: 204, + }); + } + return route.fallback(); + }); + + void page.route(/\/api\/threads\/[^/]+$/, (route) => { + if (route.request().method() === "DELETE") { + return route.fulfill({ + status: 204, + }); + } return route.fallback(); }); @@ -299,8 +423,21 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { ); // Run stream — returns a minimal SSE response with an AI message - void page.route("**/api/langgraph/runs/stream", handleRunStream); - void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream); + const handleMockRunStream = (route: Route) => { + upsertThread({ + thread_id: MOCK_THREAD_ID, + title: "New Chat", + updated_at: new Date().toISOString(), + messages: mockStreamMessages(), + }); + return handleRunStream(route); + }; + + void page.route("**/api/langgraph/runs/stream", handleMockRunStream); + void page.route( + "**/api/langgraph/threads/*/runs/stream", + handleMockRunStream, + ); // Models list — model picker dropdown void page.route("**/api/models", (route) => { @@ -391,18 +528,7 @@ export function handleRunStream(route: Route) { { event: "values", data: { - messages: [ - { - type: "human", - id: "msg-human-1", - content: [{ type: "text", text: "Hello" }], - }, - { - type: "ai", - id: "msg-ai-1", - content: "Hello from DeerFlow!", - }, - ], + messages: mockStreamMessages(), }, }, { event: "end", data: {} },