fix(webui): preserve causal message order (NAN-29) (#5503)

This commit is contained in:
chengyongru
2026-08-24 15:08:22 +08:00
committed by GitHub
parent 7f288a49fc
commit 04974b7607
8 changed files with 651 additions and 204 deletions
+8 -26
View File
@@ -1813,11 +1813,16 @@ def replay_transcript_to_ui_messages(
break break
content = str(candidate.get("content") or "") content = str(candidate.get("content") or "")
has_answer = len(content) > 0 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 ( if (
candidate.get("reasoningStreaming") candidate.get("reasoningStreaming")
or candidate.get("reasoning") is not None or (
or has_answer candidate.get("isStreaming")
or candidate.get("isStreaming") and candidate.get("reasoning") is None
)
): ):
prev[i] = { prev[i] = {
**candidate, **candidate,
@@ -1827,15 +1832,6 @@ def replay_transcript_to_ui_messages(
**turn_fields, **turn_fields,
} }
return 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 break
segment = _ensure_activity_segment() segment = _ensure_activity_segment()
prev.append( prev.append(
@@ -1915,19 +1911,6 @@ def replay_transcript_to_ui_messages(
and not m.get("media") 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( def stamp_completion(
*, *,
latency_ms: int | None = None, latency_ms: int | None = None,
@@ -2442,7 +2425,6 @@ def replay_transcript_to_ui_messages(
for i, m in enumerate(messages): for i, m in enumerate(messages):
if m.get("isStreaming"): if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False} messages[i] = {**m, "isStreaming": False}
prune_reasoning_only()
lat = rec.get("latency_ms") lat = rec.get("latency_ms")
usage = rec.get("usage") usage = rec.get("usage")
sanitized_usage = ( sanitized_usage = (
+122
View File
@@ -409,6 +409,128 @@ def test_replay_canonical_completed_stream_records() -> None:
assert msgs[1]["latencyMs"] == 42 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: def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-usage" key = "websocket:t-usage"
@@ -26,12 +26,8 @@ interface ThreadMessagesProps {
export type DisplayUnit = TurnUnit; export type DisplayUnit = TurnUnit;
export function buildDisplayUnits( export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
messages: UIMessage[], return projectActivityTimeline(messages);
isStreaming = false,
activeTurnId: string | null = null,
): DisplayUnit[] {
return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
} }
export function assistantForkFlags(units: DisplayUnit[]): boolean[] { export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
@@ -69,8 +65,8 @@ export function ThreadMessages({
const { t } = useTranslation(); const { t } = useTranslation();
const messageListRef = useRef<HTMLDivElement>(null); const messageListRef = useRef<HTMLDivElement>(null);
const units = useMemo( const units = useMemo(
() => buildDisplayUnits(messages, isStreaming, activeTurnId), () => buildDisplayUnits(messages),
[activeTurnId, isStreaming, messages], [messages],
); );
const forkBoundaryAfterUnitIndex = useMemo( const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
+5 -5
View File
@@ -21,7 +21,6 @@ import {
isReasoningOnlyPlaceholder, isReasoningOnlyPlaceholder,
matchesTurn, matchesTurn,
mergeFileEdits, mergeFileEdits,
pruneReasoningOnlyPlaceholders,
replaceMessageAt, replaceMessageAt,
stampLastAssistantCompletion, stampLastAssistantCompletion,
stripCoveredFileEditToolHintsFromMessages, stripCoveredFileEditToolHintsFromMessages,
@@ -90,10 +89,12 @@ function attachReasoningChunk(
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure(); const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0; const hasAnswer = candidate.content.length > 0;
if (hasAnswer) break; 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 ( if (
candidate.reasoningStreaming candidate.reasoningStreaming
|| candidate.reasoning !== undefined || (candidate.isStreaming && candidate.reasoning === undefined)
|| candidate.isStreaming
) { ) {
const merged: UIMessage = { const merged: UIMessage = {
...candidate, ...candidate,
@@ -841,7 +842,6 @@ export function useNanobotStream(
const completedAt = Date.now(); const completedAt = Date.now();
setMessages((prev) => { setMessages((prev) => {
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
finalized = pruneReasoningOnlyPlaceholders(finalized);
const latencyMs = const latencyMs =
typeof ev.latency_ms === "number" && ev.latency_ms >= 0 typeof ev.latency_ms === "number" && ev.latency_ms >= 0
? Math.round(ev.latency_ms) ? Math.round(ev.latency_ms)
@@ -1185,7 +1185,7 @@ export function useNanobotStream(
} }
const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev; const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev;
return [ return [
...(sideChannel || continueActiveTurn ? base : pruneReasoningOnlyPlaceholders(base)), ...base,
{ {
id: userMessageId, id: userMessageId,
role: "user", role: "user",
+115 -100
View File
@@ -1,8 +1,7 @@
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
/** A completed turn has two surfaces: one activity container and one final /** A turn is projected into ordered answer messages and collapsible activity
* answer. Answer text is never inferred to be activity merely because a later * runs. Folding changes presentation only; it never changes semantic order. */
* tool event arrived; explicit reasoning/activity fields own that distinction. */
export type TurnUnit = export type TurnUnit =
| { | {
type: "activity"; type: "activity";
@@ -49,14 +48,14 @@ export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|| previous.turnId !== lastTurnId; || previous.turnId !== lastTurnId;
} }
/** /** Project gateway rows without changing their causal order.
* Project completed or replayed gateway rows into the stable shape:
* *
* user [one live/completed activity surface] [one final answer] * 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
* A provider may emit multiple answer segments around tool activity. They are * display class are combined: activity rows share a collapsible surface, and
* merged into the one final answer instead of being reclassified as reasoning; * adjacent answer slices share an answer bubble. A visible answer is always a
* only explicit reasoning, trace, and model-activity rows enter the fold. * hard boundary between activity surfaces; completed empty transport frames
* have no display semantics and therefore create no boundary.
*/ */
export function normalizeActivityTimeline( export function normalizeActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
@@ -73,42 +72,22 @@ export function normalizeActivityTimeline(
return; return;
} }
const ordered = orderMessagesByTurnSeq(turnMessages); const projected = projectOrderedTurn(
const activity: UIMessage[] = []; orderMessagesByTurnSeq(turnMessages),
const answers: UIMessage[] = []; activeTurnStartedAtMs,
let activitySourceMessageCount = 0; );
for (const message of ordered) { if (projected.length) {
if (isRawActivity(message)) { units.push(...projected);
activity.push(message); } else if (units.length) {
activitySourceMessageCount += 1; // A turn containing only completed transport placeholders has no
} else if (isAssistantAnswer(message)) { // display surface. Keep its source count on the preceding prompt so
if (message.reasoning?.trim() || message.reasoningStreaming) { // persisted fork-boundary offsets still map to a visible unit.
// The synthetic reasoning row and answer both come from one raw const lastIndex = units.length - 1;
// message, so account for that source on the answer unit only. const last = units[lastIndex];
activity.push(reasoningOnlyMessageFromAnswer(message)); units[lastIndex] = {
} ...last,
answers.push(stripInlineReasoning(message)); sourceMessageCount: last.sourceMessageCount + turnMessages.length,
} 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,
});
} }
turnMessages = []; turnMessages = [];
@@ -133,57 +112,22 @@ export function normalizeActivityTimeline(
return units; 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( export function projectActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
liveTurnId?: string | null,
): TurnUnit[] { ): TurnUnit[] {
if (liveTurnId === undefined) return normalizeActivityTimeline(messages); 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)),
];
} }
function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number { function projectOrderedTurn(
for (let index = messages.length - 1; index >= 0; index -= 1) { messages: UIMessage[],
const message = messages[index]; startedAtMs?: number,
if (message.role !== "user") continue; ): TurnUnit[] {
if (liveTurnId === null || message.turnId === liveTurnId) return index;
}
return -1;
}
function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
const units: TurnUnit[] = []; const units: TurnUnit[] = [];
const prompt = messages[0];
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
let activity: UIMessage[] = []; let activity: UIMessage[] = [];
let activitySourceMessageCount = 0; let activitySourceMessageCount = 0;
let answers: UIMessage[] = [];
if (prompt?.role === "user") { let answerSourceMessageCount = 0;
units.push({ type: "message", message: prompt, sourceMessageCount: 1 }); let leadingNoopSourceMessageCount = 0;
}
const flushActivity = () => { const flushActivity = () => {
if (!activity.length) return; if (!activity.length) return;
@@ -191,36 +135,87 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
type: "activity", type: "activity",
messages: activity, messages: activity,
sourceMessageCount: activitySourceMessageCount, sourceMessageCount: activitySourceMessageCount,
turnLatencyMs: activityTurnLatencyMs(activity, activity),
startedAtMs, startedAtMs,
}); });
activity = []; activity = [];
activitySourceMessageCount = 0; activitySourceMessageCount = 0;
}; };
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) { const flushAnswers = () => {
if (isRawActivity(message)) { 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); activity.push(message);
activitySourceMessageCount += sourceMessageCount + claimLeadingNoops();
};
const absorbDisplayNoop = () => {
if (activity.length) {
activitySourceMessageCount += 1; 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; continue;
} }
if (isAssistantAnswer(message)) { if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) { 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(); flushActivity();
units.push({ answerSourceMessageCount += claimLeadingNoops();
type: "message", answers.push(stripInlineReasoning(message));
message: stripInlineReasoning(message), answerSourceMessageCount += 1;
sourceMessageCount: 1,
});
continue; continue;
} }
activity.push(message); appendActivity(message, 1);
activitySourceMessageCount += 1;
} }
flushActivity(); 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; return units;
} }
@@ -228,6 +223,26 @@ function isRawActivity(message: UIMessage): boolean {
return isAgentActivityMember(message); 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 { function isAssistantAnswer(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") { if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") {
return false; return false;
-15
View File
@@ -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( export function stampLastAssistantCompletion(
prev: UIMessage[], prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">, completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">,
+297 -40
View File
@@ -238,6 +238,244 @@ describe("ThreadMessages", () => {
expect(screen.getByText(/working/i)).toBeInTheDocument(); 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<typeof buildDisplayUnits>) => 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(
<ThreadMessages messages={messages} isStreaming activeTurnId={turnId} />,
);
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(<ThreadMessages messages={messages} isStreaming={false} activeTurnId={null} />);
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(<ThreadMessages messages={messages} isStreaming={false} />);
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 () => { it("offers a follow-up action for text selected within one completed answer", async () => {
const onQuoteSelection = vi.fn(); const onQuoteSelection = vi.fn();
render( 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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -523,10 +761,9 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); 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([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1", "r1",
"t1",
]); ]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
@@ -535,6 +772,9 @@ describe("ThreadMessages", () => {
content: "Let me search the latest data.", 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", () => { 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( expect(
units[1].type === "activity" ? units[1].startedAtMs : undefined, 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([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0", "t0",
"t1",
]); ]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { id: "a1", content: "partial answer" }, message: { id: "a1", content: "partial answer" },
}); });
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual([
"t1",
]);
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
const answer = screen.getByText("partial answer"); const answer = screen.getByText("partial answer");
const liveActivity = screen.getByRole("button", { name: /working/i }); const liveActivity = screen.getByRole("button", { name: /working/i });
expect(answer.closest("[data-testid='activity-model-message']")).toBeNull(); 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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -855,8 +1097,8 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); 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[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
@@ -864,16 +1106,17 @@ describe("ThreadMessages", () => {
content: "Hong Kong is hot today.", content: "Hong Kong is hot today.",
}, },
}); });
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today."); 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).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[] = [ const messages: UIMessage[] = [
{ {
id: "user", id: "user",
@@ -909,13 +1152,14 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
const thought = screen.getByRole("button", { name: /worked/i }); const activities = screen.getAllByRole("button", { name: /worked/i });
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。"); const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy(); expect(activities).toHaveLength(2);
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); 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[] = [ const messages: UIMessage[] = [
{ {
id: "thought", 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([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought", "thought",
"web",
]); ]);
expect(units[1]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { id: "answer" }, 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", type: "message",
message: { id: "next-user" }, 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).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([ 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(); 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[] = [ const messages: UIMessage[] = [
{ {
id: "early", id: "early",
@@ -1116,15 +1362,20 @@ describe("ThreadMessages", () => {
]; ];
const units = buildDisplayUnits(messages); 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([ 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", "t1",
]); ]);
expect(units[0].sourceMessageCount).toBe(1); expect(units[1].sourceMessageCount).toBe(1);
expect(units[1]).toMatchObject({ expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { id: "early", content: "starting…\n\nfinal reply" }, message: { id: "late", content: "final reply" },
sourceMessageCount: 2, sourceMessageCount: 1,
}); });
render( 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.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
expect(screen.getByText("starting…").closest("[data-testid='activity-model-message']")).toBeNull(); expect(screen.getByText("starting…").closest("[data-testid='activity-model-message']")).toBeNull();
expect(screen.getByText("final reply").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: "", content: "",
media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }], media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }],
turnPhase: "answer", turnPhase: "answer",
isStreaming: true, isStreaming: false,
createdAt: 3, createdAt: 3,
}, },
]; ];
const units = buildDisplayUnits(messages); 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([ 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", "t1",
]); ]);
expect(units[1]).toMatchObject({ expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "early", id: "attachment",
content: "generated the file", content: "",
media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }], media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }],
}, },
sourceMessageCount: 2, sourceMessageCount: 1,
}); });
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
@@ -1230,7 +1486,7 @@ describe("ThreadMessages", () => {
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />); rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
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); expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
}); });
@@ -1417,7 +1673,8 @@ describe("ThreadMessages", () => {
.filter(Boolean); .filter(Boolean);
expect(assistantFlags).toEqual([ expect(assistantFlags).toEqual([
["a1", true], ["a1", false],
["a2", true],
["a3", true], ["a3", true],
]); ]);
}); });
+99 -9
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useNanobotStream } from "@/hooks/useNanobotStream";
import { normalizeActivityTimeline } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client"; import type { StreamError } from "@/lib/nanobot-client";
import type { import type {
ConnectionStatus, ConnectionStatus,
@@ -1813,6 +1814,84 @@ describe("useNanobotStream", () => {
expect(result.current.messages[2].reasoning).toBe("Second reasoning."); 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", () => { it("keeps tool-call reasoning before the matching live tool trace", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-reasoning", EMPTY_MESSAGES), { 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 fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-empty-thinking", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-empty-thinking", EMPTY_MESSAGES), {
wrapper: wrap(fake.client), 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); 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 fake = fakeClient();
const initialMessages = [ const initialMessages = [
{ {
@@ -1950,12 +2036,16 @@ describe("useNanobotStream", () => {
result.current.send("fine"); result.current.send("fine");
}); });
expect(result.current.messages).toHaveLength(1); expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].role).toBe("user"); expect(result.current.messages[0]).toMatchObject({
expect(result.current.messages[0].content).toBe("fine"); role: "assistant",
expect(result.current.messages[0].turnId).toEqual(expect.any(String)); reasoning: "leftover thinking",
expect(result.current.messages[0].turnPhase).toBe("user"); });
expect(result.current.messages[0].deliveryStatus).toBe("sending"); 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", () => { it("returns the submitted turn identity used by the optimistic row and wire frame", () => {