mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
fix(front): Show assistant text during tool steps (#4114)
* fix: show assistant text during tool steps * test: cover collapsed tool step assistant text * test: cover tool-call text review cases
This commit is contained in:
parent
feb287077e
commit
65d474202f
@ -73,6 +73,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
|
||||
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
|
||||
|
||||
Tool-calling AI messages can contain user-visible text as well as `tool_calls`. `core/messages/utils.ts` keeps these turns in an `assistant:processing` group, and `components/workspace/messages/message-group.tsx` must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Server Components by default**, `"use client"` only for interactive components
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
MessageSquareTextIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
@ -28,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { formatTokenCount } from "@/core/messages/usage";
|
||||
import type { TokenDebugStep } from "@/core/messages/usage-model";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
@ -96,6 +98,11 @@ export function MessageGroup({
|
||||
}
|
||||
return [];
|
||||
}, [lastToolCallStep, steps]);
|
||||
const collapsibleAboveLastToolCallSteps = useMemo(
|
||||
() =>
|
||||
aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"),
|
||||
[aboveLastToolCallSteps],
|
||||
);
|
||||
const lastReasoningStep = useMemo(() => {
|
||||
if (lastToolCallStep) {
|
||||
const index = steps.indexOf(lastToolCallStep);
|
||||
@ -220,6 +227,50 @@ export function MessageGroup({
|
||||
);
|
||||
};
|
||||
|
||||
const renderAssistantText = (step: CoTAssistantTextStep) => (
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
icon={MessageSquareTextIcon}
|
||||
label={
|
||||
<MarkdownContent
|
||||
content={step.content}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
|
||||
const renderStep = (step: CoTStep) => {
|
||||
const stepIndex = steps.indexOf(step);
|
||||
if (step.type === "assistantText") {
|
||||
return [
|
||||
renderDebugSummary(step.messageId, stepIndex),
|
||||
renderAssistantText(step),
|
||||
];
|
||||
}
|
||||
if (step.type === "reasoning") {
|
||||
return [
|
||||
renderDebugSummary(step.messageId, stepIndex),
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<MarkdownContent
|
||||
content={step.reasoning ?? ""}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
renderDebugSummary(step.messageId, stepIndex),
|
||||
renderToolCall(step),
|
||||
];
|
||||
};
|
||||
|
||||
const lastReasoningDebugStep =
|
||||
showTokenDebugSummaries && lastReasoningStep?.messageId
|
||||
? debugStepByMessageId.get(lastReasoningStep.messageId)
|
||||
@ -230,7 +281,7 @@ export function MessageGroup({
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
open={true}
|
||||
>
|
||||
{aboveLastToolCallSteps.length > 0 && (
|
||||
{collapsibleAboveLastToolCallSteps.length > 0 && (
|
||||
<Button
|
||||
key="above"
|
||||
className="w-full items-start justify-start text-left"
|
||||
@ -242,7 +293,9 @@ export function MessageGroup({
|
||||
<span className="opacity-60">
|
||||
{showAbove
|
||||
? t.toolCalls.lessSteps
|
||||
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
|
||||
: t.toolCalls.moreSteps(
|
||||
collapsibleAboveLastToolCallSteps.length,
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
@ -258,30 +311,12 @@ export function MessageGroup({
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
<ChainOfThoughtContent className="px-4 pb-2">
|
||||
{showAbove &&
|
||||
aboveLastToolCallSteps.flatMap((step) => {
|
||||
const stepIndex = steps.indexOf(step);
|
||||
if (step.type === "reasoning") {
|
||||
return [
|
||||
renderDebugSummary(step.messageId, stepIndex),
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<MarkdownContent
|
||||
content={step.reasoning ?? ""}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
renderDebugSummary(step.messageId, stepIndex),
|
||||
renderToolCall(step),
|
||||
];
|
||||
})}
|
||||
{(showAbove
|
||||
? aboveLastToolCallSteps
|
||||
: aboveLastToolCallSteps.filter(
|
||||
(step) => step.type === "assistantText",
|
||||
)
|
||||
).flatMap(renderStep)}
|
||||
{renderDebugSummary(
|
||||
lastToolCallStep.messageId,
|
||||
steps.indexOf(lastToolCallStep),
|
||||
@ -699,12 +734,25 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
result?: string;
|
||||
}
|
||||
|
||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
||||
content: string;
|
||||
}
|
||||
|
||||
type CoTStep = CoTAssistantTextStep | CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
for (const message of messages) {
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
if (message.type === "ai") {
|
||||
const content = extractContentFromMessage(message);
|
||||
if (content && message.tool_calls?.length) {
|
||||
steps.push({
|
||||
id: `${message.id ?? `ai-${messageIndex}`}-content`,
|
||||
messageId: message.id,
|
||||
type: "assistantText",
|
||||
content,
|
||||
});
|
||||
}
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
if (reasoning) {
|
||||
const step: CoTReasoningStep = {
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { describe, expect, it, rs } from "@rstest/core";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { MessageGroup } from "@/components/workspace/messages/message-group";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
|
||||
rs.mock("@/components/workspace/artifacts", () => ({
|
||||
useArtifacts: () => ({
|
||||
artifacts: [],
|
||||
setArtifacts: () => undefined,
|
||||
selectedArtifact: null,
|
||||
autoSelect: false,
|
||||
select: () => undefined,
|
||||
deselect: () => undefined,
|
||||
open: false,
|
||||
autoOpen: false,
|
||||
setOpen: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MessageGroup", () => {
|
||||
it("renders assistant text attached to a tool-calling processing message", () => {
|
||||
const html = renderGroup([
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "The browser action failed, so I will try another approach.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "web_search",
|
||||
args: { query: "DeerFlow issue 4027" },
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
expect(html).toContain(
|
||||
"The browser action failed, so I will try another approach.",
|
||||
);
|
||||
expect(html).toContain("DeerFlow issue 4027");
|
||||
});
|
||||
|
||||
it("keeps assistant text visible while older tool steps stay collapsed", () => {
|
||||
const html = renderGroup([
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "The first tool failed; I will try a narrower search.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "web_search",
|
||||
args: { query: "first hidden query" },
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "web_search",
|
||||
tool_call_id: "call-1",
|
||||
content: "[]",
|
||||
} as Message,
|
||||
{
|
||||
id: "ai-2",
|
||||
type: "ai",
|
||||
content: "The second approach should reveal the missing context.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-2",
|
||||
name: "bash",
|
||||
args: {
|
||||
description: "Inspect message rendering",
|
||||
command: "rg assistantText frontend/src",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
expect(html).toContain(
|
||||
"The first tool failed; I will try a narrower search.",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"The second approach should reveal the missing context.",
|
||||
);
|
||||
expect(html).not.toContain("first hidden query");
|
||||
expect(html).toContain("Inspect message rendering");
|
||||
expect(html).toContain("1 more step");
|
||||
});
|
||||
|
||||
it("keeps tool-calling assistant text visible when reasoning is also present", () => {
|
||||
const html = renderGroup([
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "I found a likely cause, so I will inspect the renderer next.",
|
||||
additional_kwargs: {
|
||||
reasoning_content: "Check how processing groups convert messages.",
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "bash",
|
||||
args: {
|
||||
description: "Inspect renderer conversion",
|
||||
command: "sed -n '720,780p' message-group.tsx",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
expect(html).toContain(
|
||||
"I found a likely cause, so I will inspect the renderer next.",
|
||||
);
|
||||
expect(html).toContain("Inspect renderer conversion");
|
||||
expect(html).toContain("1 more step");
|
||||
expect(html).not.toContain("Check how processing groups convert messages.");
|
||||
});
|
||||
});
|
||||
|
||||
function renderGroup(messages: Message[]) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
I18nContext.Provider,
|
||||
{
|
||||
value: {
|
||||
locale: "en-US",
|
||||
setLocale: () => undefined,
|
||||
},
|
||||
},
|
||||
createElement(MessageGroup, { messages }),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user