mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
fix(frontend): keep streaming step text stable (#4510)
* fix frontend streaming step flicker * fix frontend post-tool streaming text
This commit is contained in:
parent
8a78c264b7
commit
d1d869c06c
@ -84,6 +84,8 @@ Auth UI note: the login page's "keep me signed in" option submits only `remember
|
||||
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. The protocol is versioned on the request side only: v1 covers `free_text` / `choice_with_other`, and v2 adds `form` (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a `response_kind: "text"` reply whose value is the human-readable summary plus one JSON block keyed by stable field names (`buildHumanInputFormSubmissionValue` — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS `Object.prototype` members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (`readHumanInputFormValue`); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native `<input type="checkbox">` controls seeded to an explicit `false` (`buildInitialHumanInputFormValues`) so an untouched checkbox submits as "no" while a `required` checkbox keeps must-agree semantics (no HTML `required` attribute — native constraint validation would intercept the custom submit path), and form controls carry label/`htmlFor`, `aria-required` plus a visually-hidden localized "required" marker, and `aria-invalid`/error associations whose error node stays mounted while any field is still invalid. Legacy-fallback closure: `deriveHumanInputThreadState` treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again) — an old v1-only frontend degrades a v2 request to text and the user replies through the normal composer without response metadata, and without this rule an upgraded frontend would see that request as still open and lock the composer. `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.
|
||||
While the current turn is still loading, a content-only AI message after the latest visible human input also stays in that processing group until the turn settles: a provider may append tool-call chunks to the same message later, and classifying it as a final assistant bubble too early makes the text jump into the steps panel. `MessageGroup` therefore renders processing text even before the first tool call arrives.
|
||||
The same rule applies after an earlier tool call: a later content-only AI message remains visible after the current last tool-call step while streaming, because that message may itself gain another tool call before the turn settles.
|
||||
|
||||
Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLatestEditableTurn()` exposes a human turn only when the transcript is idle and the most recent visible turn ends in a terminal assistant message. `core/threads/hooks.ts::editAndRegenerateMessage()` calls `POST /api/threads/{id}/runs/edit-regenerate/prepare`, submits the returned replacement message/checkpoint/metadata through the same LangGraph stream path as regenerate, optimistically hides the superseded message ids, and clears the optimistic replacement once the persisted replacement arrives.
|
||||
|
||||
|
||||
@ -109,6 +109,15 @@ function MessageGroupComponent({
|
||||
}
|
||||
return [];
|
||||
}, [lastToolCallStep, steps]);
|
||||
const afterLastToolCallAssistantTextSteps = useMemo(() => {
|
||||
if (!lastToolCallStep) {
|
||||
return [];
|
||||
}
|
||||
const index = steps.indexOf(lastToolCallStep);
|
||||
return steps
|
||||
.slice(index + 1)
|
||||
.filter((step) => step.type === "assistantText");
|
||||
}, [lastToolCallStep, steps]);
|
||||
const collapsibleAboveLastToolCallSteps = useMemo(
|
||||
() =>
|
||||
aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"),
|
||||
@ -314,22 +323,28 @@ function MessageGroupComponent({
|
||||
></ChainOfThoughtStep>
|
||||
</Button>
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
{(lastToolCallStep ??
|
||||
steps.some((step) => step.type === "assistantText")) && (
|
||||
<ChainOfThoughtContent className="px-4 pb-2">
|
||||
{(showAbove
|
||||
? aboveLastToolCallSteps
|
||||
: aboveLastToolCallSteps.filter(
|
||||
(step) => step.type === "assistantText",
|
||||
)
|
||||
{(lastToolCallStep
|
||||
? showAbove
|
||||
? aboveLastToolCallSteps
|
||||
: aboveLastToolCallSteps.filter(
|
||||
(step) => step.type === "assistantText",
|
||||
)
|
||||
: steps.filter((step) => step.type === "assistantText")
|
||||
).flatMap(renderStep)}
|
||||
{renderDebugSummary(
|
||||
lastToolCallStep.messageId,
|
||||
steps.indexOf(lastToolCallStep),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||||
{renderToolCall(lastToolCallStep, { isLast: true })}
|
||||
</FlipDisplay>
|
||||
<>
|
||||
{renderDebugSummary(
|
||||
lastToolCallStep.messageId,
|
||||
steps.indexOf(lastToolCallStep),
|
||||
)}
|
||||
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||||
{renderToolCall(lastToolCallStep, { isLast: true })}
|
||||
</FlipDisplay>
|
||||
{afterLastToolCallAssistantTextSteps.flatMap(renderStep)}
|
||||
</>
|
||||
)}
|
||||
</ChainOfThoughtContent>
|
||||
)}
|
||||
@ -933,7 +948,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
if (message.type === "ai") {
|
||||
const content = extractContentFromMessage(message);
|
||||
if (content && message.tool_calls?.length) {
|
||||
if (content) {
|
||||
steps.push({
|
||||
id: `${message.id ?? `ai-${messageIndex}`}-content`,
|
||||
messageId: message.id,
|
||||
|
||||
@ -180,7 +180,9 @@ function useStableMessageGroups(
|
||||
const previousGroupsRef = useRef<ThreadMessageGroup[]>([]);
|
||||
const previousIsLoadingRef = useRef(false);
|
||||
return useMemo(() => {
|
||||
const nextGroups = getMessageGroups(messages);
|
||||
const nextGroups = getMessageGroups(messages, {
|
||||
isCurrentTurnLoading: isLoading,
|
||||
});
|
||||
const previousGroups = previousGroupsRef.current;
|
||||
const activeGroupIndex =
|
||||
isLoading || previousIsLoadingRef.current ? nextGroups.length - 1 : -1;
|
||||
|
||||
@ -33,12 +33,25 @@ const HIDDEN_CONTROL_MESSAGE_NAMES = new Set([
|
||||
"todo_completion_reminder",
|
||||
]);
|
||||
|
||||
export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
export function getMessageGroups(
|
||||
messages: Message[],
|
||||
{ isCurrentTurnLoading = false }: { isCurrentTurnLoading?: boolean } = {},
|
||||
): MessageGroup[] {
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups: MessageGroup[] = [];
|
||||
let currentTurnStartIndex = -1;
|
||||
if (isCurrentTurnLoading) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human" && !isHiddenFromUIMessage(message)) {
|
||||
currentTurnStartIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the last group if it can still accept tool messages
|
||||
// (i.e. it's an in-flight processing group, not a terminal human/assistant group).
|
||||
@ -55,7 +68,7 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
if (isHiddenFromUIMessage(message)) {
|
||||
continue;
|
||||
}
|
||||
@ -127,8 +140,20 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
// panel above the bubble paints the identical reasoning a second time
|
||||
// (#3868). Intermediate reasoning (no content) and tool-calling steps
|
||||
// still belong in the processing group.
|
||||
// A content-only message is not necessarily the final answer while its
|
||||
// turn is still streaming: providers can append tool-call chunks to the
|
||||
// same message later. Keep that unresolved message in the processing
|
||||
// group so its visible text does not jump from an assistant bubble into
|
||||
// the steps panel when the tool call arrives (#4304).
|
||||
const isUnresolvedAssistantText =
|
||||
currentTurnStartIndex >= 0 &&
|
||||
messageIndex > currentTurnStartIndex &&
|
||||
hasContent(message) &&
|
||||
!hasToolCalls(message);
|
||||
const becomesAssistantBubble =
|
||||
hasContent(message) && !hasToolCalls(message);
|
||||
hasContent(message) &&
|
||||
!hasToolCalls(message) &&
|
||||
!isUnresolvedAssistantText;
|
||||
|
||||
if (hasPresentFiles(message)) {
|
||||
groups.push({
|
||||
@ -144,7 +169,9 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
});
|
||||
} else if (
|
||||
!becomesAssistantBubble &&
|
||||
(hasReasoning(message) || hasToolCalls(message))
|
||||
(hasReasoning(message) ||
|
||||
hasToolCalls(message) ||
|
||||
isUnresolvedAssistantText)
|
||||
) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
// Accumulate consecutive intermediate AI messages into one processing group.
|
||||
|
||||
@ -32,6 +32,23 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("MessageGroup", () => {
|
||||
it("renders unresolved streaming assistant text before a tool call arrives", () => {
|
||||
const html = renderGroup(
|
||||
[
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "I will inspect the source material first.",
|
||||
} as Message,
|
||||
],
|
||||
{ isLoading: true },
|
||||
);
|
||||
|
||||
expect(html).toContain(">inspect</span>");
|
||||
expect(html).toContain(">source</span>");
|
||||
expect(html).toContain(">first.</span>");
|
||||
});
|
||||
|
||||
it("renders assistant text attached to a tool-calling processing message", () => {
|
||||
const html = renderGroup([
|
||||
{
|
||||
@ -103,6 +120,42 @@ describe("MessageGroup", () => {
|
||||
expect(html).toContain("1 more step");
|
||||
});
|
||||
|
||||
it("keeps content-only assistant text visible after a tool call while streaming", () => {
|
||||
const html = renderGroup(
|
||||
[
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "I will inspect the current implementation.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "read_file",
|
||||
args: { path: "message-group.tsx" },
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "read_file",
|
||||
tool_call_id: "call-1",
|
||||
content: "file contents",
|
||||
} as Message,
|
||||
{
|
||||
id: "ai-2",
|
||||
type: "ai",
|
||||
content: "Here is the final streamed answer.",
|
||||
} as Message,
|
||||
],
|
||||
{ isLoading: true },
|
||||
);
|
||||
|
||||
expect(html).toContain(">final</span>");
|
||||
expect(html).toContain(">streamed</span>");
|
||||
expect(html).toContain(">answer.</span>");
|
||||
});
|
||||
|
||||
it("does not schedule artifact auto-open during render", () => {
|
||||
artifactsMockState.autoOpen = true;
|
||||
artifactsMockState.autoSelect = true;
|
||||
|
||||
@ -244,6 +244,92 @@ test("reasoning + content (no tool calls) yields a single assistant bubble, not
|
||||
expect(turnUsage.at(-1)?.map((message) => message.id)).toEqual(["ai-1"]);
|
||||
});
|
||||
|
||||
test("keeps unresolved streaming text in the processing group when tool calls arrive later", () => {
|
||||
const textOnlyMessages = [
|
||||
{ id: "human-1", type: "human", content: "Create a presentation" },
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "I will inspect the source material first.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
const textOnlyGroups = getMessageGroups(textOnlyMessages, {
|
||||
isCurrentTurnLoading: true,
|
||||
});
|
||||
expect(textOnlyGroups.map((group) => group.type)).toEqual([
|
||||
"human",
|
||||
"assistant:processing",
|
||||
]);
|
||||
|
||||
const withToolCall = [
|
||||
textOnlyMessages[0],
|
||||
{
|
||||
...textOnlyMessages[1],
|
||||
tool_calls: [
|
||||
{ id: "call-1", name: "read_file", args: { path: "slides.md" } },
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
const toolCallGroups = getMessageGroups(withToolCall, {
|
||||
isCurrentTurnLoading: true,
|
||||
});
|
||||
expect(toolCallGroups.map((group) => group.type)).toEqual([
|
||||
"human",
|
||||
"assistant:processing",
|
||||
]);
|
||||
expect(toolCallGroups[1]?.id).toBe(textOnlyGroups[1]?.id);
|
||||
|
||||
expect(getMessageGroups(textOnlyMessages).map((group) => group.type)).toEqual(
|
||||
["human", "assistant"],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps post-tool streaming text in the processing group until the turn settles", () => {
|
||||
const messages = [
|
||||
{ id: "human-1", type: "human", content: "Inspect and summarize" },
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "I will inspect the current implementation.",
|
||||
tool_calls: [
|
||||
{ id: "call-1", name: "read_file", args: { path: "source.ts" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "read_file",
|
||||
tool_call_id: "call-1",
|
||||
content: "file contents",
|
||||
},
|
||||
{
|
||||
id: "ai-2",
|
||||
type: "ai",
|
||||
content: "Here is the final streamed answer.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
const loadingGroups = getMessageGroups(messages, {
|
||||
isCurrentTurnLoading: true,
|
||||
});
|
||||
expect(loadingGroups.map((group) => group.type)).toEqual([
|
||||
"human",
|
||||
"assistant:processing",
|
||||
]);
|
||||
expect(loadingGroups[1]?.messages.map((message) => message.id)).toEqual([
|
||||
"ai-1",
|
||||
"tool-1",
|
||||
"ai-2",
|
||||
]);
|
||||
|
||||
expect(getMessageGroups(messages).map((group) => group.type)).toEqual([
|
||||
"human",
|
||||
"assistant:processing",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps tool-call reasoning in the processing group while the final answer's reasoning rides its own bubble", () => {
|
||||
// Companion to #3868: only the message that also becomes an assistant bubble
|
||||
// (content, no tool calls) is pulled out of the processing group. Reasoning
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user