fix(frontend): preserve first-turn order during chat creation (#6074)

This commit is contained in:
yang rui
2026-09-30 10:45:19 +08:00
committed by GitHub
parent 0ef405a77f
commit fcff55a075
6 changed files with 172 additions and 7 deletions
+3
View File
@@ -643,6 +643,9 @@ Ordinary `task` subagents retain the parent run's captured thread incarnation fo
MCP tool names are prefixed with `<server_name>_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers.
Signed-in users' notification toggle, default model, conversation mode, and reasoning effort are saved to their account and restored on other browsers or after clearing browser storage. Browser notification permission still needs to be granted on each device. Changes retry after network failures; unsent changes survive a reload in the same tab. Concurrent edits to different fields are preserved; for the same field, the last server write wins. Existing unscoped browser preferences are not uploaded automatically because they have no account owner; reselect those settings once after upgrading. Static demos and auth-disabled development keep browser-local settings. Thread-specific model overrides and other display preferences remain local.
In a new chat, the submitted question stays above its streamed reasoning and
tool steps while the server creates the conversation and confirms the message.
Capability Center groups plugins by office collaboration, documents and knowledge, search and research, business and data, and development and operations. The directory includes setup references alongside existing MCP configurations and Lark. Recommended integrations and built-in support do not imply an installed or verified connection; the Installed filter shows configured MCP entries and installed Lark only.
Personal MCP connections configured in the web interface are persisted per user.
+7
View File
@@ -1,5 +1,12 @@
### Data Flow
Scope local-turn ordering to `displayThreadId ?? threadId`, the displayed
conversation identity. Confirming a new chat changes the SDK `threadId` from
undefined to its existing display ID; preserve the human anchor across that
transition and clear it only on a displayed-conversation switch or the existing
failure/replay-gap paths. Draft and confirmation regressions live in
`tests/unit/core/threads/local-turn-order.dom.test.tsx`.
Answer details use `workspace/message-details` descriptors; skill run scoping
is documented in `docs/skill-usage-ui.md`.
+8 -2
View File
@@ -2352,7 +2352,6 @@ export function useThreadStream({
};
summarizedRef.current = new Set<string>();
pendingUsageBaselineMessageIdsRef.current = new Set();
localTurnAnchorRef.current = null;
pendingPreparedReplayRef.current = null;
setPendingSupersededRunIds(new Set());
setPendingSupersededMessageIds(new Set());
@@ -2360,6 +2359,12 @@ export function useThreadStream({
latestMessageCountsRef.current.humanMessageCount;
}, [threadId]);
// Confirming a new thread only assigns its SDK id; the displayed
// conversation and its submitted human anchor have not changed.
useEffect(() => {
localTurnAnchorRef.current = null;
}, [currentViewThreadId]);
// Release entries individually once canonical history confirms their stable
// identities. Keep unconfirmed entries across failure/refetch within this
// page lifecycle so a temporary persistence gap cannot hide a turn.
@@ -2956,7 +2961,7 @@ export function useThreadStream({
visibleOptimisticMessages,
);
const localTurnAnchor =
localTurnAnchorRef.current?.threadId === threadId
localTurnAnchorRef.current?.threadId === currentViewThreadId
? localTurnAnchorRef.current
: null;
const canonicalHistoryIdentities = new Set(
@@ -3008,6 +3013,7 @@ export function useThreadStream({
canonicalHistoryIdentities,
);
}, [
currentViewThreadId,
previouslyRenderedOrder,
renderMessages,
threadId,
@@ -220,15 +220,24 @@ function reasoningStreamFrames() {
}
/** Holds the SSE connection open so the turn stays in its streaming state. */
async function startHeldOpenStreamServer() {
const frames = reasoningStreamFrames();
async function startHeldOpenStreamServer(
getFrames: (url: string) => string[] = reasoningStreamFrames,
) {
const server = createServer((_request, response) => {
const threadId = new URL(
_request.url ?? "/",
"http://localhost",
).searchParams.get("threadId");
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "Content-Location",
...(threadId
? { "Content-Location": `/threads/${threadId}/runs/${RUN_ID}` }
: {}),
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
});
response.write(frames.join(""));
response.write(getFrames(_request.url ?? "/").join(""));
});
await new Promise<void>((resolve, reject) => {
@@ -262,6 +271,68 @@ async function expectRenderedAbove(upper: Locator, lower: Locator) {
expect(upperBox!.y).toBeLessThan(lowerBox!.y);
}
test("keeps the first question above reasoning while a new chat streams", async ({
page,
}) => {
const streamServer = await startHeldOpenStreamServer((url) => {
const threadId = new URL(url, "http://localhost").searchParams.get(
"threadId",
);
return [
{ event: "metadata", data: { run_id: RUN_ID, thread_id: threadId } },
{
event: "messages",
data: [
{
type: "AIMessageChunk",
id: "first-turn-reasoning",
content: ANSWER_TEXT,
additional_kwargs: { reasoning_content: REASONING_TEXT },
tool_calls: [],
tool_call_chunks: [],
},
{ langgraph_node: "agent" },
],
},
].map(
(event) =>
`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`,
);
});
mockLangGraphAPI(page, {
createdThreadMessages: [],
honorRequestedThreadId: true,
});
await page.route("**/api/langgraph/threads/*/runs/stream", (route) => {
const threadId = /\/threads\/([^/]+)\/runs\/stream/.exec(
route.request().url(),
)![1]!;
return route.continue({
url: `${streamServer.url}?threadId=${encodeURIComponent(threadId)}`,
});
});
try {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Explain your capabilities");
await textarea.press("Enter");
await expect(page).not.toHaveURL(/\/chats\/new$/);
// No human echo or terminal snapshot is sent: the first live frame must
// stay below the optimistic question after onStart confirms the thread.
await expectRenderedAbove(
page.getByText("Explain your capabilities", { exact: true }),
page.getByText("Reasoning", { exact: true }),
);
await expectRenderedAbove(
page.getByText("Reasoning", { exact: true }),
page.getByText(ANSWER_TEXT),
);
} finally {
await streamServer.close();
}
});
test("renders reasoning above the answer text while the turn is streaming", async ({
page,
}) => {
+8 -2
View File
@@ -121,6 +121,7 @@ export type MockAPIOptions = {
trashDocuments?: MockTrashDocument[];
threadFileGroups?: MockThreadFileGroup[];
createdThreadMessages?: unknown[];
honorRequestedThreadId?: boolean;
agents?: MockAgent[];
skills?: MockSkill[];
scheduledTasks?: Array<{
@@ -818,8 +819,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
// Thread create — called when user sends first message in a new chat
void page.route("**/api/langgraph/threads", (route) => {
if (route.request().method() === "POST") {
const threadId =
(options?.honorRequestedThreadId
? (route.request().postDataJSON() as { thread_id?: string } | null)
?.thread_id
: undefined) ?? MOCK_THREAD_ID;
upsertThread({
thread_id: MOCK_THREAD_ID,
thread_id: threadId,
title: "New Chat",
updated_at: new Date().toISOString(),
messages: options?.createdThreadMessages ?? mockStreamMessages(),
@@ -828,7 +834,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
status: 200,
contentType: "application/json",
body: JSON.stringify({
thread_id: MOCK_THREAD_ID,
thread_id: threadId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {},
@@ -309,6 +309,78 @@ test("keeps early streamed steps behind a local user message after finish", asyn
]);
});
test.each([false, true])(
"keeps a new chat's first turn ordered when the server thread is confirmed: %s",
async (confirmed) => {
rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { useThreadStream } = await import("@/core/threads/hooks");
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { rerender, result } = renderHook(
({ created }: { created: boolean }) =>
useThreadStream({
context: DEFAULT_LOCAL_SETTINGS.context,
isMock: true,
threadId: created ? "thread-1" : undefined,
displayThreadId: "thread-1",
}),
{
initialProps: { created: false },
wrapper: createWrapper(queryClient),
},
);
const submittedId = await submitVisibleTurn(result);
if (confirmed) {
// ChatPage's onStart confirms the existing draft conversation without
// remounting the hook or changing displayThreadId.
rerender({ created: true });
}
streamMockState.messages = [aiMessage("first-step", "Searching the web")];
streamMockState.isLoading = true;
rerender({ created: confirmed });
expect(visibleMessageIds(result.current.thread.messages)).toEqual([
submittedId,
"first-step",
]);
const serverHuman = humanMessage(
`${submittedId}__user`,
"Continue the work",
);
streamMockState.messages = [
...streamMockState.messages,
{
type: "tool",
id: "search-result",
tool_call_id: "search",
content: "Found news",
} as Message,
serverHuman,
];
rerender({ created: confirmed });
await act(async () => {
await rs.advanceTimersByTimeAsync(100);
});
expect(visibleMessageIds(result.current.thread.messages)).toEqual([
serverHuman.id,
"first-step",
"search-result",
]);
act(() => {
streamMockState.onFinish?.({
values: { messages: streamMockState.messages },
});
streamMockState.isLoading = false;
rerender({ created: confirmed });
});
expect(visibleMessageIds(result.current.thread.messages)).toEqual([
serverHuman.id,
"first-step",
"search-result",
]);
},
);
test("keeps established history order while the submitted human is outside the render snapshot", async () => {
rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let rows = seededHistoryRows();