From e86a6ede247b0640064972d710ec8a0b29cb19eb Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Sun, 28 Jun 2026 13:39:02 -0400 Subject: [PATCH] fix(session): drop content-less provider-rejection turns from persisted history A request the provider rejects (e.g. 413 oversized payload) yields a synthesized assistant turn with empty content and stopReason 'error'. That turn is written to session.jsonl, so on reload it replays as an empty assistant turn and re-sends the same rejected context. Keep the rejection UI-only (pinned error) and out of persisted history so a reloaded session resumes from the last good turn. --- .../coding-agent/src/session/agent-session.ts | 2 ++ packages/coding-agent/src/session/messages.ts | 13 +++++++++ .../test/session/empty-error-turn.test.ts | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 packages/coding-agent/test/session/empty-error-turn.test.ts diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f271f21bf..e83e3a20e 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -338,6 +338,7 @@ import { demoteInterruptedThinking, INTERRUPTED_THINKING_MESSAGE_TYPE, type InterruptedThinkingDetails, + isEmptyErrorTurn, isUserInterruptAbort, type PythonExecutionMessage, readQueueChipText, @@ -3318,6 +3319,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), diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index f72c829f5..5b6999a3c 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -183,6 +183,19 @@ export function shouldRenderAbortReason(message: Pick): 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"; diff --git a/packages/coding-agent/test/session/empty-error-turn.test.ts b/packages/coding-agent/test/session/empty-error-turn.test.ts new file mode 100644 index 000000000..7d256bcc9 --- /dev/null +++ b/packages/coding-agent/test/session/empty-error-turn.test.ts @@ -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; + +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); + }); +});