mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(webui): keep answer text outside reasoning shell
This commit is contained in:
+11
-16
@@ -1870,7 +1870,14 @@ def replay_transcript_to_ui_messages(
|
||||
return None
|
||||
return str(last.get("id"))
|
||||
|
||||
def demote_interrupted_assistant(segment: str) -> None:
|
||||
def close_interrupted_assistant() -> None:
|
||||
"""Close an answer segment before tool activity without changing its semantics.
|
||||
|
||||
The wire protocol already marks answer, reasoning, and activity phases.
|
||||
A later tool event does not turn previously emitted answer text into
|
||||
reasoning; preserving ``content`` also keeps live and replay projections
|
||||
equivalent.
|
||||
"""
|
||||
nonlocal buffer_message_id, buffer_parts
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
candidate = messages[i]
|
||||
@@ -1886,19 +1893,7 @@ def replay_transcript_to_ui_messages(
|
||||
or candidate.get("media")
|
||||
):
|
||||
continue
|
||||
reasoning_parts = [
|
||||
part
|
||||
for part in (candidate.get("reasoning"), content)
|
||||
if isinstance(part, str) and part.strip()
|
||||
]
|
||||
messages[i] = {
|
||||
**candidate,
|
||||
"content": "",
|
||||
"reasoning": "\n\n".join(reasoning_parts),
|
||||
"reasoningStreaming": False,
|
||||
"isStreaming": False,
|
||||
"activitySegmentId": candidate.get("activitySegmentId") or segment,
|
||||
}
|
||||
messages[i] = {**candidate, "isStreaming": False}
|
||||
if buffer_message_id == candidate.get("id"):
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
@@ -2069,7 +2064,7 @@ def replay_transcript_to_ui_messages(
|
||||
if not segment:
|
||||
segment = _new_activity_segment(activate=False)
|
||||
active_file_edit_segment_id = segment
|
||||
demote_interrupted_assistant(segment)
|
||||
close_interrupted_assistant()
|
||||
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
|
||||
target_index = find_file_edit_trace_index(segment, edits)
|
||||
if target_index is not None:
|
||||
@@ -2363,7 +2358,7 @@ def replay_transcript_to_ui_messages(
|
||||
if not trace_lines:
|
||||
continue
|
||||
segment = _ensure_activity_segment()
|
||||
demote_interrupted_assistant(segment)
|
||||
close_interrupted_assistant()
|
||||
last = messages[-1] if messages else None
|
||||
if (
|
||||
last
|
||||
|
||||
@@ -1485,7 +1485,7 @@ def test_replay_keeps_every_file_from_one_apply_patch_call() -> None:
|
||||
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
|
||||
|
||||
|
||||
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
||||
def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
||||
{"event": "stream_end", "chat_id": "t-stream"},
|
||||
@@ -1504,8 +1504,10 @@ def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
||||
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0]["role"] == "assistant"
|
||||
assert msgs[0]["content"] == ""
|
||||
assert msgs[0]["reasoning"] == "I will inspect first."
|
||||
assert msgs[0]["content"] == "I will inspect first."
|
||||
assert msgs[0]["turnPhase"] == "answer"
|
||||
assert "reasoning" not in msgs[0]
|
||||
assert "activitySegmentId" not in msgs[0]
|
||||
assert "isStreaming" not in msgs[0]
|
||||
assert msgs[1]["kind"] == "trace"
|
||||
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
||||
|
||||
@@ -355,11 +355,15 @@ function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null)
|
||||
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
||||
if (previous.type !== next.type) return false;
|
||||
if (previous.type === "message" && next.type === "message") {
|
||||
return shallowMessageEqual(previous.message, next.message);
|
||||
return (
|
||||
previous.sourceMessageCount === next.sourceMessageCount
|
||||
&& shallowMessageEqual(previous.message, next.message)
|
||||
);
|
||||
}
|
||||
if (previous.type !== "activity" || next.type !== "activity") return false;
|
||||
return (
|
||||
previous.turnLatencyMs === next.turnLatencyMs
|
||||
previous.sourceMessageCount === next.sourceMessageCount
|
||||
&& previous.turnLatencyMs === next.turnLatencyMs
|
||||
&& previous.startedAtMs === next.startedAtMs
|
||||
&& previous.messages.length === next.messages.length
|
||||
&& previous.messages.every((message, index) =>
|
||||
@@ -383,7 +387,7 @@ function unitIndexAfterMessageCount(
|
||||
let seen = 0;
|
||||
for (let i = 0; i < units.length; i += 1) {
|
||||
const unit = units[i];
|
||||
seen += unit.type === "activity" ? unit.messages.length : 1;
|
||||
seen += unit.sourceMessageCount;
|
||||
if (seen >= messageCount) return i;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
/** A completed turn has two surfaces: one activity container and one final
|
||||
* answer. An active turn temporarily preserves arrival order so visible
|
||||
* Markdown never moves when a later tool starts. */
|
||||
* answer. Answer text is never inferred to be activity merely because a later
|
||||
* tool event arrived; explicit reasoning/activity fields own that distinction. */
|
||||
export type TurnUnit =
|
||||
| {
|
||||
type: "activity";
|
||||
messages: UIMessage[];
|
||||
/** Number of raw UI messages represented by this display unit. */
|
||||
sourceMessageCount: number;
|
||||
turnLatencyMs?: number;
|
||||
startedAtMs?: number;
|
||||
}
|
||||
| { type: "message"; message: UIMessage };
|
||||
| {
|
||||
type: "message";
|
||||
message: UIMessage;
|
||||
/** Number of raw UI messages represented by this display unit. */
|
||||
sourceMessageCount: number;
|
||||
};
|
||||
|
||||
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
|
||||
if (
|
||||
message.activityKind === "model"
|
||||
|| message.content.trim().length > 0
|
||||
|| !!message.media?.length
|
||||
|| !!message.images?.length
|
||||
) return false;
|
||||
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
|
||||
}
|
||||
|
||||
@@ -42,10 +54,9 @@ export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|
||||
*
|
||||
* user → [one live/completed activity surface] → [one final answer]
|
||||
*
|
||||
* Assistant text that is followed by another activity is an intermediate model
|
||||
* segment. It remains in the activity timeline, where the renderer keeps its
|
||||
* normal Markdown surface, but it never creates a second answer bubble. This
|
||||
* is the same causal model used by Codex-style transcripts.
|
||||
* 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.
|
||||
*/
|
||||
export function normalizeActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
@@ -63,50 +74,41 @@ export function normalizeActivityTimeline(
|
||||
}
|
||||
|
||||
const ordered = orderMessagesByTurnSeq(turnMessages);
|
||||
const lastActivityIndex = ordered.reduce(
|
||||
(index, message, current) => isRawActivity(message) ? current : index,
|
||||
-1,
|
||||
);
|
||||
const answerIndices = ordered
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isAssistantAnswer(message));
|
||||
const finalAnswerIndex = answerIndices.at(-1)?.index;
|
||||
// A replay can deliver a completed answer before a late trace row. Keep
|
||||
// that answer visible, but place the late activity in the single activity
|
||||
// surface before it. An answer followed by more activity is an
|
||||
// intermediate model segment and stays in that surface in turn order.
|
||||
const hasFinalAnswer = finalAnswerIndex !== undefined
|
||||
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
|
||||
|
||||
const activity: UIMessage[] = [];
|
||||
const answers: UIMessage[] = [];
|
||||
ordered.forEach((message, index) => {
|
||||
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));
|
||||
}
|
||||
if (hasFinalAnswer && index === finalAnswerIndex) {
|
||||
answers.push(stripInlineReasoning(message));
|
||||
} else {
|
||||
activity.push(modelActivitySnippet(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) });
|
||||
units.push({
|
||||
type: "message",
|
||||
message: mergeAssistantAnswers(answers),
|
||||
sourceMessageCount: answers.length,
|
||||
});
|
||||
}
|
||||
|
||||
turnMessages = [];
|
||||
@@ -117,7 +119,7 @@ export function normalizeActivityTimeline(
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
flushTurn();
|
||||
units.push({ type: "message", message });
|
||||
units.push({ type: "message", message, sourceMessageCount: 1 });
|
||||
activeTurnId = message.turnId;
|
||||
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
|
||||
continue;
|
||||
@@ -132,15 +134,16 @@ export function normalizeActivityTimeline(
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
|
||||
* reliable way to know whether an assistant text segment is the final answer
|
||||
* or commentary before another tool call. Reclassifying it when that tool
|
||||
* arrives makes an already-visible Markdown tree jump between containers.
|
||||
* 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 final answer. While the turn is active, text and
|
||||
* contiguous activity phases stay in arrival order. A later tool therefore
|
||||
* appends a Working surface after existing Markdown instead of reparenting it.
|
||||
* 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[],
|
||||
@@ -176,23 +179,29 @@ function projectLiveTurn(messages: UIMessage[]): 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 });
|
||||
if (prompt?.role === "user") {
|
||||
units.push({ type: "message", message: prompt, sourceMessageCount: 1 });
|
||||
}
|
||||
|
||||
const flushActivity = () => {
|
||||
if (!activity.length) return;
|
||||
units.push({
|
||||
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);
|
||||
activitySourceMessageCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (isAssistantAnswer(message)) {
|
||||
@@ -200,10 +209,15 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
|
||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||
}
|
||||
flushActivity();
|
||||
units.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
units.push({
|
||||
type: "message",
|
||||
message: stripInlineReasoning(message),
|
||||
sourceMessageCount: 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
activity.push(message);
|
||||
activitySourceMessageCount += 1;
|
||||
}
|
||||
|
||||
flushActivity();
|
||||
@@ -215,7 +229,16 @@ function isRawActivity(message: UIMessage): boolean {
|
||||
}
|
||||
|
||||
function isAssistantAnswer(message: UIMessage): boolean {
|
||||
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
|
||||
if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") {
|
||||
return false;
|
||||
}
|
||||
if (message.turnPhase === "reasoning" || message.turnPhase === "activity") return false;
|
||||
return (
|
||||
message.turnPhase === "answer"
|
||||
|| message.content.trim().length > 0
|
||||
|| !!message.media?.length
|
||||
|| !!message.images?.length
|
||||
);
|
||||
}
|
||||
|
||||
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
|
||||
@@ -248,18 +271,6 @@ function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
|
||||
return merged;
|
||||
}
|
||||
|
||||
function modelActivitySnippet(message: UIMessage): UIMessage {
|
||||
return {
|
||||
...stripInlineReasoning(message),
|
||||
id: `${message.id}-activity`,
|
||||
activityKind: "model",
|
||||
turnPhase: "activity",
|
||||
// Keep the source stream state so the activity surface can render this
|
||||
// segment with the same Markdown streaming semantics as a normal answer.
|
||||
isStreaming: message.isStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
return {
|
||||
id: `${message.id}-reasoning`,
|
||||
|
||||
@@ -595,6 +595,168 @@
|
||||
"turnSeq": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "answer_tool_answer_preserves_answer_semantics",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"initial_messages": [
|
||||
{
|
||||
"id": "fixture-user-interleaved-answer",
|
||||
"role": "user",
|
||||
"content": "Inspect and summarize.",
|
||||
"turnId": "turn-interleaved-answer",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1,
|
||||
"createdAt": 1700000004000
|
||||
}
|
||||
],
|
||||
"live_events": [
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "First answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "First answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"kind": "tool_hint",
|
||||
"text": "read_file()",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "Final answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "Final answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"latency_ms": 12,
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 7
|
||||
}
|
||||
],
|
||||
"transcript": [
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "Inspect and summarize.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "user",
|
||||
"turn_seq": 1,
|
||||
"created_at_ms": 1700000004000
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "First answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 2
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "First answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 3
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"kind": "tool_hint",
|
||||
"text": "read_file()",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "activity",
|
||||
"turn_seq": 4
|
||||
},
|
||||
{
|
||||
"event": "delta",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "Final answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 5
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"text": "Final answer segment.",
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "answer",
|
||||
"turn_seq": 6
|
||||
},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "fixture-interleaved-answer",
|
||||
"latency_ms": 12,
|
||||
"turn_id": "turn-interleaved-answer",
|
||||
"turn_phase": "complete",
|
||||
"turn_seq": 7
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Inspect and summarize.",
|
||||
"turnId": "turn-interleaved-answer",
|
||||
"turnPhase": "user",
|
||||
"turnSeq": 1
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "First answer segment.",
|
||||
"turnId": "turn-interleaved-answer",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 3
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "read_file()",
|
||||
"kind": "trace",
|
||||
"traces": [
|
||||
"read_file()"
|
||||
],
|
||||
"activitySegmentId": "segment-1",
|
||||
"turnId": "turn-interleaved-answer",
|
||||
"turnPhase": "activity",
|
||||
"turnSeq": 4
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Final answer segment.",
|
||||
"latencyMs": 12,
|
||||
"turnId": "turn-interleaved-answer",
|
||||
"turnPhase": "answer",
|
||||
"turnSeq": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -334,18 +334,31 @@ describe("ThreadMessages", () => {
|
||||
it("renders a fork boundary divider after the copied history", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "original", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "branch prompt", createdAt: 3 },
|
||||
{ id: "a1", role: "assistant", content: "first answer", createdAt: 2 },
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "second answer", createdAt: 4 },
|
||||
{ id: "u2", role: "user", content: "branch prompt", createdAt: 5 },
|
||||
];
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<ThreadMessages
|
||||
messages={messages}
|
||||
forkBoundaryMessageCount={2}
|
||||
forkBoundaryMessageCount={4}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Forked from history")).toBeInTheDocument();
|
||||
const rows = Array.from(container.firstElementChild?.children ?? []);
|
||||
const dividerIndex = rows.findIndex((row) => row.textContent?.includes("Forked from history"));
|
||||
const branchPromptIndex = rows.findIndex((row) => row.textContent?.includes("branch prompt"));
|
||||
expect(dividerIndex).toBeGreaterThan(0);
|
||||
expect(dividerIndex).toBe(branchPromptIndex - 1);
|
||||
});
|
||||
|
||||
it("keeps turn unit keys stable across replayed ids and mutable turn sequence", () => {
|
||||
@@ -379,7 +392,6 @@ describe("ThreadMessages", () => {
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual([
|
||||
"turn-turn-1-user",
|
||||
"turn-turn-1-activity-1",
|
||||
"turn-turn-1-answer-1",
|
||||
]);
|
||||
});
|
||||
@@ -764,7 +776,7 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.queryByText("Worked for 3s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps late activity after the live assistant answer while streaming", () => {
|
||||
it("keeps a streamed answer outside late activity when the prompt snapshot is absent", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "t0",
|
||||
@@ -795,17 +807,21 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"t0",
|
||||
"a1-activity",
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "a1", content: "partial answer" },
|
||||
});
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1075,7 +1091,7 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("projects assistant slices into one answer with one action set", () => {
|
||||
it("keeps all assistant answer slices outside activity with one action set", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "early",
|
||||
@@ -1099,6 +1115,18 @@ describe("ThreadMessages", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"t1",
|
||||
]);
|
||||
expect(units[0].sourceMessageCount).toBe(1);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "early", content: "starting…\n\nfinal reply" },
|
||||
sourceMessageCount: 2,
|
||||
});
|
||||
|
||||
render(
|
||||
<ThreadMessages
|
||||
messages={messages}
|
||||
@@ -1109,8 +1137,57 @@ describe("ThreadMessages", () => {
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
|
||||
expect(screen.getByText("starting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
||||
expect(screen.getByText("starting…").closest("[data-testid='activity-model-message']")).toBeNull();
|
||||
expect(screen.getByText("final reply").closest("[data-testid='activity-model-message']")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a media-only answer slice outside the activity surface", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "early",
|
||||
role: "assistant",
|
||||
content: "generated the file",
|
||||
turnPhase: "answer",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "write_file()",
|
||||
traces: ["write_file()"],
|
||||
turnPhase: "activity",
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
id: "attachment",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }],
|
||||
turnPhase: "answer",
|
||||
isStreaming: true,
|
||||
createdAt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "early",
|
||||
content: "generated the file",
|
||||
media: [{ kind: "file", url: "/api/media/result.csv", name: "result.csv" }],
|
||||
},
|
||||
sourceMessageCount: 2,
|
||||
});
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getByText("result.csv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides current turn actions until turn_end", () => {
|
||||
@@ -1340,7 +1417,7 @@ describe("ThreadMessages", () => {
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a2", true],
|
||||
["a1", true],
|
||||
["a3", true],
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user