diff --git a/frontend/src/core/threads/export.ts b/frontend/src/core/threads/export.ts index cf1f92e47..5d40eeab5 100644 --- a/frontend/src/core/threads/export.ts +++ b/frontend/src/core/threads/export.ts @@ -5,12 +5,45 @@ import { extractReasoningContentFromMessage, hasContent, hasToolCalls, + isHiddenFromUIMessage, stripUploadedFilesTag, } from "../messages/utils"; import type { AgentThread } from "./types"; import { titleOfThread } from "./utils"; +/** + * Optional debug switches for advanced exports. + * + * Bytedance/deer-flow issue #3107 BUG-006: by default, the user-facing chat + * export must include only the visible transcript. Internal payloads — + * `hide_from_ui` messages, reasoning content, tool calls, and tool result + * messages — stay out unless the caller explicitly opts in. There is no UI + * surface for this today; the flags exist so a future "debug export" can + * reuse the same formatter instead of forking it. + */ +export interface ExportOptions { + includeReasoning?: boolean; + includeToolCalls?: boolean; + includeToolMessages?: boolean; + includeHidden?: boolean; +} + +function visibleMessages( + messages: Message[], + options: ExportOptions, +): Message[] { + return messages.filter((message) => { + if (!options.includeHidden && isHiddenFromUIMessage(message)) { + return false; + } + if (!options.includeToolMessages && message.type === "tool") { + return false; + } + return true; + }); +} + function formatMessageContent(message: Message): string { const text = extractContentFromMessage(message); if (!text) return ""; @@ -26,6 +59,7 @@ function formatToolCalls(message: Message): string { export function formatThreadAsMarkdown( thread: AgentThread, messages: Message[], + options: ExportOptions = {}, ): string { const title = titleOfThread(thread); const createdAt = thread.created_at @@ -41,16 +75,20 @@ export function formatThreadAsMarkdown( "", ]; - for (const message of messages) { + for (const message of visibleMessages(messages, options)) { if (message.type === "human") { const content = formatMessageContent(message); if (content) { lines.push(`## 🧑 User`, "", content, "", "---", ""); } } else if (message.type === "ai") { - const reasoning = extractReasoningContentFromMessage(message); + const reasoning = options.includeReasoning + ? extractReasoningContentFromMessage(message) + : undefined; const content = formatMessageContent(message); - const toolCalls = formatToolCalls(message); + const toolCalls = options.includeToolCalls + ? formatToolCalls(message) + : ""; if (!content && !toolCalls && !reasoning) continue; @@ -86,17 +124,20 @@ export function formatThreadAsMarkdown( export function formatThreadAsJSON( thread: AgentThread, messages: Message[], + options: ExportOptions = {}, ): string { const exportData = { title: titleOfThread(thread), thread_id: thread.thread_id, created_at: thread.created_at, exported_at: new Date().toISOString(), - messages: messages.map((msg) => ({ + messages: visibleMessages(messages, options).map((msg) => ({ type: msg.type, id: msg.id, content: typeof msg.content === "string" ? msg.content : msg.content, - ...(msg.type === "ai" && msg.tool_calls?.length + ...(options.includeToolCalls && + msg.type === "ai" && + msg.tool_calls?.length ? { tool_calls: msg.tool_calls } : {}), })), diff --git a/frontend/tests/unit/core/threads/export.test.ts b/frontend/tests/unit/core/threads/export.test.ts new file mode 100644 index 000000000..de755387d --- /dev/null +++ b/frontend/tests/unit/core/threads/export.test.ts @@ -0,0 +1,134 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { describe, expect, it } from "vitest"; + +import { + formatThreadAsJSON, + formatThreadAsMarkdown, +} from "@/core/threads/export"; +import type { AgentThread } from "@/core/threads/types"; + +// Bytedance/deer-flow issue #3107 BUG-006: the chat export path bypasses the +// UI-level hidden-message filter and emits reasoning content, tool calls, and +// any other "internal" payload as if it were part of the user transcript. + +function makeThread(): AgentThread { + return { + thread_id: "thread-1", + created_at: "2026-05-21T00:00:00Z", + updated_at: "2026-05-21T00:00:00Z", + metadata: { title: "Demo thread" }, + status: "idle", + values: { messages: [] }, + } as unknown as AgentThread; +} + +function human(content: string, extra: Partial = {}): Message { + return { + id: `h-${content}`, + type: "human", + content, + ...extra, + } as Message; +} + +function ai( + content: string, + extra: Partial & { tool_calls?: unknown } = {}, +): Message { + return { + id: `a-${content}`, + type: "ai", + content, + ...extra, + } as Message; +} + +function toolMsg(content: string): Message { + return { + id: `t-${content}`, + type: "tool", + content, + name: "task", + tool_call_id: "call-1", + } as unknown as Message; +} + +describe("formatThreadAsMarkdown", () => { + it("includes plain user and assistant text", () => { + const md = formatThreadAsMarkdown(makeThread(), [ + human("hello"), + ai("hi there"), + ]); + expect(md).toContain("hello"); + expect(md).toContain("hi there"); + }); + + it("drops messages marked hide_from_ui", () => { + const hidden = human("internal system reminder", { + additional_kwargs: { hide_from_ui: true }, + } as Partial); + const md = formatThreadAsMarkdown(makeThread(), [ + hidden, + ai("public answer"), + ]); + expect(md).not.toContain("internal system reminder"); + expect(md).toContain("public answer"); + }); + + it("does not emit reasoning_content by default", () => { + const message = ai("final answer", { + additional_kwargs: { + reasoning_content: "secret chain of thought", + }, + } as Partial); + const md = formatThreadAsMarkdown(makeThread(), [message]); + expect(md).not.toContain("secret chain of thought"); + expect(md).not.toContain("Thinking"); + }); + + it("does not emit tool calls by default", () => { + const message = ai("ok", { + tool_calls: [{ id: "1", name: "task", args: { description: "do work" } }], + } as Partial); + const md = formatThreadAsMarkdown(makeThread(), [message]); + expect(md).not.toContain("**Tool:**"); + expect(md).not.toContain("`task`"); + }); + + it("drops tool result messages", () => { + const md = formatThreadAsMarkdown(makeThread(), [ + ai("delegating"), + toolMsg("Task Succeeded. Result: confidential"), + ]); + expect(md).not.toContain("confidential"); + }); +}); + +describe("formatThreadAsJSON", () => { + it("strips hidden messages, tool messages, reasoning, and tool calls", () => { + const messages = [ + human("hello"), + human("secret reminder", { + additional_kwargs: { hide_from_ui: true }, + } as Partial), + ai("answer", { + additional_kwargs: { + reasoning_content: "secret reasoning", + }, + tool_calls: [{ id: "1", name: "task", args: {} }], + } as Partial), + toolMsg("internal trace"), + ]; + const raw = formatThreadAsJSON(makeThread(), messages); + const parsed = JSON.parse(raw) as { + messages: { type: string; tool_calls?: unknown[] }[]; + }; + + expect(parsed.messages).toHaveLength(2); + expect(parsed.messages.every((m) => m.type !== "tool")).toBe(true); + expect(raw).not.toContain("secret reminder"); + expect(raw).not.toContain("secret reasoning"); + expect(raw).not.toContain("internal trace"); + expect(raw).not.toContain("tool_calls"); + }); +});