diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2df457d33..597ad21bd 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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), 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); + }); +});