fix(frontend): exclude hidden, reasoning, and tool payloads from chat export

`formatThreadAsMarkdown` / `formatThreadAsJSON` iterated raw messages without
running the UI-level `isHiddenFromUIMessage` filter. Exported transcripts
therefore included `hide_from_ui` system reminders, memory injections,
provider `reasoning_content`, tool calls, and tool result messages — content
that is intentionally hidden in the chat view.

Filter the export to the user-visible transcript by default and gate
reasoning / tool calls / tool messages / hidden messages behind explicit
`ExportOptions` flags so a future debug export can opt back in without
forking the formatter.

Refs: bytedance/deer-flow#3107 (BUG-006)
This commit is contained in:
fancyboi999
2026-05-21 15:08:59 +08:00
parent 308d43c9bd
commit 9b77ef160f
2 changed files with 180 additions and 5 deletions
+46 -5
View File
@@ -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 }
: {}),
})),
@@ -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> = {}): Message {
return {
id: `h-${content}`,
type: "human",
content,
...extra,
} as Message;
}
function ai(
content: string,
extra: Partial<Message> & { 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<Message>);
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<Message>);
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<Message>);
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<Message>),
ai("answer", {
additional_kwargs: {
reasoning_content: "secret reasoning",
},
tool_calls: [{ id: "1", name: "task", args: {} }],
} as Partial<Message>),
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");
});
});