From bbb1c21ed399eaf2606b9feda917fdae6a30b39d Mon Sep 17 00:00:00 2001 From: Matthew Robert Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:09:36 -0400 Subject: [PATCH] fix(chat): say when the context limit cut an answer short Since #294 the backend records why the model stopped in the saved message's stats (stop_reason), and "length" means the runtime ran out of context mid-answer. The UI never read it, so a truncated answer ended mid-sentence and looked complete. The live "answer_truncated" status only reached the composer's status line, where the next event replaced it. An assistant message whose stats carry stop_reason "length" now shows a quiet note under the answer, beside the existing translation note: it was cut short by the context limit, and raising the Context window gives answers more room. The response announcer says "Response cut short by the context limit" instead of "Response complete", so screen reader users get the same information. How much history is reserved for the answer is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/features/chat/ChatPage.a11y.test.tsx | 12 ++++++++ .../src/features/chat/MessageCard.test.tsx | 30 +++++++++++++++++++ frontend/src/features/chat/MessageCard.tsx | 4 +++ .../src/features/chat/ResponseAnnouncer.tsx | 4 ++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/chat/ChatPage.a11y.test.tsx b/frontend/src/features/chat/ChatPage.a11y.test.tsx index cb83259..4f23b6f 100644 --- a/frontend/src/features/chat/ChatPage.a11y.test.tsx +++ b/frontend/src/features/chat/ChatPage.a11y.test.tsx @@ -338,6 +338,18 @@ describe("ChatPage response announcements", () => { await waitFor(() => expect(announcer()).toHaveTextContent(/^Response complete$/)); }); + it("says a response was cut short when the context limit ended it, and shows why under the answer", async () => { + const user = userEvent.setup(); + const { api, finishWith } = harness(); + renderChat(api); + await sendPrompt(user); + + await finishWith("generation.completed", { assistant_message_id: "assistant-1" }, savedAnswer({ eval_count: 900, stop_reason: "length" })); + + await waitFor(() => expect(announcer()).toHaveTextContent(/^Response cut short by the context limit$/)); + expect(await screen.findByText(/This answer was cut short by the context limit/)).toBeVisible(); + }); + it("says a response stopped after the person stopped it and the answer was kept", async () => { const user = userEvent.setup(); const { api, finishWith } = harness(); diff --git a/frontend/src/features/chat/MessageCard.test.tsx b/frontend/src/features/chat/MessageCard.test.tsx index c831981..5c06fc0 100644 --- a/frontend/src/features/chat/MessageCard.test.tsx +++ b/frontend/src/features/chat/MessageCard.test.tsx @@ -92,4 +92,34 @@ describe("MessageCard", () => { expect(screen.queryByRole("region", { name: "Cortex suggests remembering" })).toBeNull(); expect(screen.getByText("Noted.")).toBeInTheDocument(); }); + + it("notes under an answer the context limit cut short, and says what helps", () => { + renderCard({ + id: "assistant-1", + role: "assistant", + content: "The three steps are: first, open the", + stats: { eval_count: 512, tokens_per_second: 24.5, stop_reason: "length" }, + }); + + const note = screen.getByRole("note"); + expect(note).toHaveTextContent("This answer was cut short by the context limit."); + expect(note).toHaveTextContent("Raising the Context window gives answers more room."); + // The partial answer and its usage are still shown as they were. + expect(screen.getByText("The three steps are: first, open the")).toBeInTheDocument(); + expect(screen.getByText(/512 tok/)).toBeInTheDocument(); + }); + + it("still says why when the limit left only reasoning and no answer", () => { + renderCard({ id: "assistant-1", role: "assistant", content: "", thoughts: "Let me think about this carefully.", stats: { stop_reason: "length" } }); + + expect(screen.getByRole("note")).toHaveTextContent("cut short by the context limit"); + }); + + it("adds no note to an answer that finished on its own, one with no reason recorded, or a person's message", () => { + renderCard({ id: "assistant-1", role: "assistant", content: "Done.", stats: { eval_count: 3, tokens_per_second: 30, stop_reason: "stop" } }); + renderCard({ id: "assistant-2", role: "assistant", content: "Older answer.", stats: { eval_count: 3, tokens_per_second: 30 } }); + renderCard({ id: "user-1", role: "user", content: "Question?", stats: { stop_reason: "length" } }); + + expect(screen.queryByText(/cut short by the context limit/)).toBeNull(); + }); }); diff --git a/frontend/src/features/chat/MessageCard.tsx b/frontend/src/features/chat/MessageCard.tsx index 48dd3c7..8398a83 100644 --- a/frontend/src/features/chat/MessageCard.tsx +++ b/frontend/src/features/chat/MessageCard.tsx @@ -74,6 +74,10 @@ export const MessageCard = memo(function MessageCard({ message, index, isFinalAs
{message.role === "user" ?

{message.content}

: }
)} {message.role === "assistant" && untranslated &&

Couldn't translate this answer; showing the original.

} + {/* The runtime reports why the model stopped. "length" means it ran + into the context limit, and the answer ends wherever that fell: + without this it reads as though it finished. */} + {message.role === "assistant" && message.stats?.stop_reason === "length" &&

This answer was cut short by the context limit. Raising the Context window gives answers more room.

} {message.sources && message.sources.length > 0 &&
Sources{message.sources.length} {message.sources.length === 1 ? "item" : "items"}
typeof source === "string" ? source : JSON.stringify(source)).join("\n\n")} />
} {message.role === "assistant" && message.id && onSaveMemory && proposedMemories && proposedMemories.length > 0 && ( diff --git a/frontend/src/features/chat/ResponseAnnouncer.tsx b/frontend/src/features/chat/ResponseAnnouncer.tsx index fa147b1..14f1f46 100644 --- a/frontend/src/features/chat/ResponseAnnouncer.tsx +++ b/frontend/src/features/chat/ResponseAnnouncer.tsx @@ -4,12 +4,14 @@ import { useChatStore } from "../../stores/useChatStore"; /** * What to tell someone who cannot see the answer arrive, once it is saved: - * whether it finished or was stopped, and how long it was when that is known. + * whether it finished, was stopped, or was cut off by the context limit, and + * how long it was when that is known. */ function describeFinishedResponse(chat: ChatResponse | null): string { const lastAssistant = [...(chat?.messages ?? [])].reverse().find((message) => message.role === "assistant"); const stats = lastAssistant?.stats; if (stats?.stopped) return "Response stopped"; + if (stats?.stop_reason === "length") return "Response cut short by the context limit"; const tokens = stats?.eval_count; if (typeof tokens === "number" && tokens > 0) { return `Response complete, ${tokens.toLocaleString()} ${tokens === 1 ? "token" : "tokens"}`;