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";
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { isEmptyErrorTurn } from "@oh-my-pi/pi-coding-agent/session/messages";
type Turn = Pick<AssistantMessage, "stopReason" | "content">;
const turn = (stopReason: AssistantMessage["stopReason"], content: AssistantMessage["content"]): Turn => ({
stopReason,
content,
});
describe("isEmptyErrorTurn", () => {
it("flags a content-less provider-rejection turn (the wedge poison that replays on reload)", () => {
expect(isEmptyErrorTurn(turn("error", []))).toBe(true);
expect(isEmptyErrorTurn(turn("error", [{ type: "text", text: " " }]))).toBe(true);
});
it("keeps error turns that streamed real text or tool calls", () => {
expect(isEmptyErrorTurn(turn("error", [{ type: "text", text: "partial answer" }]))).toBe(false);
expect(isEmptyErrorTurn(turn("error", [{ type: "toolCall", id: "c1", name: "bash", arguments: {} }]))).toBe(
false,
);
});
it("never flags non-error turns, even when empty — only the rejection turn is dropped", () => {
expect(isEmptyErrorTurn(turn("stop", []))).toBe(false);
expect(isEmptyErrorTurn(turn("aborted", []))).toBe(false);
});
});