fix(webui): keep answer text outside reasoning shell
This commit is contained in:
@@ -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