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:
Xuehao Xu 2026-09-18 09:35:32 +08:00 committed by GitHub
parent d8db4e1bf4
commit c24fd1e66f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 289 additions and 22 deletions

View File

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

View File

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

View File

@ -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 ?? []) {

View File

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

View File

@ -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";
}

View File

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

View File

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

View File

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