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
@@ -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");
});
});