Merge PR #4457: fix(session): drop content-less provider-rejection turns from persisted history (@mattwilkinsonn)

This commit is contained in:
can1357
2026-07-05 13:10:26 +02:00
3 changed files with 44 additions and 0 deletions
@@ -341,6 +341,7 @@ import {
demoteInterruptedThinking,
INTERRUPTED_THINKING_MESSAGE_TYPE,
type InterruptedThinkingDetails,
isEmptyErrorTurn,
isUserInterruptAbort,
type PythonExecutionMessage,
readQueueChipText,
@@ -3347,6 +3348,7 @@ export class AgentSession {
if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
if (this.#isClassifierRefusal(assistantMsg)) return;
if (isEmptyErrorTurn(assistantMsg)) return;
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
assistantMsg.contextSnapshot = {
promptTokens: calculatePromptTokens(assistantMsg.usage),
@@ -183,6 +183,19 @@ export function shouldRenderAbortReason(message: Pick<AssistantMessage, "errorId
return !isSilentAbort(message) && !isUserInterruptAbort(message);
}
/** A provider-rejection turn carrying nothing but the error flag: stopReason
* "error" with no text content and no tool calls — e.g. a request the provider
* rejected before any output (an oversized 413 payload). Persisting it writes an
* empty assistant turn that replays on reload and re-sends the rejected context;
* the error is surfaced live (pinned) instead. A turn that streamed partial text
* or tool calls is NOT empty and stays in history. */
export function isEmptyErrorTurn(message: Pick<AssistantMessage, "stopReason" | "content">): boolean {
if (message.stopReason !== "error") return false;
return !message.content.some(
block => (block.type === "text" && block.text.trim().length > 0) || block.type === "toolCall",
);
}
/** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
* reason (bare `abort()`). Renderers treat it as "no specific reason given". */
export const GENERIC_ABORT_SENTINEL = "Request was aborted";