mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 06:28:58 +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.
|
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
|
### Key Patterns
|
||||||
|
|
||||||
- **Server Components by default**, `"use client"` only for interactive components
|
- **Server Components by default**, `"use client"` only for interactive components
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
ListTodoIcon,
|
ListTodoIcon,
|
||||||
MessageCircleQuestionMarkIcon,
|
MessageCircleQuestionMarkIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
NotebookPenIcon,
|
NotebookPenIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SquareTerminalIcon,
|
SquareTerminalIcon,
|
||||||
@ -28,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { formatTokenCount } from "@/core/messages/usage";
|
import { formatTokenCount } from "@/core/messages/usage";
|
||||||
import type { TokenDebugStep } from "@/core/messages/usage-model";
|
import type { TokenDebugStep } from "@/core/messages/usage-model";
|
||||||
import {
|
import {
|
||||||
|
extractContentFromMessage,
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
@ -96,6 +98,11 @@ export function MessageGroup({
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [lastToolCallStep, steps]);
|
}, [lastToolCallStep, steps]);
|
||||||
|
const collapsibleAboveLastToolCallSteps = useMemo(
|
||||||
|
() =>
|
||||||
|
aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"),
|
||||||
|
[aboveLastToolCallSteps],
|
||||||
|
);
|
||||||
const lastReasoningStep = useMemo(() => {
|
const lastReasoningStep = useMemo(() => {
|
||||||
if (lastToolCallStep) {
|
if (lastToolCallStep) {
|
||||||
const index = steps.indexOf(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 =
|
const lastReasoningDebugStep =
|
||||||
showTokenDebugSummaries && lastReasoningStep?.messageId
|
showTokenDebugSummaries && lastReasoningStep?.messageId
|
||||||
? debugStepByMessageId.get(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)}
|
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||||
open={true}
|
open={true}
|
||||||
>
|
>
|
||||||
{aboveLastToolCallSteps.length > 0 && (
|
{collapsibleAboveLastToolCallSteps.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
key="above"
|
key="above"
|
||||||
className="w-full items-start justify-start text-left"
|
className="w-full items-start justify-start text-left"
|
||||||
@ -242,7 +293,9 @@ export function MessageGroup({
|
|||||||
<span className="opacity-60">
|
<span className="opacity-60">
|
||||||
{showAbove
|
{showAbove
|
||||||
? t.toolCalls.lessSteps
|
? t.toolCalls.lessSteps
|
||||||
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
|
: t.toolCalls.moreSteps(
|
||||||
|
collapsibleAboveLastToolCallSteps.length,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
@ -258,30 +311,12 @@ export function MessageGroup({
|
|||||||
)}
|
)}
|
||||||
{lastToolCallStep && (
|
{lastToolCallStep && (
|
||||||
<ChainOfThoughtContent className="px-4 pb-2">
|
<ChainOfThoughtContent className="px-4 pb-2">
|
||||||
{showAbove &&
|
{(showAbove
|
||||||
aboveLastToolCallSteps.flatMap((step) => {
|
? aboveLastToolCallSteps
|
||||||
const stepIndex = steps.indexOf(step);
|
: aboveLastToolCallSteps.filter(
|
||||||
if (step.type === "reasoning") {
|
(step) => step.type === "assistantText",
|
||||||
return [
|
)
|
||||||
renderDebugSummary(step.messageId, stepIndex),
|
).flatMap(renderStep)}
|
||||||
<ChainOfThoughtStep
|
|
||||||
key={step.id}
|
|
||||||
label={
|
|
||||||
<MarkdownContent
|
|
||||||
content={step.reasoning ?? ""}
|
|
||||||
isLoading={isLoading}
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
></ChainOfThoughtStep>,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
renderDebugSummary(step.messageId, stepIndex),
|
|
||||||
renderToolCall(step),
|
|
||||||
];
|
|
||||||
})}
|
|
||||||
{renderDebugSummary(
|
{renderDebugSummary(
|
||||||
lastToolCallStep.messageId,
|
lastToolCallStep.messageId,
|
||||||
steps.indexOf(lastToolCallStep),
|
steps.indexOf(lastToolCallStep),
|
||||||
@ -699,12 +734,25 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
|||||||
result?: string;
|
result?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoTStep = CoTAssistantTextStep | CoTReasoningStep | CoTToolCallStep;
|
||||||
|
|
||||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||||
const steps: CoTStep[] = [];
|
const steps: CoTStep[] = [];
|
||||||
for (const message of messages) {
|
for (const [messageIndex, message] of messages.entries()) {
|
||||||
if (message.type === "ai") {
|
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);
|
const reasoning = extractReasoningContentFromMessage(message);
|
||||||
if (reasoning) {
|
if (reasoning) {
|
||||||
const step: CoTReasoningStep = {
|
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