Merge PR #4457: fix(session): drop content-less provider-rejection turns from persisted history (@mattwilkinsonn)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user