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:
DanielWalnut 2026-07-12 19:50:15 +08:00 committed by GitHub
parent feb287077e
commit 65d474202f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 217 additions and 28 deletions

View File

@ -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

View File

@ -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 = {

View File

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