mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
fix(frontend): keep clarification text outside execution steps (#5508)
* fix(frontend): keep clarification text outside execution steps * refactor(frontend): share clarification run boundary detection
This commit is contained in:
parent
d8db4e1bf4
commit
c24fd1e66f
@ -1230,7 +1230,7 @@ Gateway-generated follow-up suggestions now normalize both plain-string model ou
|
||||
|
||||
The Web UI composer can polish draft input before sending. The rewrite runs as a short Gateway LLM request using the `input_polish` model configuration, keeps slash skill prefixes such as `/data-analysis`, and only replaces the local draft after the user clicks the polish button; it does not create a thread run or persist a message.
|
||||
|
||||
When the agent asks for clarification, the Web UI shows the structured response card but keeps the normal composer available. Users can complete the card or send a free-form chat message to bypass it; that message closes the latest pending clarification and becomes the agent's next input.
|
||||
When the agent asks for clarification, the Web UI shows the structured response card but keeps the normal composer available. Users can complete the card or send a free-form chat message to bypass it; that message closes the latest pending clarification and becomes the agent's next input. Accompanying answer text stays outside the execution steps panel, and answering the request keeps previously completed text in the conversation while the agent continues.
|
||||
|
||||
Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include a selected slash skill when present, and are cleared once a send is accepted. Attachments and quoted conversation context are intentionally not persisted.
|
||||
|
||||
|
||||
@ -175,3 +175,15 @@ Array previews coalesce consecutive generated markers only at the end into one o
|
||||
frame path for older clients.
|
||||
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
|
||||
- `src/components/workspace/chats/chat-box.tsx` owns the desktop right-panel layout, and **all three** right panels (artifacts, sidecar, browser) share one `ResizablePanelGroup` — do not fork a non-resizable branch per panel kind, which is how the artifacts divider silently lost its drag handle (#4465). Open/close is `collapse()` / `resize()` on the side panel's imperative handle, not conditional rendering, so the width can animate. Three constraints hold that together: the size transition is applied from the group as `[&>[data-panel]]:transition-[flex-grow]` because the sized flex item is the library's own `[data-panel]` element rather than the child `className` lands on; it is applied only while an open/close is in flight, so a drag is not interpolated frame by frame; and during the animation the panel content is held at its final width in `cqw` and clipped, because a reflowing message list re-runs its scroll-to-bottom (pinned by `tests/e2e/sidecar-chat.spec.ts`'s no-animated-scroll test) and a re-wrapping composer changes which responsive labels it shows. Because the panel is `collapsible`, the library can also collapse it to `0%` on its own when a drag crosses `minSize`, without going through the state that owns it. `onResize` records the last positive size while the pointer moves, but the owning `sidecar` / `browserView` / `artifactsOpen` state must only mirror a final `0%` layout from `onLayoutChanged`, after pointer release; closing on the first `0%` resize frame breaks a continuous drag that reaches the edge and then reverses before release.
|
||||
|
||||
Clarification ToolMessages delimit completed runs for streaming message grouping,
|
||||
including continuations submitted with hidden human replies. Do not classify all
|
||||
messages after the last visible human as unresolved once a clarification result
|
||||
has arrived. The processing renderer keeps tool-calling messages intact for
|
||||
association and usage accounting, but renders text accompanying
|
||||
`ask_clarification` outside the execution panel (including mixed tool calls).
|
||||
|
||||
`findCurrentTurnStartIndex` owns the boundary rule for both full and incremental
|
||||
message grouping. Incremental prefix/tail splitting applies only at human
|
||||
boundaries; clarification results also belong to the preceding processing group,
|
||||
so derive the full grouping and stabilize references at clarification boundaries.
|
||||
|
||||
@ -76,7 +76,24 @@ function MessageGroupComponent({
|
||||
const [showLastThinking, setShowLastThinking] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const allSteps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
// Keep the original messages and tool associations intact. Only the display
|
||||
// of clarification context moves outside the execution disclosure (#5503).
|
||||
const clarificationTextSteps = useMemo(
|
||||
() =>
|
||||
allSteps.filter(
|
||||
(step): step is CoTAssistantTextStep =>
|
||||
step.type === "assistantText" && step.isClarificationContext === true,
|
||||
),
|
||||
[allSteps],
|
||||
);
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
allSteps.filter(
|
||||
(step) => step.type !== "assistantText" || !step.isClarificationContext,
|
||||
),
|
||||
[allSteps],
|
||||
);
|
||||
const stepIndexByStep = useMemo(
|
||||
() => new Map(steps.map((step, index) => [step, index] as const)),
|
||||
[steps],
|
||||
@ -315,7 +332,7 @@ function MessageGroupComponent({
|
||||
? debugStepByMessageId.get(lastReasoningStep.messageId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
const processingPanel = (
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
open={true}
|
||||
@ -447,6 +464,17 @@ function MessageGroupComponent({
|
||||
)}
|
||||
</ChainOfThought>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{processingPanel}
|
||||
{clarificationTextSteps.map((step) => (
|
||||
<div key={step.id} className="w-full">
|
||||
<MarkdownContent content={step.content} isLoading={isLoading} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageGroup = memo(
|
||||
@ -973,6 +1001,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
}
|
||||
|
||||
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
||||
isClarificationContext?: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@ -1046,6 +1075,9 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
messageId: message.id,
|
||||
type: "assistantText",
|
||||
content,
|
||||
isClarificationContext: message.tool_calls?.some(
|
||||
(toolCall) => toolCall.name === "ask_clarification",
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const tool_call of message.tool_calls ?? []) {
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { getMessageRunId } from "./run-duration";
|
||||
import {
|
||||
findCurrentTurnStartIndex,
|
||||
getMessageGroups,
|
||||
isHiddenFromUIMessage,
|
||||
type MessageGroup,
|
||||
@ -92,18 +93,14 @@ export function deriveStableMessageGroups(
|
||||
previousIsLoading: boolean,
|
||||
): MessageGroup[] {
|
||||
if (isLoading && previousGroups.length > 0) {
|
||||
let turnStartIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const candidate = messages[index];
|
||||
if (candidate?.type === "human" && !isHiddenFromUIMessage(candidate)) {
|
||||
turnStartIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const turnStartIndex = findCurrentTurnStartIndex(messages);
|
||||
|
||||
const turnStartMessage = messages[turnStartIndex];
|
||||
let previousTurnStartGroupIndex = -1;
|
||||
if (turnStartMessage) {
|
||||
// Only human boundaries can be split independently. A clarification result
|
||||
// also belongs to its preceding processing group for tool association;
|
||||
// use full grouping below at that boundary, then stabilize the groups.
|
||||
if (turnStartMessage?.type === "human") {
|
||||
for (let index = previousGroups.length - 1; index >= 0; index -= 1) {
|
||||
const group = previousGroups[index];
|
||||
if (
|
||||
|
||||
@ -44,16 +44,9 @@ export function getMessageGroups(
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentTurnStartIndex = isCurrentTurnLoading
|
||||
? findCurrentTurnStartIndex(messages)
|
||||
: -1;
|
||||
|
||||
// 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).
|
||||
@ -768,6 +761,25 @@ export function hasPresentFiles(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The latest visible user input or clarification result delimits a run. */
|
||||
export function findCurrentTurnStartIndex(
|
||||
messages: readonly Message[],
|
||||
): number {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
// Clarification replies are hidden: the result, rather than the last
|
||||
// visible human, separates completed answers from their continuation.
|
||||
if (
|
||||
message &&
|
||||
!isHiddenFromUIMessage(message) &&
|
||||
(message.type === "human" || isClarificationToolMessage(message))
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { createElement, type ComponentProps } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { MessageGroup } from "@/components/workspace/messages/message-group";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
|
||||
const artifactsMockState = rs.hoisted(() => ({
|
||||
autoOpen: false,
|
||||
autoSelect: false,
|
||||
}));
|
||||
|
||||
rs.mock("@/components/workspace/artifacts", () => ({
|
||||
useArtifacts: () => ({
|
||||
artifacts: [],
|
||||
setArtifacts: () => undefined,
|
||||
selectedArtifact: null,
|
||||
autoSelect: artifactsMockState.autoSelect,
|
||||
select: () => undefined,
|
||||
deselect: () => undefined,
|
||||
open: false,
|
||||
autoOpen: artifactsMockState.autoOpen,
|
||||
setOpen: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
artifactsMockState.autoOpen = false;
|
||||
artifactsMockState.autoSelect = false;
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Clarification context", () => {
|
||||
it("renders clarification context once outside the processing panel, including mixed tool calls", () => {
|
||||
for (const mixed of [false, true]) {
|
||||
const html = renderGroup([
|
||||
{
|
||||
id: "ask",
|
||||
type: "ai",
|
||||
content: "Completed deployment plan.",
|
||||
additional_kwargs: {
|
||||
reasoning_content: "Choose the deployment target.",
|
||||
},
|
||||
tool_calls: [
|
||||
...(mixed
|
||||
? [
|
||||
{
|
||||
id: "search",
|
||||
name: "web_search",
|
||||
args: { query: "deployment" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "clarify",
|
||||
name: "ask_clarification",
|
||||
args: { question: "Which environment?" },
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
]);
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = html;
|
||||
const panel = root.querySelector(".border");
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.textContent).not.toContain("Completed deployment plan.");
|
||||
expect(
|
||||
root.textContent?.split("Completed deployment plan."),
|
||||
).toHaveLength(2);
|
||||
expect(panel?.textContent).toContain("Need your help");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function renderGroup(
|
||||
messages: Message[],
|
||||
props: Omit<ComponentProps<typeof MessageGroup>, "messages"> = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
I18nContext.Provider,
|
||||
{
|
||||
value: {
|
||||
locale: "en-US",
|
||||
setLocale: () => undefined,
|
||||
t: enUS,
|
||||
},
|
||||
},
|
||||
createElement(MessageGroup, { ...props, messages }),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -153,3 +153,61 @@ describe("incremental message derivation", () => {
|
||||
expect(next.byGroupIndex.at(-1)).not.toBe(initial.byGroupIndex.at(-1));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pre-clarification answers stable through hidden replies, reconnect, and settlement", () => {
|
||||
const history = [
|
||||
message("human", "h", "Plan a deployment"),
|
||||
message("ai", "plan", "Completed plan"),
|
||||
{
|
||||
...message("ai", "ask", ""),
|
||||
tool_calls: [{ id: "call", name: "ask_clarification", args: {} }],
|
||||
},
|
||||
{
|
||||
...message("tool", "request", "Which environment?"),
|
||||
name: "ask_clarification",
|
||||
tool_call_id: "call",
|
||||
},
|
||||
] as Message[];
|
||||
const reply = {
|
||||
...message("human", "reply", "staging"),
|
||||
additional_kwargs: { hide_from_ui: true },
|
||||
} as Message;
|
||||
const continued = [
|
||||
...history,
|
||||
reply,
|
||||
message("ai", "next", "Starting deployment"),
|
||||
];
|
||||
const waiting = deriveStableMessageGroups(history, false, [], false);
|
||||
const running = deriveStableMessageGroups(continued, true, waiting, false);
|
||||
const reconnect = deriveStableMessageGroups(continued, true, [], false);
|
||||
const settled = deriveStableMessageGroups(continued, false, running, true);
|
||||
for (const groups of [waiting, running, reconnect, settled]) {
|
||||
expect(groups.find((group) => group.id === "plan")?.type).toBe("assistant");
|
||||
expect(
|
||||
groups
|
||||
.flatMap((group) => group.messages)
|
||||
.filter((item) => item.id === "plan"),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
expect(
|
||||
running
|
||||
.find((group) => group.id === "ask")
|
||||
?.messages.map((item) => item.id),
|
||||
).toEqual(["ask", "request"]);
|
||||
expect(running.find((group) => group.id === "plan")).toBe(
|
||||
waiting.find((group) => group.id === "plan"),
|
||||
);
|
||||
const nextVisibleTurn = [
|
||||
...continued,
|
||||
message("human", "followup", "Check status"),
|
||||
message("ai", "status", "Checking"),
|
||||
];
|
||||
expect(
|
||||
deriveStableMessageGroups(nextVisibleTurn, true, running, true),
|
||||
).toEqual(getMessageGroups(nextVisibleTurn, { isCurrentTurnLoading: true }));
|
||||
expect(running).toEqual(reconnect);
|
||||
expect(running.find((group) => group.id === "next")?.type).toBe(
|
||||
"assistant:processing",
|
||||
);
|
||||
expect(settled.find((group) => group.id === "next")?.type).toBe("assistant");
|
||||
});
|
||||
|
||||
@ -1483,3 +1483,65 @@ describe("orphan tool messages", () => {
|
||||
expect(t1b?.type).toBe("tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clarification run boundaries", () => {
|
||||
const beforeReply = [
|
||||
{ id: "human", type: "human", content: "Plan the deployment" },
|
||||
{ id: "plan", type: "ai", content: "The completed deployment plan." },
|
||||
{
|
||||
id: "ask",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [{ id: "call", name: "ask_clarification", args: {} }],
|
||||
},
|
||||
{
|
||||
id: "request",
|
||||
type: "tool",
|
||||
name: "ask_clarification",
|
||||
tool_call_id: "call",
|
||||
content: "Which environment?",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
test("keeps completed text outside processing when the request arrives and during hidden-reply continuation", () => {
|
||||
const reply = {
|
||||
id: "reply",
|
||||
type: "human",
|
||||
content: "staging",
|
||||
additional_kwargs: { hide_from_ui: true },
|
||||
} as Message;
|
||||
const continuation = {
|
||||
id: "next",
|
||||
type: "ai",
|
||||
content: "Deploying now.",
|
||||
} as Message;
|
||||
for (const messages of [
|
||||
beforeReply,
|
||||
[...beforeReply, reply, continuation],
|
||||
]) {
|
||||
const groups = getMessageGroups(messages, { isCurrentTurnLoading: true });
|
||||
expect(groups.find((group) => group.id === "plan")?.type).toBe(
|
||||
"assistant",
|
||||
);
|
||||
expect(groups.filter((group) => group.type === "human")).toHaveLength(1);
|
||||
}
|
||||
const groups = getMessageGroups([...beforeReply, reply, continuation], {
|
||||
isCurrentTurnLoading: true,
|
||||
});
|
||||
expect(groups.find((group) => group.id === "next")?.type).toBe(
|
||||
"assistant:processing",
|
||||
);
|
||||
expect(
|
||||
getMessageGroups([...beforeReply, reply, continuation]).find(
|
||||
(group) => group.id === "next",
|
||||
)?.type,
|
||||
).toBe("assistant");
|
||||
});
|
||||
|
||||
test("recognizes a clarification boundary without a loaded visible human message", () => {
|
||||
const groups = getMessageGroups(beforeReply.slice(1), {
|
||||
isCurrentTurnLoading: true,
|
||||
});
|
||||
expect(groups[0]?.type).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user