From 04974b76074f289708d2929d076d06db5eef1cdd Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:08:22 +0800 Subject: [PATCH] fix(webui): preserve causal message order (NAN-29) (#5503) --- nanobot/webui/transcript.py | 34 +- tests/utils/test_webui_transcript.py | 122 +++++++ .../src/components/thread/ThreadMessages.tsx | 12 +- webui/src/hooks/useNanobotStream.ts | 10 +- webui/src/lib/activity-timeline.ts | 217 +++++------ webui/src/lib/thread-event-projection.ts | 15 - webui/src/tests/thread-messages.test.tsx | 337 +++++++++++++++--- webui/src/tests/useNanobotStream.test.tsx | 108 +++++- 8 files changed, 651 insertions(+), 204 deletions(-) diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 68ff0adfd..43906be90 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -1813,11 +1813,16 @@ def replay_transcript_to_ui_messages( break content = str(candidate.get("content") or "") has_answer = len(content) > 0 + if has_answer: + break + # A completed reasoning field is closed even while its assistant + # placeholder remains streaming for the rest of the turn. if ( candidate.get("reasoningStreaming") - or candidate.get("reasoning") is not None - or has_answer - or candidate.get("isStreaming") + or ( + candidate.get("isStreaming") + and candidate.get("reasoning") is None + ) ): prev[i] = { **candidate, @@ -1827,15 +1832,6 @@ def replay_transcript_to_ui_messages( **turn_fields, } return - if not has_answer and candidate.get("isStreaming"): - prev[i] = { - **candidate, - "reasoning": chunk, - "reasoningStreaming": True, - "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(), - **turn_fields, - } - return break segment = _ensure_activity_segment() prev.append( @@ -1915,19 +1911,6 @@ def replay_transcript_to_ui_messages( and not m.get("media") ) - def is_tool_trace_at(index: int) -> bool: - m = messages[index] if 0 <= index < len(messages) else None - return bool(m and m.get("kind") == "trace") - - def prune_reasoning_only() -> None: - nonlocal messages - kept: list[dict[str, Any]] = [] - for i, m in enumerate(messages): - if is_reasoning_only_placeholder(m) and not is_tool_trace_at(i + 1): - continue - kept.append(m) - messages = kept - def stamp_completion( *, latency_ms: int | None = None, @@ -2442,7 +2425,6 @@ def replay_transcript_to_ui_messages( for i, m in enumerate(messages): if m.get("isStreaming"): messages[i] = {**m, "isStreaming": False} - prune_reasoning_only() lat = rec.get("latency_ms") usage = rec.get("usage") sanitized_usage = ( diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index bbd7d2157..2cac8a685 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -409,6 +409,128 @@ def test_replay_canonical_completed_stream_records() -> None: assert msgs[1]["latencyMs"] == 42 +def test_replay_preserves_closed_reasoning_slices_before_later_tool_trace() -> None: + msgs = replay_transcript_to_ui_messages([ + { + "event": "reasoning_delta", + "chat_id": "reasoning-boundary", + "text": "First reasoning.", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "reasoning", + "turn_seq": 1, + }, + { + "event": "reasoning_end", + "chat_id": "reasoning-boundary", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "reasoning", + "turn_seq": 2, + }, + { + "event": "reasoning_delta", + "chat_id": "reasoning-boundary", + "text": "Second reasoning.", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "reasoning", + "turn_seq": 3, + }, + { + "event": "reasoning_end", + "chat_id": "reasoning-boundary", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "reasoning", + "turn_seq": 4, + }, + { + "event": "message", + "chat_id": "reasoning-boundary", + "text": "exec()", + "kind": "tool_hint", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "activity", + "turn_seq": 5, + }, + { + "event": "message", + "chat_id": "reasoning-boundary", + "text": "Final answer.", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "answer", + "turn_seq": 6, + }, + { + "event": "turn_end", + "chat_id": "reasoning-boundary", + "turn_id": "turn-reasoning-boundary", + "turn_phase": "complete", + "turn_seq": 7, + }, + ]) + + assert [ + message.get("reasoning") + or (message.get("traces") or [None])[0] + or message.get("content") + for message in msgs + ] == [ + "First reasoning.", + "Second reasoning.", + "exec()", + "Final answer.", + ] + + +def test_replay_preserves_closed_reasoning_slices_without_tool_trace() -> None: + msgs = replay_transcript_to_ui_messages([ + {"event": "reasoning_delta", "text": "First reasoning.", "turn_seq": 1}, + {"event": "reasoning_end", "turn_seq": 2}, + {"event": "reasoning_delta", "text": "Second reasoning.", "turn_seq": 3}, + {"event": "reasoning_end", "turn_seq": 4}, + {"event": "message", "text": "Final answer.", "turn_seq": 5}, + {"event": "turn_end", "turn_seq": 6}, + ]) + + assert [ + (message.get("reasoning"), message.get("content")) + for message in msgs + ] == [ + ("First reasoning.", ""), + ("Second reasoning.", "Final answer."), + ] + + +def test_replay_keeps_answer_separate_from_reasoning_before_delayed_tool_trace() -> None: + msgs = replay_transcript_to_ui_messages([ + {"event": "delta", "text": "Visible progress.", "turn_phase": "answer"}, + {"event": "stream_end", "turn_phase": "answer"}, + {"event": "reasoning_delta", "text": "Think again.", "turn_phase": "reasoning"}, + {"event": "reasoning_end", "turn_phase": "reasoning"}, + { + "event": "message", + "text": "exec()", + "kind": "tool_hint", + "turn_phase": "activity", + }, + {"event": "message", "text": "Final answer.", "turn_phase": "answer"}, + {"event": "turn_end", "turn_phase": "complete"}, + ]) + + assert [ + ( + message.get("content"), + message.get("reasoning"), + message.get("kind"), + message.get("turnPhase"), + ) + for message in msgs + ] == [ + ("Visible progress.", None, None, "answer"), + ("", "Think again.", None, "reasoning"), + ("exec()", None, "trace", "activity"), + ("Final answer.", None, None, "answer"), + ] + + def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t-usage" diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index b4d0c410d..01e77ee82 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -26,12 +26,8 @@ interface ThreadMessagesProps { export type DisplayUnit = TurnUnit; -export function buildDisplayUnits( - messages: UIMessage[], - isStreaming = false, - activeTurnId: string | null = null, -): DisplayUnit[] { - return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined); +export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] { + return projectActivityTimeline(messages); } export function assistantForkFlags(units: DisplayUnit[]): boolean[] { @@ -69,8 +65,8 @@ export function ThreadMessages({ const { t } = useTranslation(); const messageListRef = useRef(null); const units = useMemo( - () => buildDisplayUnits(messages, isStreaming, activeTurnId), - [activeTurnId, isStreaming, messages], + () => buildDisplayUnits(messages), + [messages], ); const forkBoundaryAfterUnitIndex = useMemo( () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index e2b04247d..9b1d7ac2f 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -21,7 +21,6 @@ import { isReasoningOnlyPlaceholder, matchesTurn, mergeFileEdits, - pruneReasoningOnlyPlaceholders, replaceMessageAt, stampLastAssistantCompletion, stripCoveredFileEditToolHintsFromMessages, @@ -90,10 +89,12 @@ function attachReasoningChunk( const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const hasAnswer = candidate.content.length > 0; if (hasAnswer) break; + // ``reasoning_end`` closes this row even though the assistant placeholder + // stays streaming for the rest of the turn. The next reasoning stream must + // get its own row when an intervening tool trace is delayed or unavailable. if ( candidate.reasoningStreaming - || candidate.reasoning !== undefined - || candidate.isStreaming + || (candidate.isStreaming && candidate.reasoning === undefined) ) { const merged: UIMessage = { ...candidate, @@ -841,7 +842,6 @@ export function useNanobotStream( const completedAt = Date.now(); setMessages((prev) => { let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); - finalized = pruneReasoningOnlyPlaceholders(finalized); const latencyMs = typeof ev.latency_ms === "number" && ev.latency_ms >= 0 ? Math.round(ev.latency_ms) @@ -1185,7 +1185,7 @@ export function useNanobotStream( } const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev; return [ - ...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)), + ...base, { id: userMessageId, role: "user", diff --git a/webui/src/lib/activity-timeline.ts b/webui/src/lib/activity-timeline.ts index a8e7778c3..86a8f7cad 100644 --- a/webui/src/lib/activity-timeline.ts +++ b/webui/src/lib/activity-timeline.ts @@ -1,8 +1,7 @@ import type { UIMessage } from "@/lib/types"; -/** A completed turn has two surfaces: one activity container and one final - * answer. Answer text is never inferred to be activity merely because a later - * tool event arrived; explicit reasoning/activity fields own that distinction. */ +/** A turn is projected into ordered answer messages and collapsible activity + * runs. Folding changes presentation only; it never changes semantic order. */ export type TurnUnit = | { type: "activity"; @@ -49,14 +48,14 @@ export function hasPendingAgentActivity(messages: UIMessage[]): boolean { || previous.turnId !== lastTurnId; } -/** - * Project completed or replayed gateway rows into the stable shape: +/** Project gateway rows without changing their causal order. * - * user → [one live/completed activity surface] → [one final answer] - * - * A provider may emit multiple answer segments around tool activity. They are - * merged into the one final answer instead of being reclassified as reasoning; - * only explicit reasoning, trace, and model-activity rows enter the fold. + * Messages use ``turnSeq`` when every row in the turn provides it and fall + * back to stable arrival order otherwise. Only contiguous rows of the same + * display class are combined: activity rows share a collapsible surface, and + * adjacent answer slices share an answer bubble. A visible answer is always a + * hard boundary between activity surfaces; completed empty transport frames + * have no display semantics and therefore create no boundary. */ export function normalizeActivityTimeline( messages: UIMessage[], @@ -73,42 +72,22 @@ export function normalizeActivityTimeline( return; } - const ordered = orderMessagesByTurnSeq(turnMessages); - const activity: UIMessage[] = []; - const answers: UIMessage[] = []; - let activitySourceMessageCount = 0; - for (const message of ordered) { - if (isRawActivity(message)) { - activity.push(message); - activitySourceMessageCount += 1; - } else if (isAssistantAnswer(message)) { - if (message.reasoning?.trim() || message.reasoningStreaming) { - // The synthetic reasoning row and answer both come from one raw - // message, so account for that source on the answer unit only. - activity.push(reasoningOnlyMessageFromAnswer(message)); - } - answers.push(stripInlineReasoning(message)); - } else { - activity.push(message); - activitySourceMessageCount += 1; - } - } - - if (activity.length) { - units.push({ - type: "activity", - messages: activity, - sourceMessageCount: activitySourceMessageCount, - turnLatencyMs: activityTurnLatencyMs(activity, ordered), - startedAtMs: activeTurnStartedAtMs, - }); - } - if (answers.length) { - units.push({ - type: "message", - message: mergeAssistantAnswers(answers), - sourceMessageCount: answers.length, - }); + const projected = projectOrderedTurn( + orderMessagesByTurnSeq(turnMessages), + activeTurnStartedAtMs, + ); + if (projected.length) { + units.push(...projected); + } else if (units.length) { + // A turn containing only completed transport placeholders has no + // display surface. Keep its source count on the preceding prompt so + // persisted fork-boundary offsets still map to a visible unit. + const lastIndex = units.length - 1; + const last = units[lastIndex]; + units[lastIndex] = { + ...last, + sourceMessageCount: last.sourceMessageCount + turnMessages.length, + }; } turnMessages = []; @@ -133,57 +112,22 @@ export function normalizeActivityTimeline( return units; } -/** - * Keep an in-flight turn in arrival order. Answer, reasoning, and activity - * semantics are explicit, but a later tool event can still arrive after - * already-visible answer Markdown. Reparenting on each event would make that - * Markdown tree jump between containers. - * - * Completed turns still use ``normalizeActivityTimeline`` and collapse into - * one audit surface plus the merged answer. While the turn is active, answer - * text and contiguous activity phases stay in arrival order. A later tool - * therefore appends a Working surface after existing Markdown instead of - * reparenting it. - */ export function projectActivityTimeline( messages: UIMessage[], - liveTurnId?: string | null, ): TurnUnit[] { - if (liveTurnId === undefined) return normalizeActivityTimeline(messages); - - const liveStart = findLiveTurnStart(messages, liveTurnId); - if (liveStart < 0) return normalizeActivityTimeline(messages); - const nextPrompt = messages.findIndex( - (message, index) => index > liveStart && message.role === "user", - ); - const liveEnd = nextPrompt < 0 ? messages.length : nextPrompt; - - return [ - ...normalizeActivityTimeline(messages.slice(0, liveStart)), - ...projectLiveTurn(messages.slice(liveStart, liveEnd)), - ...normalizeActivityTimeline(messages.slice(liveEnd)), - ]; + return normalizeActivityTimeline(messages); } -function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.role !== "user") continue; - if (liveTurnId === null || message.turnId === liveTurnId) return index; - } - return -1; -} - -function projectLiveTurn(messages: UIMessage[]): TurnUnit[] { +function projectOrderedTurn( + messages: UIMessage[], + startedAtMs?: number, +): TurnUnit[] { const units: TurnUnit[] = []; - const prompt = messages[0]; - const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined; let activity: UIMessage[] = []; let activitySourceMessageCount = 0; - - if (prompt?.role === "user") { - units.push({ type: "message", message: prompt, sourceMessageCount: 1 }); - } + let answers: UIMessage[] = []; + let answerSourceMessageCount = 0; + let leadingNoopSourceMessageCount = 0; const flushActivity = () => { if (!activity.length) return; @@ -191,36 +135,87 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] { type: "activity", messages: activity, sourceMessageCount: activitySourceMessageCount, - turnLatencyMs: activityTurnLatencyMs(activity, activity), startedAtMs, }); activity = []; activitySourceMessageCount = 0; }; - for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) { - if (isRawActivity(message)) { - activity.push(message); + const flushAnswers = () => { + if (!answers.length) return; + units.push({ + type: "message", + message: mergeAssistantAnswers(answers), + sourceMessageCount: answerSourceMessageCount, + }); + answers = []; + answerSourceMessageCount = 0; + }; + + const claimLeadingNoops = () => { + const count = leadingNoopSourceMessageCount; + leadingNoopSourceMessageCount = 0; + return count; + }; + + const appendActivity = (message: UIMessage, sourceMessageCount: number) => { + flushAnswers(); + activity.push(message); + activitySourceMessageCount += sourceMessageCount + claimLeadingNoops(); + }; + + const absorbDisplayNoop = () => { + if (activity.length) { activitySourceMessageCount += 1; + } else if (answers.length) { + answerSourceMessageCount += 1; + } else { + leadingNoopSourceMessageCount += 1; + } + }; + + for (const message of messages) { + if (isCompletedDisplayNoop(message)) { + absorbDisplayNoop(); + continue; + } + if (isRawActivity(message)) { + appendActivity(message, 1); continue; } if (isAssistantAnswer(message)) { if (message.reasoning?.trim() || message.reasoningStreaming) { - activity.push(reasoningOnlyMessageFromAnswer(message)); + // This raw message contributes one answer source plus a synthetic + // reasoning row, so count it only on the answer unit. + appendActivity(reasoningOnlyMessageFromAnswer(message), 0); } flushActivity(); - units.push({ - type: "message", - message: stripInlineReasoning(message), - sourceMessageCount: 1, - }); + answerSourceMessageCount += claimLeadingNoops(); + answers.push(stripInlineReasoning(message)); + answerSourceMessageCount += 1; continue; } - activity.push(message); - activitySourceMessageCount += 1; + appendActivity(message, 1); } flushActivity(); + flushAnswers(); + + let lastActivityIndex = -1; + for (let index = units.length - 1; index >= 0; index -= 1) { + if (units[index].type !== "activity") continue; + lastActivityIndex = index; + break; + } + if (lastActivityIndex >= 0) { + const lastActivity = units[lastActivityIndex]; + if (lastActivity.type === "activity") { + const turnLatencyMs = activityTurnLatencyMs(lastActivity.messages, messages); + if (turnLatencyMs !== undefined) { + units[lastActivityIndex] = { ...lastActivity, turnLatencyMs }; + } + } + } return units; } @@ -228,6 +223,26 @@ function isRawActivity(message: UIMessage): boolean { return isAgentActivityMember(message); } +/** A completed transport placeholder carries ordering/accounting metadata but + * no user-visible semantics, so it cannot define a display boundary. */ +function isCompletedDisplayNoop(message: UIMessage): boolean { + return ( + message.role === "assistant" + && message.kind !== "trace" + && message.activityKind !== "model" + && !message.isStreaming + && !message.reasoningStreaming + && message.content.trim().length === 0 + && !message.reasoning?.trim() + && !message.media?.length + && !message.images?.length + && !message.traces?.some((line) => line.trim().length > 0) + && !message.toolEvents?.length + && !message.fileEdits?.length + && !message.sessionMessage + ); +} + function isAssistantAnswer(message: UIMessage): boolean { if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") { return false; diff --git a/webui/src/lib/thread-event-projection.ts b/webui/src/lib/thread-event-projection.ts index 2890b9269..471b51b1f 100644 --- a/webui/src/lib/thread-event-projection.ts +++ b/webui/src/lib/thread-event-projection.ts @@ -119,21 +119,6 @@ export function isReasoningOnlyPlaceholder(message: UIMessage): boolean { ); } -function isToolTrace(message: UIMessage | undefined): boolean { - return message?.kind === "trace"; -} - -export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] { - return prev.filter((message, index) => { - if (!isReasoningOnlyPlaceholder(message)) return true; - // A reasoning-only assistant row immediately followed by tool traces is - // the live equivalent of a persisted assistant tool-call message with - // empty content, reasoning_content, and tool_calls. Keep it so live render - // and history replay stay isomorphic. - return isToolTrace(prev[index + 1]); - }); -} - export function stampLastAssistantCompletion( prev: UIMessage[], completion: Pick, diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index f48dc8fbf..fa9a85355 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -238,6 +238,244 @@ describe("ThreadMessages", () => { expect(screen.getByText(/working/i)).toBeInTheDocument(); }); + it("projects a turn in causal order independently of streaming state", () => { + const turnId = "turn-causal-order"; + const messages: UIMessage[] = [ + { + id: "u1", + role: "user", + content: "inspect this", + turnId, + turnPhase: "user", + turnSeq: 0, + createdAt: 1, + }, + { + id: "a1", + role: "assistant", + content: "I will inspect it.", + turnId, + turnPhase: "answer", + turnSeq: 1, + createdAt: 2, + }, + { + id: "a2", + role: "assistant", + content: "Inspection complete.", + turnId, + turnPhase: "answer", + turnSeq: 4, + createdAt: 5, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "shell()", + traces: ["shell()"], + turnId, + turnPhase: "activity", + turnSeq: 2, + createdAt: 3, + }, + { + id: "r1", + role: "assistant", + content: "", + reasoning: "checking output", + turnId, + turnPhase: "reasoning", + turnSeq: 3, + createdAt: 4, + }, + ]; + + const units = buildDisplayUnits(messages); + const order = (units: ReturnType) => units.map((unit) => ( + unit.type === "activity" + ? `activity:${unit.messages.map((message) => message.id).join(",")}` + : unit.message.id + )); + + expect(order(units)).toEqual([ + "u1", + "a1", + "activity:t1,r1", + "a2", + ]); + + const { rerender } = render( + , + ); + const firstAnswer = screen.getByText("I will inspect it."); + const finalAnswer = screen.getByText("Inspection complete."); + const liveActivity = screen.getByRole("button", { name: /working/i }); + expect(firstAnswer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(liveActivity.compareDocumentPosition(finalAnswer) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + + rerender(); + const completedActivity = screen.getByRole("button", { name: /worked/i }); + expect(firstAnswer.compareDocumentPosition(completedActivity) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(completedActivity.compareDocumentPosition(finalAnswer) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + + it("ignores a completed empty answer frame without splitting contiguous activity", () => { + const turnId = "turn-empty-answer-frame"; + const segmentId = "activity-1"; + const messages: UIMessage[] = [ + { + id: "user", + role: "user", + content: "reply ok, then check", + turnId, + turnPhase: "user", + turnSeq: 1, + createdAt: 1, + }, + { + id: "reasoning-before", + role: "assistant", + content: "", + reasoning: "Planning confirmation", + activitySegmentId: segmentId, + turnId, + turnPhase: "reasoning", + turnSeq: 3, + createdAt: 2, + }, + { + id: "ok", + role: "assistant", + content: "ok", + reasoning: "Preparing first query", + activitySegmentId: segmentId, + turnId, + turnPhase: "answer", + turnSeq: 7, + createdAt: 3, + }, + { + id: "first-tool", + role: "tool", + kind: "trace", + content: "first()", + traces: ["first()"], + activitySegmentId: segmentId, + turnId, + turnPhase: "activity", + turnSeq: 9, + createdAt: 4, + }, + { + id: "empty-answer-frame", + role: "assistant", + content: "", + isStreaming: false, + turnId, + turnPhase: "answer", + turnSeq: 10, + createdAt: 5, + }, + { + id: "second-tool", + role: "tool", + kind: "trace", + content: "second()", + traces: ["second()"], + activitySegmentId: segmentId, + turnId, + turnPhase: "activity", + turnSeq: 12, + createdAt: 6, + }, + { + id: "final", + role: "assistant", + content: "finished", + reasoning: "Summarizing result", + activitySegmentId: segmentId, + turnId, + turnPhase: "answer", + turnSeq: 113, + createdAt: 7, + }, + ]; + + const units = buildDisplayUnits(messages); + expect(units.map((unit) => ( + unit.type === "activity" + ? `activity:${unit.messages.map((message) => message.id).join(",")}` + : unit.message.id + ))).toEqual([ + "user", + "activity:reasoning-before,ok-reasoning", + "ok", + "activity:first-tool,second-tool,final-reasoning", + "final", + ]); + expect(units.map((unit) => unit.sourceMessageCount)).toEqual([1, 1, 1, 3, 1]); + + render(); + + const activityShells = screen.getAllByRole("button", { name: /worked/i }); + const ok = screen.getByText("ok"); + const final = screen.getByText("finished"); + expect(activityShells).toHaveLength(2); + expect(activityShells[0].compareDocumentPosition(ok) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(ok.compareDocumentPosition(activityShells[1]) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(activityShells[1].compareDocumentPosition(final) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + + it("keeps empty frame source counts on the nearest visible unit", () => { + const emptyFrame: UIMessage = { + id: "empty", + role: "assistant", + content: "", + isStreaming: false, + turnPhase: "answer", + createdAt: 2, + }; + const answerUnits = buildDisplayUnits([ + { id: "a1", role: "assistant", content: "first", createdAt: 1 }, + emptyFrame, + { id: "a2", role: "assistant", content: "second", createdAt: 3 }, + ]); + expect(answerUnits).toMatchObject([{ + type: "message", + message: { content: "first\n\nsecond" }, + sourceMessageCount: 3, + }]); + + const emptyTurnUnits = buildDisplayUnits([ + { id: "user", role: "user", content: "hello", createdAt: 1 }, + emptyFrame, + ]); + expect(emptyTurnUnits).toMatchObject([{ + type: "message", + message: { id: "user" }, + sourceMessageCount: 2, + }]); + + const streamingUnits = buildDisplayUnits([{ + ...emptyFrame, + id: "streaming-placeholder", + isStreaming: true, + }]); + expect(streamingUnits).toMatchObject([{ + type: "activity", + messages: [{ id: "streaming-placeholder" }], + sourceMessageCount: 1, + }]); + }); + it("offers a follow-up action for text selected within one completed answer", async () => { const onQuoteSelection = vi.fn(); render( @@ -494,7 +732,7 @@ describe("ThreadMessages", () => { ]); }); - it("moves orphan trailing activity before the completed assistant answer", () => { + it("keeps trailing activity after the completed assistant answer", () => { const messages: UIMessage[] = [ { id: "r1", @@ -523,10 +761,9 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); - expect(units).toHaveLength(2); + expect(units).toHaveLength(3); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ "r1", - "t1", ]); expect(units[1]).toMatchObject({ type: "message", @@ -535,6 +772,9 @@ describe("ThreadMessages", () => { content: "Let me search the latest data.", }, }); + expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual([ + "t1", + ]); }); it("only marks the current activity timeline as live while streaming", () => { @@ -611,7 +851,7 @@ describe("ThreadMessages", () => { }, ]; - const units = buildDisplayUnits(messages, true); + const units = buildDisplayUnits(messages); expect( units[1].type === "activity" ? units[1].startedAtMs : undefined, @@ -805,27 +1045,29 @@ describe("ThreadMessages", () => { }, ]; - const units = buildDisplayUnits(messages, true); + const units = buildDisplayUnits(messages); - expect(units).toHaveLength(2); + expect(units).toHaveLength(3); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ "t0", - "t1", ]); expect(units[1]).toMatchObject({ type: "message", message: { id: "a1", content: "partial answer" }, }); + expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual([ + "t1", + ]); render(); const answer = screen.getByText("partial answer"); const liveActivity = screen.getByRole("button", { name: /working/i }); expect(answer.closest("[data-testid='activity-model-message']")).toBeNull(); - expect(liveActivity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); - it("moves late activity before a completed assistant answer", () => { + it("keeps late activity after a completed assistant answer", () => { const messages: UIMessage[] = [ { id: "r1", @@ -855,8 +1097,8 @@ describe("ThreadMessages", () => { const units = buildDisplayUnits(messages); - expect(units).toHaveLength(2); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]); + expect(units).toHaveLength(3); + expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[1]).toMatchObject({ type: "message", message: { @@ -864,16 +1106,17 @@ describe("ThreadMessages", () => { content: "Hong Kong is hot today.", }, }); + expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]); render(); const answer = screen.getByText("Hong Kong is hot today."); - const laterActivity = screen.getByRole("button", { name: /worked/i }); + const laterActivity = screen.getAllByRole("button", { name: /worked/i }).at(-1); expect(laterActivity).toBeTruthy(); - expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); - it("does not leave a completed web-search thought below the final answer", () => { + it("keeps completed web-search activity on both sides of an answer", () => { const messages: UIMessage[] = [ { id: "user", @@ -909,13 +1152,14 @@ describe("ThreadMessages", () => { render(); - const thought = screen.getByRole("button", { name: /worked/i }); + const activities = screen.getAllByRole("button", { name: /worked/i }); const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。"); - expect(thought).toBeTruthy(); - expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(activities).toHaveLength(2); + expect(activities[0].compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(answer.compareDocumentPosition(activities[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); - it("normalizes completed prior turns while the next user turn is streaming", () => { + it("preserves a completed prior turn's order while the next turn is streaming", () => { const messages: UIMessage[] = [ { id: "thought", @@ -949,18 +1193,20 @@ describe("ThreadMessages", () => { }, ]; - const units = buildDisplayUnits(messages, true); + const units = buildDisplayUnits(messages); - expect(units).toHaveLength(3); + expect(units).toHaveLength(4); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ "thought", - "web", ]); expect(units[1]).toMatchObject({ type: "message", message: { id: "answer" }, }); - expect(units[2]).toMatchObject({ + expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual([ + "web", + ]); + expect(units[3]).toMatchObject({ type: "message", message: { id: "next-user" }, }); @@ -1001,7 +1247,7 @@ describe("ThreadMessages", () => { }, ]; - const units = buildDisplayUnits(messages, true); + const units = buildDisplayUnits(messages); expect(units).toHaveLength(2); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ @@ -1091,7 +1337,7 @@ describe("ThreadMessages", () => { expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument(); }); - it("keeps all assistant answer slices outside activity with one action set", () => { + it("keeps answer slices on either side of activity in generation order", () => { const messages: UIMessage[] = [ { id: "early", @@ -1116,15 +1362,20 @@ describe("ThreadMessages", () => { ]; const units = buildDisplayUnits(messages); - expect(units).toHaveLength(2); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ + expect(units).toHaveLength(3); + expect(units[0]).toMatchObject({ + type: "message", + message: { id: "early", content: "starting…" }, + sourceMessageCount: 1, + }); + expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([ "t1", ]); - expect(units[0].sourceMessageCount).toBe(1); - expect(units[1]).toMatchObject({ + expect(units[1].sourceMessageCount).toBe(1); + expect(units[2]).toMatchObject({ type: "message", - message: { id: "early", content: "starting…\n\nfinal reply" }, - sourceMessageCount: 2, + message: { id: "late", content: "final reply" }, + sourceMessageCount: 1, }); render( @@ -1135,7 +1386,7 @@ describe("ThreadMessages", () => { />, ); - expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2); expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1); expect(screen.getByText("starting…").closest("[data-testid='activity-model-message']")).toBeNull(); expect(screen.getByText("final reply").closest("[data-testid='activity-model-message']")).toBeNull(); @@ -1165,25 +1416,30 @@ describe("ThreadMessages", () => { content: "", media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }], turnPhase: "answer", - isStreaming: true, + isStreaming: false, createdAt: 3, }, ]; const units = buildDisplayUnits(messages); - expect(units).toHaveLength(2); - expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ + expect(units).toHaveLength(3); + expect(units[0]).toMatchObject({ + type: "message", + message: { id: "early", content: "generated the file" }, + sourceMessageCount: 1, + }); + expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([ "t1", ]); - expect(units[1]).toMatchObject({ + expect(units[2]).toMatchObject({ type: "message", message: { - id: "early", - content: "generated the file", + id: "attachment", + content: "", media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }], }, - sourceMessageCount: 2, + sourceMessageCount: 1, }); render(); @@ -1230,7 +1486,7 @@ describe("ThreadMessages", () => { rerender(); - expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(2); + expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3); expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2); }); @@ -1417,7 +1673,8 @@ describe("ThreadMessages", () => { .filter(Boolean); expect(assistantFlags).toEqual([ - ["a1", true], + ["a1", false], + ["a2", true], ["a3", true], ]); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 3ac2c0880..85c4056f2 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import { useNanobotStream } from "@/hooks/useNanobotStream"; +import { normalizeActivityTimeline } from "@/lib/activity-timeline"; import type { StreamError } from "@/lib/nanobot-client"; import type { ConnectionStatus, @@ -1813,6 +1814,84 @@ describe("useNanobotStream", () => { expect(result.current.messages[2].reasoning).toBe("Second reasoning."); }); + it("preserves closed reasoning slices when the tool trace is unavailable", async () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-r8", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-r8", { + event: "reasoning_delta", + chat_id: "chat-r8", + text: "First reasoning.", + turn_id: "turn-r8", + turn_phase: "reasoning", + turn_seq: 1, + }); + fake.emit("chat-r8", { + event: "reasoning_end", + chat_id: "chat-r8", + turn_id: "turn-r8", + turn_phase: "reasoning", + turn_seq: 2, + }); + fake.emit("chat-r8", { + event: "reasoning_delta", + chat_id: "chat-r8", + text: "Second reasoning.", + turn_id: "turn-r8", + turn_phase: "reasoning", + turn_seq: 3, + }); + fake.emit("chat-r8", { + event: "reasoning_end", + chat_id: "chat-r8", + turn_id: "turn-r8", + turn_phase: "reasoning", + turn_seq: 4, + }); + fake.emit("chat-r8", { + event: "message", + chat_id: "chat-r8", + text: "Final answer.", + turn_id: "turn-r8", + turn_phase: "answer", + turn_seq: 5, + }); + fake.emit("chat-r8", { + event: "turn_end", + chat_id: "chat-r8", + turn_id: "turn-r8", + turn_phase: "complete", + turn_seq: 6, + }); + }); + + await flushStreamFrame(); + + expect(result.current.messages.map((message) => ({ + reasoning: message.reasoning, + content: message.content, + }))).toEqual([ + { reasoning: "First reasoning.", content: "" }, + { reasoning: "Second reasoning.", content: "Final answer." }, + ]); + + const units = normalizeActivityTimeline(result.current.messages); + expect(units).toHaveLength(2); + expect(units[0].type === "activity" ? units[0].messages.map((message) => ( + message.reasoning || message.traces?.[0] + )) : []).toEqual([ + "First reasoning.", + "Second reasoning.", + ]); + expect(units[1]).toMatchObject({ + type: "message", + message: { content: "Final answer." }, + }); + }); + it("keeps tool-call reasoning before the matching live tool trace", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-tool-reasoning", EMPTY_MESSAGES), { @@ -1903,7 +1982,7 @@ describe("useNanobotStream", () => { }); }); - it("prunes reasoning-only placeholders when a turn ends without an answer", () => { + it("preserves reasoning-only output when a turn ends without an answer", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-empty-thinking", EMPTY_MESSAGES), { wrapper: wrap(fake.client), @@ -1925,11 +2004,18 @@ describe("useNanobotStream", () => { }); }); - expect(result.current.messages).toHaveLength(0); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "", + reasoning: "thinking without final text", + reasoningStreaming: false, + isStreaming: false, + }); expect(result.current.isStreaming).toBe(false); }); - it("drops stale reasoning-only placeholders before sending the next user turn", () => { + it("keeps earlier reasoning before sending the next user turn", () => { const fake = fakeClient(); const initialMessages = [ { @@ -1950,12 +2036,16 @@ describe("useNanobotStream", () => { result.current.send("fine"); }); - expect(result.current.messages).toHaveLength(1); - expect(result.current.messages[0].role).toBe("user"); - expect(result.current.messages[0].content).toBe("fine"); - expect(result.current.messages[0].turnId).toEqual(expect.any(String)); - expect(result.current.messages[0].turnPhase).toBe("user"); - expect(result.current.messages[0].deliveryStatus).toBe("sending"); + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + reasoning: "leftover thinking", + }); + expect(result.current.messages[1].role).toBe("user"); + expect(result.current.messages[1].content).toBe("fine"); + expect(result.current.messages[1].turnId).toEqual(expect.any(String)); + expect(result.current.messages[1].turnPhase).toBe("user"); + expect(result.current.messages[1].deliveryStatus).toBe("sending"); }); it("returns the submitted turn identity used by the optimistic row and wire frame", () => {