fix(webui): preserve causal message order (NAN-29) (#5503)
This commit is contained in:
@@ -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<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 () => {
|
||||
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(<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();
|
||||
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(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
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(<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 今天开打了。");
|
||||
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(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
@@ -1230,7 +1486,7 @@ describe("ThreadMessages", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1417,7 +1673,8 @@ describe("ThreadMessages", () => {
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", true],
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user