Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, BlockingExtensionUiRequest, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } from "@/lib/types";
import { normalizeCustomPanelLines } from "@/lib/ansi";
import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks } from "@/lib/message-display";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isAssistantTruncated, isMessageGroupAnchor, splitFinalAssistantBlocks } from "@/lib/message-display";
import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files";
import { buildQuotedSelection } from "@/lib/quoted-selection";
import { MessageView } from "./MessageView";
Expand Down Expand Up @@ -1103,7 +1103,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi

const finalAssistant = messages[finalAssistantIdx] as AssistantMessage;
const finalSplit = splitFinalAssistantBlocks(finalAssistant);
const finalAnswerMessage = finalSplit.answerBlocks.length > 0 || getAssistantErrorMessage(finalAssistant)
const finalAnswerMessage = finalSplit.answerBlocks.length > 0 || getAssistantErrorMessage(finalAssistant) || isAssistantTruncated(finalAssistant)
? withAssistantBlocks(finalAssistant, finalSplit.answerBlocks)
: null;

Expand Down
27 changes: 27 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,33 @@ test("renders a provider error when the assistant message has no content", () =>
assert.match(html, /<html>request forbidden<\/html>/);
});

test("renders a truncation notice for stopReason length", () => {
const html = renderMessage({
role: "assistant",
provider: "anthropic",
model: "claude-test",
content: [{ type: "thinking", thinking: "Long reasoning chain" }],
stopReason: "length",
});

assert.match(html, /role="alert"/);
assert.match(html, /output limit/i);
assert.match(html, /follow-up/i);
});

test("renders a truncation notice for thinking-only messages with stopReason length", () => {
const html = renderMessage({
role: "assistant",
provider: "anthropic",
model: "claude-test",
content: [],
stopReason: "length",
});

assert.match(html, /role="alert"/);
assert.match(html, /output limit/i);
});

test("renders partial assistant content before the provider error", () => {
const html = renderMessage({
role: "assistant",
Expand Down
26 changes: 24 additions & 2 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ThinkingIcon } from "./ThinkingIcon";
import { copyText } from "@/lib/clipboard";
import { useI18n } from "@/hooks/useI18n";
import { parseCompactionSummary } from "@/lib/compaction-summary";
import { getAssistantErrorMessage, getThinkingPreview, isEmptyThinkingBlock } from "@/lib/message-display";
import { getAssistantErrorMessage, getThinkingPreview, isAssistantTruncated, isEmptyThinkingBlock } from "@/lib/message-display";
import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch";
import { isEditToolName } from "@/lib/tool-names";
import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference";
Expand Down Expand Up @@ -629,6 +629,7 @@ function AssistantMessageView({
.filter(({ block }) => !isEmptyThinkingBlock(block, { isStreaming })), [message.content, isStreaming]);
const blocks = useMemo(() => blockItems.map(({ block }) => block), [blockItems]);
const providerError = getAssistantErrorMessage(message, { isStreaming });
const truncated = isAssistantTruncated(message, { isStreaming });
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const streamStartRef = useRef<number | null>(null);
Expand Down Expand Up @@ -746,7 +747,7 @@ function AssistantMessageView({
return () => clearInterval(id);
}, [isStreaming]);

if (blocks.length === 0 && !isStreaming && !providerError) return null;
if (blocks.length === 0 && !isStreaming && !providerError && !truncated) return null;

return (
<div
Expand Down Expand Up @@ -825,6 +826,27 @@ function AssistantMessageView({
</div>
)}

{truncated && (
<div
role="alert"
style={{
marginTop: blocks.length > 0 || providerError ? 8 : 0,
padding: "7px 10px",
border: "1px solid rgba(234,179,8,0.3)",
borderRadius: 6,
background: "rgba(234,179,8,0.07)",
color: "#ca8a04",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.5,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
{t("chat.truncatedByOutputLimit")}
</div>
)}

{writtenFiles && writtenFiles.length > 0 && (
<TurnWrittenFiles files={writtenFiles} onOpenFile={onOpenFile} />
)}
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export const enLocale: LocalePlugin = {
"chat.loadingSession": "Loading session...",
"chat.runningTool": "Running tool...",
"chat.generatingToolInput": "Generating parameters...",
"chat.truncatedByOutputLimit": "This response was cut off after reaching the model’s output limit. Send a follow-up to continue.",
"chat.runningNamedTool": "Running {name}...",
"chat.runningTools": "Running {names}...",
"chat.runningToolsMore": "Running {names} (+{count})...",
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.loadingSession": "正在加载会话...",
"chat.runningTool": "正在运行工具...",
"chat.generatingToolInput": "正在生成参数...",
"chat.truncatedByOutputLimit": "回复因达到模型输出长度上限而被截断。发送一条后续消息以继续。",
"chat.runningNamedTool": "正在运行 {name}...",
"chat.runningTools": "正在运行 {names}...",
"chat.runningToolsMore": "正在运行 {names}(另有 {count} 个)...",
Expand Down
1 change: 1 addition & 0 deletions lib/i18n/messages/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export const zhTWLocale: LocalePlugin = {
"chat.loadingSession": "正在載入工作階段...",
"chat.runningTool": "正在執行工具...",
"chat.generatingToolInput": "正在產生參數...",
"chat.truncatedByOutputLimit": "回覆因達到模型輸出長度上限而被截斷。傳送一則後續訊息以繼續。",
"chat.runningNamedTool": "正在執行 {name}...",
"chat.runningTools": "正在執行 {names}...",
"chat.runningToolsMore": "正在執行 {names}(另有 {count} 個)...",
Expand Down
15 changes: 15 additions & 0 deletions lib/message-display.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,18 @@ test("treats compaction summaries as turn anchors", async () => {
}), true);
assert.equal(isMessageGroupAnchor(assistant([])), false);
});

test("isAssistantTruncated is true only for a finished stopReason length", async () => {
const { isAssistantTruncated } = await loadSubject();

const truncated = {
...assistant([{ type: "thinking", thinking: " lengthy reasoning " }]),
stopReason: "length",
};
assert.equal(isAssistantTruncated(truncated), true);
// Still streaming: the final stopReason is not known yet.
assert.equal(isAssistantTruncated(truncated, { isStreaming: true }), false);
assert.equal(isAssistantTruncated({ ...assistant([]), stopReason: "stop" }), false);
assert.equal(isAssistantTruncated({ ...assistant([]), stopReason: "error", errorMessage: "oops" }), false);
assert.equal(isAssistantTruncated({ ...assistant([]) }), false);
});
12 changes: 12 additions & 0 deletions lib/message-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ export function getAssistantErrorMessage(
return message.errorMessage?.trim() || "Unknown provider error";
}

/**
* A turn that ended on `stopReason: "length"` spent its whole output budget
* (often on reasoning alone) and produced no final answer; without a notice it
* looks like a hung session. The copy lives in i18n (`chat.truncatedByOutputLimit`).
*/
export function isAssistantTruncated(
message: AssistantMessage,
options: DisplayOptions = {},
): boolean {
return !options.isStreaming && message.stopReason === "length";
}

function isFinalAnswerBlock(block: AssistantContentBlock): boolean {
return block.type === "text" || block.type === "image";
}
Expand Down
Loading