Merge pull request #8493 from usr-bin-roygbiv/fix/gemini-malformed-tool-retry

fix(gemini): recover malformed, empty, and broken Markdown turns
This commit is contained in:
Can Bölük
2026-08-14 05:14:45 +02:00
committed by GitHub
19 changed files with 732 additions and 141 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Distinguished Gemini thought-only `STOP` responses from empty transports, avoiding repeated identical reasoning requests and duplicate Antigravity endpoint streams while surfacing the missing final output for session-level recovery.
## [17.3.2] - 2026-08-13
### Fixed
+11 -1
View File
@@ -24,6 +24,7 @@ export const Flag = {
StaleResponsesItem: 0x0010_0000,
MalformedFunctionCall: 0x0020_0000,
ProviderFinishError: 0x0040_0000,
EmptyResponse: 0x0000_2000,
ContentBlocked: 0x0000_8000,
/** Account-scoped provider policy denial that may succeed with another credential. */
AccountPolicy: 0x0000_4000,
@@ -50,6 +51,7 @@ const KIND_MASK =
Flag.StaleResponsesItem |
Flag.MalformedFunctionCall |
Flag.ProviderFinishError |
Flag.EmptyResponse |
Flag.ContentBlocked |
Flag.AccountPolicy |
Flag.ContextOverflow |
@@ -62,7 +64,12 @@ const KIND_MASK =
Flag.OAuthExpiry;
const RETRIABLE_KINDS =
Flag.Transient | Flag.UsageLimit | Flag.ThinkingLoop | Flag.StaleResponsesItem | Flag.ProviderFinishError;
Flag.Transient |
Flag.UsageLimit |
Flag.ThinkingLoop |
Flag.StaleResponsesItem |
Flag.ProviderFinishError |
Flag.EmptyResponse;
const OVERFLOW_PATTERNS = [
/prompt is too long/i, // Anthropic
@@ -104,6 +111,7 @@ const AUTH_FAILURE_PATTERN =
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
const EMPTY_RESPONSE_PATTERN = /\bthought-only response without final output\b/i;
const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
@@ -197,6 +205,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
[Flag.StaleResponsesItem, "stale-responses-item"],
[Flag.MalformedFunctionCall, "malformed-function-call"],
[Flag.ProviderFinishError, "provider-finish-error"],
[Flag.EmptyResponse, "empty-response"],
[Flag.ContentBlocked, "content-blocked"],
[Flag.AccountPolicy, "account-policy"],
[Flag.ContextOverflow, "context-overflow"],
@@ -340,6 +349,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
if (ACCOUNT_POLICY_PATTERN.test(errorMessage)) kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
+6 -5
View File
@@ -9,6 +9,8 @@ export type ProviderResponseErrorKind =
| "output"
/** Response body was empty/missing when content was required. */
| "empty-body"
/** Response completed without actionable output (for example, thoughts only). */
| "empty-output"
/** Malformed wire envelope (unexpected message ordering / shape). */
| "envelope"
/** Content was blocked by a provider safety filter. */
@@ -38,11 +40,10 @@ export class ProviderResponseError extends Error {
this.kind = options.kind ?? "output";
// A safety filter block is terminal and intentionally non-retryable.
if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
// An incomplete stream (connection dropped / truncated before any terminal
// event) or an empty body never produced any content — the request didn't
// complete, so it is safe to retry and eligible for model fallback. The
// retry layer's replay-unsafe guard still blocks a retry when partial tool
// output was already emitted.
// A logically empty completed output needs a session-level reminder that
// asks for the missing final answer. Empty bodies and incomplete streams
// stay on the generic transient retry/model-fallback path.
else if (this.kind === "empty-output") attach(this, create(Flag.Transient, Flag.EmptyResponse));
else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
}
}
+32 -18
View File
@@ -622,11 +622,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const isFlashLeakModel = model.id.includes("flash");
let started = false;
// Tracks whether *visible* content (text delta or tool call) has been
// pushed downstream. `started` alone is a poor failover guard because a
// hidden thought part also flips it (via `ensureStarted`); a thinking-only
// STOP must still fail over to the alternate Antigravity endpoint (#8480).
let emittedVisibleContent = false;
// Once any stream event starts, the endpoint is committed downstream.
// Failover remains safe only while `started` is false.
let sawFinishReason = false;
let lastResponseId: string | undefined;
const ensureStarted = () => {
@@ -705,7 +702,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const emitVisibleText = (delta: string, thoughtSignature?: string): void => {
if (!delta) return;
emittedVisibleContent = true;
const block = startTextBlock();
block.text += delta;
block.textSignature = retainThoughtSignature(block.textSignature, thoughtSignature);
@@ -864,7 +860,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
};
output.content.push(toolCall);
emittedVisibleContent = true;
ensureStarted();
pushToolCallEvents(toolCall, blockIndex(), output, stream);
}
@@ -932,13 +927,17 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
};
let receivedContent = false;
const hasThinkingOutput = () =>
output.content.some(
block =>
block.type === "thinking" && (block.thinking.trim().length > 0 || Boolean(block.thinkingSignature)),
);
for (let i = 0; i < endpoints.length; i++) {
const endpoint = endpoints[i];
const isLastEndpoint = i === endpoints.length - 1;
try {
started = false;
emittedVisibleContent = false;
resetOutput();
// Per attempt: arm a pre-response (TTFT) timer, cleared the instant
@@ -1022,17 +1021,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
}
const streamed = await streamResponse(currentResponse);
// Only accept an empty STOP as valid silence once every fallback
// endpoint is exhausted: an earlier endpoint returning empty
// successful streams must still fail over (Antigravity auto mode)
// rather than be recorded as a real silent review.
// Eventless silence may fail over to the alternate Antigravity
// endpoint. Once thinking has streamed, the endpoint is already
// committed downstream; Advisor mode may accept that silence,
// while normal sessions surface it to final-output recovery.
const thoughtOnly = hasThinkingOutput();
const acceptedSilence =
options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint;
options?.acceptEmptyResponse === true &&
!streamed.strippedPlanningLeak &&
(isLastEndpoint || thoughtOnly);
if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) {
receivedContent = streamed.meaningful || acceptedSilence;
break;
}
// A thought-only STOP is a complete provider response, not a
// transiently empty transport. Replaying the identical request
// burns another full reasoning pass; let session recovery add
// an explicit final-output reminder instead.
if (thoughtOnly) break;
if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) {
resetOutput();
}
@@ -1046,10 +1054,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
}
if (!receivedContent) {
throw new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", {
provider: model.provider,
kind: "empty-body",
});
const thoughtOnly = hasThinkingOutput();
throw new AIError.ProviderResponseError(
thoughtOnly
? "Cloud Code Assist API returned a thought-only response without final output"
: "Cloud Code Assist API returned an empty response",
{
provider: model.provider,
kind: thoughtOnly ? "empty-output" : "empty-body",
},
);
}
if (options?.signal?.aborted) {
@@ -1081,7 +1095,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const status = extractHttpStatusFromError(error);
if (
!isLastEndpoint &&
!emittedVisibleContent &&
!started &&
(AIError.isTransientStatus(status) ||
(status === undefined &&
!(error instanceof AIError.ProviderResponseError && error.kind === "output") &&
+18 -6
View File
@@ -88,16 +88,28 @@ describe("AIError.classify — structural provider errors", () => {
expect(AIError.retriable(id)).toBe(true);
});
it("classifies an empty provider response as transient + retryable", () => {
// Regression: "Cloud Code Assist API returned an empty response" matched no
// text pattern and empty-body carried no flag, so retry/model-fallback
// chains never engaged and the turn hard-failed.
const err = new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", {
provider: "google-antigravity",
it("keeps empty response bodies on the generic transient fallback path", () => {
const err = new AIError.ProviderResponseError("Google API returned an empty response body", {
provider: "google",
kind: "empty-body",
});
const id = AIError.classify(err);
expect(AIError.is(id, AIError.Flag.Transient)).toBe(true);
expect(AIError.is(id, AIError.Flag.EmptyResponse)).toBe(false);
expect(AIError.retriable(id)).toBe(true);
});
it("classifies thought-only output as transient + empty-response + retryable", () => {
const err = new AIError.ProviderResponseError(
"Cloud Code Assist API returned a thought-only response without final output",
{
provider: "google-antigravity",
kind: "empty-output",
},
);
const id = AIError.classify(err);
expect(AIError.is(id, AIError.Flag.Transient)).toBe(true);
expect(AIError.is(id, AIError.Flag.EmptyResponse)).toBe(true);
expect(AIError.retriable(id)).toBe(true);
});
@@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { streamGoogle } from "@oh-my-pi/pi-ai/providers/google";
import { streamGoogleGeminiCli } from "@oh-my-pi/pi-ai/providers/google-gemini-cli";
import { streamGoogleVertex } from "@oh-my-pi/pi-ai/providers/google-vertex";
@@ -291,6 +292,33 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => {
void events;
});
it("surfaces thought-only STOP immediately for session-level final-output recovery", async () => {
let calls = 0;
const fetchMock: FetchImpl = async () => {
calls += 1;
const response = sse(ccaThinkingOnlyChunk("The task is complete, but I omitted the final answer."));
Object.defineProperty(response, "url", { value: "https://example.com/v1internal:streamGenerateContent" });
return response;
};
const stream = streamGoogleGeminiCli(cliModel, context, {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
fetch: fetchMock,
});
const result = await stream.result();
expect(calls).toBe(1);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("thought-only response without final output");
expect(AIError.is(result.errorId, AIError.Flag.EmptyResponse)).toBe(true);
expect(result.content).toEqual([
expect.objectContaining({
type: "thinking",
thinking: "The task is complete, but I omitted the final answer.",
}),
]);
});
it("accepts an empty STOP when silence is a valid caller result", async () => {
let calls = 0;
const fetchMock: FetchImpl = async () => {
@@ -410,7 +438,7 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => {
expect(result.errorMessage).toBeUndefined();
});
it("fails over before accepting Advisor silence when daily returns a thinking-only STOP", async () => {
it("accepts Advisor silence without failover after thought events start", async () => {
const requestedEndpoints: string[] = [];
const fetchMock: FetchImpl = async input => {
const endpoint = endpointFromInput(input);
@@ -428,52 +456,52 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => {
acceptEmptyResponse: true,
fetch: fetchMock,
});
const { events, starts } = await drain(stream);
const result = await stream.result();
expect({
requestedEndpoints,
stopReason: result.stopReason,
errorMessage: result.errorMessage,
text: textOf(result),
}).toEqual({
requestedEndpoints: [
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_SANDBOX_ENDPOINT,
],
stopReason: "stop",
errorMessage: undefined,
text: "Recovered.",
});
expect(requestedEndpoints).toEqual([ANTIGRAVITY_DAILY_ENDPOINT]);
expect(starts).toBe(1);
expect(events.filter(event => event.type === "thinking_start")).toHaveLength(1);
expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(1);
expect(events.filter(event => event.type === "thinking_end")).toHaveLength(1);
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
expect(textOf(result)).toBe("");
});
it("accepts thinking-only silence on the final endpoint when both endpoints stay silent", async () => {
it("does not fail over a thought-only error after stream events start", async () => {
const requestedEndpoints: string[] = [];
const fetchMock: FetchImpl = async input => {
const endpoint = endpointFromInput(input);
requestedEndpoints.push(endpoint);
return withResponseUrl(sse(ccaThinkingOnlyChunk("Nothing to add. Staying silent.")), endpoint);
const response =
endpoint === ANTIGRAVITY_SANDBOX_ENDPOINT
? sse(ccaChunk("Recovered."))
: sse(ccaThinkingOnlyChunk("I reasoned but omitted the final answer."));
return withResponseUrl(response, endpoint);
};
const stream = streamGoogleGeminiCli(antigravityModel, context, {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
antigravityEndpointMode: "auto",
acceptEmptyResponse: true,
fetch: fetchMock,
});
const { events, starts } = await drain(stream);
const result = await stream.result();
// Daily burns its empty budget and fails over; the sandbox (final) endpoint
// records the thinking-only STOP as valid Advisor silence.
expect(requestedEndpoints).toEqual([
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_SANDBOX_ENDPOINT,
expect(requestedEndpoints).toEqual([ANTIGRAVITY_DAILY_ENDPOINT]);
expect(starts).toBe(1);
expect(events.filter(event => event.type === "thinking_start")).toHaveLength(1);
expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(1);
expect(events.filter(event => event.type === "thinking_end")).toHaveLength(1);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("thought-only response without final output");
expect(result.content).toEqual([
expect.objectContaining({
type: "thinking",
thinking: "I reasoned but omitted the final answer.",
}),
]);
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
});
for (const { mode, endpoint } of [
+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
### Fixed
- Automatically continued Gemini turns that stopped after thinking without final output, using a bounded final-answer reminder instead of exhausting generic retries.
- Retried Gemini `MALFORMED_FUNCTION_CALL` failures when every emitted tool call was proven unexecuted, while preserving real tool-result and visible-output replay guards.
- Kept current terminal retry errors in one pinned banner with attempt context while surfacing local continuation failures instead of stale provider errors.
## [17.3.2] - 2026-08-13
### Fixed
@@ -141,6 +141,8 @@ export class EventController {
// restored when the banner clears at the next `agent_start` (see
// #handleMessageEnd / #handleAgentStart).
#pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
#pinnedErrorMessage: AssistantMessage | undefined = undefined;
#restorePinnedErrorInline = true;
#retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
#retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
// Set when `auto_retry_start` fires and cleared by `auto_retry_end` (both
@@ -650,6 +652,8 @@ export class EventController {
this.#readToolCallAssistantComponents.clear();
this.#lastAssistantComponent = undefined;
this.#pinnedErrorComponent = undefined;
this.#pinnedErrorMessage = undefined;
this.#restorePinnedErrorInline = true;
this.#retryPending = this.ctx.viewSession.isRetrying;
this.#cancelIdleCompaction();
this.#cancelIdleRecap();
@@ -743,10 +747,13 @@ export class EventController {
this.#resetReadGroup();
this.#resolveDisplaceableTodo();
this.#lastAssistantComponent = undefined;
// Restore the previous turn's inline error in the transcript before dropping
// the banner, so the error stays in history once the banner is gone.
this.#pinnedErrorComponent?.setErrorPinned(false);
// Restore terminal errors in transcript history when their banner clears.
// Recoverable empty-output attempts are discarded by session recovery and
// must stay hidden rather than resurfacing as a stale inline error.
if (this.#restorePinnedErrorInline) this.#pinnedErrorComponent?.setErrorPinned(false);
this.#pinnedErrorComponent = undefined;
this.#pinnedErrorMessage = undefined;
this.#restorePinnedErrorInline = true;
this.ctx.clearPinnedError();
if (this.ctx.retryLoader) {
this.ctx.retryLoader.stop();
@@ -1320,14 +1327,20 @@ export class EventController {
}
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
// Pin a turn-ending provider error (e.g. Anthropic content-filter block)
// above the editor so it survives transcript scroll. Cleared at the next
// turn's agent_start. Suppress the transcript's inline `Error: …` line for
// the same message while pinned so the error isn't rendered twice.
// Pin a turn-ending provider error above the editor so it survives
// transcript scroll and suppress its duplicate inline row. Empty-output
// errors are known intermediate attempts: hide them entirely while
// session recovery continues, but retain the component so a terminal
// retry-cap event can promote its final error into the one banner.
if (event.message.stopReason === "error" && event.message.errorMessage && !isSilentAbort(event.message)) {
const recoverableEmptyOutput =
!event.message.errorMessage.startsWith("Retry budget exhausted") &&
AIError.is(AIError.classifyMessage(event.message), AIError.Flag.EmptyResponse);
this.#lastAssistantComponent?.setErrorPinned(true);
this.#pinnedErrorComponent = this.#lastAssistantComponent;
this.ctx.showPinnedError(event.message.errorMessage);
this.#pinnedErrorMessage = event.message;
this.#restorePinnedErrorInline = !recoverableEmptyOutput;
if (!recoverableEmptyOutput) this.ctx.showPinnedError(event.message.errorMessage);
}
this.ctx.statusLine.invalidate();
this.ctx.ui.requestRender();
@@ -1947,6 +1960,8 @@ export class EventController {
// restore its inline Error row; just unpin the fixed-region banner so the
// retry UI is the visible state.
this.#pinnedErrorComponent = undefined;
this.#pinnedErrorMessage = undefined;
this.#restorePinnedErrorInline = true;
this.ctx.clearPinnedError();
}
const delaySeconds = Math.round(event.delayMs / 1000);
@@ -1968,20 +1983,51 @@ export class EventController {
this.ctx.retryLoader = undefined;
this.ctx.statusContainer.disposeChildren();
}
const pinnedError = this.#pinnedErrorMessage?.errorMessage;
const terminalFailurePinned =
!event.success &&
this.#pinnedErrorComponent !== undefined &&
pinnedError !== undefined &&
pinnedError === event.finalError;
let stalePinnedErrorCleared = false;
if (!event.success && this.#pinnedErrorComponent && !terminalFailurePinned) {
this.#pinnedErrorComponent.setErrorPinned(false);
this.#pinnedErrorComponent = undefined;
this.#pinnedErrorMessage = undefined;
this.#restorePinnedErrorInline = true;
this.ctx.clearPinnedError();
stalePinnedErrorCleared = true;
}
let appliedRetryUpdate = false;
for (const retryError of event.retryErrors ?? []) {
const component = this.#takeRetrySupersededAssistantComponent(retryError.persistenceKey);
if (!component) continue;
component.applyRetryRecovery(retryError.retryRecovery);
if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
if (!terminalFailurePinned && this.#pinnedErrorComponent === component) {
this.#pinnedErrorComponent = undefined;
this.#pinnedErrorMessage = undefined;
this.#restorePinnedErrorInline = true;
}
appliedRetryUpdate = true;
}
if (appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0) {
if (
!terminalFailurePinned &&
!stalePinnedErrorCleared &&
(appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0)
) {
this.ctx.clearPinnedError();
}
this.#clearRetrySupersededAssistantComponents();
if (!event.success) {
this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
if (terminalFailurePinned) {
const terminalError = this.#restorePinnedErrorInline
? `Retry failed after ${event.attempt} attempts: ${event.finalError || pinnedError || "Unknown error"}`
: (pinnedError ?? event.finalError);
if (terminalError) this.ctx.showPinnedError(terminalError);
this.#restorePinnedErrorInline = true;
} else {
this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
}
}
this.#ensureWorkingLoaderWhileStreaming();
this.ctx.ui.requestRender();
@@ -1,4 +1,4 @@
<system-injection>
Stopped; task incomplete. Continue.
Stopped without actionable output; task incomplete. Continue with a user-visible final answer or the next required tool call.
Attempt #{{retryCount}}/{{maxRetries}}
</system-injection>
@@ -2854,11 +2854,18 @@ export class AgentSession {
// tool_result and corrupts message history. The handler also
// schedules its own retry, so a real empty stop never needs the
// active-goal threshold pre-empt below.
if (await this.#recovery.handleEmptyAssistantStop(msg)) {
const emptyOutputRecovery = await this.#recovery.handleEmptyAssistantStop(msg);
if (emptyOutputRecovery === "continue") {
maintenanceRoute("empty-stop-handled");
await emitAgentEndNotification({ willContinue: true });
return;
}
if (emptyOutputRecovery === "terminal") {
// The cap already closed retry state and made provider-empty errors
// non-retryable. Continue through terminal maintenance so session_stop
// hooks and queued follow-up handling retain their normal contract.
maintenanceRoute("empty-stop-retry-cap");
}
// Record quota exhaustion before deciding whether this failed turn may be
// replayed. Visible/side-effecting output then remains terminal while its
@@ -395,7 +395,7 @@ export class TurnRecovery {
}
/** Handles empty terminal assistant turns and schedules bounded recovery. */
handleEmptyAssistantStop(message: AssistantMessage): Promise<boolean> {
handleEmptyAssistantStop(message: AssistantMessage): Promise<"continue" | "terminal" | undefined> {
return this.#handleEmptyAssistantStop(message);
}
@@ -648,24 +648,37 @@ export class TurnRecovery {
return retryErrors;
}
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
if (!isEmptyAssistantStop(assistantMessage)) {
#isRecoverableProviderEmptyOutput(message: AssistantMessage): boolean {
if (message.stopReason !== "error") return false;
const id = this.#classifyRetryMessage(message);
if (!AIError.is(id, AIError.Flag.EmptyResponse)) return false;
return message.content.every(
block => block.type === "thinking" || (block.type === "text" && !hasNonWhitespace(block.text)),
);
}
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<"continue" | "terminal" | undefined> {
const providerEmptyOutput = this.#isRecoverableProviderEmptyOutput(assistantMessage);
if (!isEmptyAssistantStop(assistantMessage) && !providerEmptyOutput) {
this.#emptyStopRetryCount = 0;
return false;
return undefined;
}
if (this.#acceptTerminalEmptyStopForPrompt && assistantMessage.stopReason === "stop") {
this.#acceptTerminalEmptyStopForPrompt = false;
this.#discardAcceptedTerminalEmptyStop(assistantMessage);
this.#emptyStopRetryCount = 0;
return false;
return undefined;
}
this.#emptyStopRetryCount++;
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
const attempts = this.#emptyStopRetryCount - 1;
const finalError =
"Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
const finalError = providerEmptyOutput
? "Assistant returned no final output after retry cap; try switching models"
: "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
assistantMessage.errorMessage = finalError;
if (providerEmptyOutput) assistantMessage.errorId = AIError.create();
logger.warn(finalError, {
attempts,
model: assistantMessage.model,
@@ -680,12 +693,12 @@ export class TurnRecovery {
this.#clearPendingRetryErrors();
this.#retryAttempt = 0;
this.resolveRetry();
// A zero-content turn carries no transcript value, while its provider usage
// can anchor the next prompt at the full failed-request size and re-trigger
// compaction at the same boundary. Remove every capped empty stop; toolUse
// orphans still need this for Anthropic message-history validity.
// A turn with no actionable output carries no transcript value, while its
// provider usage can anchor the next prompt at the full failed-request size
// and re-trigger compaction at the same boundary. Remove every capped
// empty output; toolUse orphans still need this for Anthropic history.
await this.dropPersistedAssistantTurn(assistantMessage);
return false;
return "terminal";
}
this.discardAssistantTurn(assistantMessage);
this.#host.agent.appendMessage({
@@ -695,7 +708,7 @@ export class TurnRecovery {
timestamp: Date.now(),
});
this.#host.scheduleAgentContinue({ generation: this.#host.promptGeneration() });
return true;
return "continue";
}
#emptyStopRetryReminder(): string {
@@ -1019,52 +1032,39 @@ export class TurnRecovery {
if (this.#isUsagePreflightBlocked(message)) return false;
const id = this.#classifyRetryMessage(message);
// Context overflow is handled by compaction, not retry
// Context overflow is handled by compaction, not retry.
const contextWindow = this.#host.model()?.contextWindow ?? 0;
if (AIError.isContextOverflow(message, contextWindow)) return false;
// Credential rotation and classifier fallbacks are safe only before
// committed text, images, tool calls, or server tools. Thinking-only
// output remains replay-safe. The one exception is a refusal whose ONLY
// replay-unsafe output is tool calls the agent loop proved never ran
// (`#refusalReplaySafe`): nothing reached the user and no side effect
// happened, so discarding the turn duplicates nothing and the fallback
// chain gets its chance.
if (this.#hasReplayUnsafeOutput(message) && !this.#refusalReplaySafe(message)) return false;
// output remains replay-safe. A classifier refusal or malformed-function
// response may also be replayed when every emitted tool call is paired
// with positive proof that it never executed.
const replaySafeUnexecutedTools =
(this.isClassifierRefusal(message) || AIError.is(id, AIError.Flag.MalformedFunctionCall)) &&
this.#unexecutedToolCallsReplaySafe(message);
if (this.#hasReplayUnsafeOutput(message) && !replaySafeUnexecutedTools) return false;
if (AIError.is(id, AIError.Flag.AccountPolicy) || this.isClassifierRefusal(message)) return true;
return AIError.retriable(id);
}
/**
* True when a classifier refusal is replay-safe *despite* having emitted tool
* calls, because every emitted call provably never executed.
* True when every emitted tool call provably never executed and no other
* replay-unsafe output exists. The caller restricts this exception to
* classifier refusals and malformed-function responses.
*
* Anthropic's request classifier can fire after the model has already streamed
* a tool call, which used to strand the turn: `#hasReplayUnsafeOutput` sees the
* `toolCall` block and vetoes retry one line before the refusal could reach the
* fallback-chain consult, so a refusal that a different model family would very
* likely have served just ended the turn.
* Gemini can report `MALFORMED_FUNCTION_CALL` after streaming an earlier,
* well-formed call. Anthropic classifiers can likewise refuse after a call.
* The agent loop pairs each emitted-but-unrun call with a synthetic
* `executed: false` result, which proves `tool.execute()` never ran.
*
* That veto exists to protect against duplicating work or visible output. Neither
* risk is present here: the agent loop pairs each emitted-but-unrun call with a
* synthetic `executed: false` result (see {@link isSyntheticToolResultMessage}),
* which is a positive record that `tool.execute()` never ran. So the veto is
* lifted only when ALL of the following hold, and any uncertainty (assistant
* message missing from state, a call with no result, a non-synthetic result, an
* `executed` that is not exactly `false`) keeps it in place:
*
* - the stop is a classifier refusal/sensitivity stop;
* - the only replay-unsafe blocks are tool calls — an `image`, an
* `anthropicServerTool`, or committed non-whitespace text has already rendered
* or has side effects, so replaying would duplicate it;
* - at least one tool call was emitted (otherwise the plain refusal path already
* handles it);
* - every emitted call id has a result after the assistant message in state, and
* every such result is synthetic with `executed === false`.
* Any uncertainty keeps the replay veto in place: the assistant must exist
* in state, every call must have a later synthetic result, every result must
* say `executed === false`, and the turn must contain no image, server tool,
* or committed non-whitespace text.
*/
#refusalReplaySafe(message: AssistantMessage): boolean {
if (!this.isClassifierRefusal(message)) return false;
#unexecutedToolCallsReplaySafe(message: AssistantMessage): boolean {
const emittedToolCallIds = new Set<string>();
for (const block of message.content) {
if (block.type === "toolCall") {
@@ -1076,7 +1076,7 @@ export class TurnRecovery {
}
if (emittedToolCallIds.size === 0) return false;
// The refused assistant message is NOT the tail of state: the agent loop
// The errored assistant message is NOT the tail of state: the agent loop
// appends the synthetic results after it before the turn ends, so locate it
// by walking backwards exactly as `classifyResolvedInterruptedToolTurn` does.
const messages = this.#host.agent.state.messages;
@@ -1803,6 +1803,10 @@ export class TurnRecovery {
const errorMessage = message.errorMessage || "Unknown error";
const id = this.#classifyRetryMessage(message);
const preserveFailedTurn =
options?.preserveFailedTurn === true ||
((classifierRefusal || AIError.is(id, AIError.Flag.MalformedFunctionCall)) &&
this.#unexecutedToolCallsReplaySafe(message));
const rateLimitReason = parseRateLimitReason(errorMessage);
const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
const accountPolicyDenial = AIError.is(id, AIError.Flag.AccountPolicy);
@@ -2013,9 +2017,10 @@ export class TurnRecovery {
errorId: message.errorId,
});
// Resolved stream-stall tools have already emitted results. Keep that failed
// turn intact so continuation cannot repeat their side effects.
if (!options?.preserveFailedTurn) {
// Resolved stream-stall tools and proven-unexecuted malformed/refused
// calls keep their assistant/result pair. Continuation then sees explicit
// synthetic results and cannot repeat a side effect.
if (!preserveFailedTurn) {
this.removeAssistantMessageFromActiveContext(message, "auto-retry");
}
@@ -2058,11 +2063,10 @@ export class TurnRecovery {
// rejects any assistant tail, so a missed removal fails the scheduled
// retry locally before a provider request is ever made. Re-check the
// tail after the backoff (covering rebuilds during the sleep too) and
// strip a still-failed assistant tail by position. Never in
// preserveFailedTurn mode — the kept turn ends in synthetic tool
// results that continue() accepts — and never once a newer prompt owns
// the session.
if (!options?.preserveFailedTurn && this.#host.promptGeneration() === generation) {
// strip a still-failed assistant tail by position. Never when preserving
// the failed turn — the kept turn ends in synthetic tool results that
// continue() accepts — and never once a newer prompt owns the session.
if (!preserveFailedTurn && this.#host.promptGeneration() === generation) {
this.#stripFailedAssistantTail();
}
@@ -77,6 +77,14 @@ function thinkingOnlyStop(): MockResponse {
};
}
function emptyProviderResponse(): MockResponse {
return {
content: [{ type: "thinking", thinking: "I finished reasoning but omitted the final answer." }],
stopReason: "error",
errorMessage: "Cloud Code Assist API returned a thought-only response without final output",
};
}
function signedThinkingOnlyStop(): MockResponse {
const content: ThinkingContent = { type: "thinking", thinking: "", thinkingSignature: "nonempty" };
return {
@@ -245,6 +253,67 @@ describe("AgentSession empty stop guard", () => {
expect(emptyAssistantStops(session.agent.state.messages)).toHaveLength(0);
});
it("continues with an output reminder after a Cloud Code Assist empty response", async () => {
const { session, mock } = await createHarness([
emptyProviderResponse(),
{ content: ["finished after provider-empty retry"], stopReason: "stop" },
]);
await session.prompt("finish the response");
await session.waitForIdle();
expect(mock.calls).toHaveLength(2);
expect(assistantText(session.agent.state.messages)).toContain("finished after provider-empty retry");
expect(reminderMessages(session.agent.state.messages)).toHaveLength(1);
expect(
session.agent.state.messages.some(message => message.role === "assistant" && message.stopReason === "error"),
).toBe(false);
});
it("caps provider-empty recovery without consuming generic retries and accepts the next prompt", async () => {
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
const { session, mock } = await createHarness(
[emptyProviderResponse(), emptyProviderResponse(), emptyProviderResponse(), emptyProviderResponse()],
{
"retry.enabled": true,
"retry.baseDelayMs": 5,
"retry.maxDelayMs": 5_000,
"retry.maxRetries": 2,
},
);
const retryStartEvents: Array<Extract<AgentSessionEvent, { type: "auto_retry_start" }>> = [];
const retryEndEvents: Array<Extract<AgentSessionEvent, { type: "auto_retry_end" }>> = [];
session.subscribe(event => {
if (event.type === "auto_retry_start") retryStartEvents.push(event);
if (event.type === "auto_retry_end") retryEndEvents.push(event);
});
await expectPromptCompletes(session.prompt("finish the response after reasoning"));
await session.waitForIdle();
expect(mock.calls).toHaveLength(4);
expect(reminderMessages(session.agent.state.messages)).toHaveLength(3);
expect(retryStartEvents).toHaveLength(0);
expect(retryEndEvents).toHaveLength(1);
expect(retryEndEvents[0]).toMatchObject({
type: "auto_retry_end",
success: false,
attempt: 3,
});
expect(retryEndEvents[0]?.finalError).toContain("no final output");
expect(session.isRetrying).toBe(false);
expect(session.retryAttempt).toBe(0);
mock.push({ content: ["fresh final answer"], stopReason: "stop" });
await expectPromptCompletes(session.prompt("continue"));
await session.waitForIdle();
expect(mock.calls).toHaveLength(5);
expect(retryEndEvents).toHaveLength(1);
expect(session.isRetrying).toBe(false);
expect(assistantText(session.agent.state.messages)).toContain("fresh final answer");
});
it("accepts a signed thinking-only stop without retrying", async () => {
const { session, mock } = await createHarness([
signedThinkingOnlyStop(),
@@ -10,6 +10,7 @@ import {
type ModelUsageHealth,
type ProviderSessionState,
} from "@oh-my-pi/pi-ai";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache";
@@ -60,9 +61,10 @@ function getLastAssistantMessage(session: AgentSession): AssistantMessage {
function createFallbackAgent(
primaryModel: Model,
requestedModels: string[],
options: { retryAfterMs?: number } = {},
options: { retryAfterMs?: number; firstError?: string | Error } = {},
): Agent {
const retryAfterMs = options.retryAfterMs ?? FALLBACK_TEST_RETRY_AFTER_MS;
const firstError = options.firstError ?? `rate limit exceeded retry-after-ms=${retryAfterMs}`;
const mock = createMockModel();
let primaryAttempts = 0;
return new Agent({
@@ -77,7 +79,7 @@ function createFallbackAgent(
requestedModels.push(`${model.provider}/${model.id}`);
if (model.provider === primaryModel.provider && model.id === primaryModel.id && primaryAttempts === 0) {
primaryAttempts += 1;
mock.push({ throw: `rate limit exceeded retry-after-ms=${retryAfterMs}` });
mock.push({ throw: firstError });
} else {
mock.push({ content: [`ok:${model.provider}/${model.id}`] });
}
@@ -244,6 +246,46 @@ describe("AgentSession retry fallback", () => {
]);
});
it("keeps non-Gemini empty-body errors on the model-fallback path", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
if (!primaryModel || !fallbackModel) {
throw new Error("Expected bundled empty-body fallback models");
}
const requestedModels: string[] = [];
const agent = createFallbackAgent(primaryModel, requestedModels, {
firstError: new AIError.ProviderResponseError("Devin API error: empty response body", {
provider: "devin",
kind: "empty-body",
}),
});
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.baseDelayMs": 5,
"retry.fallbackChains": {
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
},
});
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
await session.prompt("Recover the empty provider body");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
expect(session.model?.provider).toBe(fallbackModel.provider);
expect(session.model?.id).toBe(fallbackModel.id);
});
it("forwards retry fallback events to extension handlers", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
@@ -4233,7 +4275,7 @@ describe("AgentSession retry fallback", () => {
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(false);
});
it("auto-retries Gemini MALFORMED_FUNCTION_CALL transient errors", async () => {
it("auto-retries Gemini MALFORMED_FUNCTION_CALL after an unexecuted tool call", async () => {
const model = getBundledModel("google", "gemini-1.5-flash");
if (!model) {
throw new Error("Expected bundled Google test model to exist");
@@ -4241,11 +4283,30 @@ describe("AgentSession retry fallback", () => {
const malformedError = "Generation failed with finish reason: MALFORMED_FUNCTION_CALL";
const requestedModels: string[] = [];
let toolExecutions = 0;
const toolSchema = type({ value: type("string") });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "record",
label: "Record",
description: "Record a value",
parameters: toolSchema,
async execute(_toolCallId, params) {
toolExecutions += 1;
return { content: [{ type: "text", text: params.value }], details: params };
},
};
const mock = createMockModel({
responses: [
{
content: [{ type: "thinking", thinking: "Thinking before malformed function call..." }],
content: [
{
type: "toolCall",
id: "malformed-call",
name: "record",
arguments: { value: "must-not-execute" },
},
],
stopReason: "error",
errorMessage: malformedError,
},
@@ -4257,7 +4318,7 @@ describe("AgentSession retry fallback", () => {
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
tools: [tool],
messages: [],
},
streamFn: (requestedModel, context, options) => {
@@ -4284,15 +4345,28 @@ describe("AgentSession retry fallback", () => {
await session.prompt("recover from Gemini malformed error");
await session.waitForIdle();
expect(mock.calls).toHaveLength(2);
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
expect(toolExecutions).toBe(0);
expect(retryStartEvents).toHaveLength(1);
expect(retryEndEvents).toHaveLength(1);
expect(session.agent.state.messages).toHaveLength(2);
const assistantMsg = session.agent.state.messages[1];
if (assistantMsg.role !== "assistant") {
throw new Error(`Expected assistant message, got ${assistantMsg.role}`);
const messages = session.agent.state.messages;
expect(messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult", "assistant"]);
const failedAssistant = messages[1];
if (failedAssistant.role !== "assistant") {
throw new Error(`Expected failed assistant message, got ${failedAssistant.role}`);
}
const contentBlock = assistantMsg.content[0];
expect(failedAssistant.errorMessage).toBe(malformedError);
const syntheticResult = messages[2];
if (syntheticResult.role !== "toolResult") {
throw new Error(`Expected synthetic tool result, got ${syntheticResult.role}`);
}
expect(syntheticResult.toolCallId).toBe("malformed-call");
expect(syntheticResult.details).toMatchObject({ executed: false, source: "assistant_stop_error" });
const recoveredAssistant = messages[3];
if (recoveredAssistant.role !== "assistant") {
throw new Error(`Expected recovered assistant message, got ${recoveredAssistant.role}`);
}
const contentBlock = recoveredAssistant.content[0];
if (contentBlock.type !== "text") {
throw new Error(`Expected text content block, got ${contentBlock.type}`);
}
@@ -65,6 +65,7 @@ function createFixture(streamingMessage?: AssistantMessage) {
};
const showPinnedError = vi.fn();
const clearPinnedError = vi.fn();
const showError = vi.fn();
const statusContainer = {
clear: vi.fn(),
disposeChildren: vi.fn(),
@@ -116,7 +117,7 @@ function createFixture(streamingMessage?: AssistantMessage) {
flushCompactionQueue: vi.fn(async () => {}),
showPinnedError,
clearPinnedError,
showError: vi.fn(),
showError,
showStatus: vi.fn(),
noteDisplayableThinkingContent,
get hasDisplayableThinkingContent() {
@@ -134,7 +135,7 @@ function createFixture(streamingMessage?: AssistantMessage) {
} as unknown as InteractiveModeContext;
const controller = new EventController(ctx);
return { controller, ctx, showPinnedError, clearPinnedError, streamingComponent, componentCalls };
return { controller, ctx, showPinnedError, clearPinnedError, showError, streamingComponent, componentCalls };
}
describe("EventController error banner", () => {
@@ -155,6 +156,143 @@ describe("EventController error banner", () => {
expect(streamingComponent.setErrorPinned).toHaveBeenCalledWith(true);
});
it("suppresses a recoverable empty-output error while session continuation starts", async () => {
const message = makeAssistantMessage({
content: [{ type: "thinking", thinking: "Reasoning finished without final output." }],
stopReason: "error",
errorId: AIError.create(AIError.Flag.Transient, AIError.Flag.EmptyResponse),
errorMessage: "Cloud Code Assist API returned a thought-only response without final output",
});
const { controller, showPinnedError, streamingComponent } = createFixture(message);
await controller.handleEvent({ type: "message_end", message } as Extract<
AgentSessionEvent,
{ type: "message_end" }
>);
expect(streamingComponent.setErrorPinned).toHaveBeenCalledWith(true);
expect(showPinnedError).not.toHaveBeenCalled();
streamingComponent.setErrorPinned.mockClear();
await controller.handleEvent({ type: "agent_start" } as Extract<AgentSessionEvent, { type: "agent_start" }>);
expect(streamingComponent.setErrorPinned).not.toHaveBeenCalled();
});
it("keeps a terminal empty-output error pinned without adding a duplicate failure banner", async () => {
const message = makeAssistantMessage({
content: [{ type: "thinking", thinking: "Reasoning finished without final output." }],
stopReason: "error",
errorId: AIError.create(AIError.Flag.Transient, AIError.Flag.EmptyResponse),
errorMessage: "Cloud Code Assist API returned a thought-only response without final output",
});
const { controller, showPinnedError, clearPinnedError, showError } = createFixture(message);
await controller.handleEvent({ type: "message_end", message } as Extract<
AgentSessionEvent,
{ type: "message_end" }
>);
expect(showPinnedError).not.toHaveBeenCalled();
const finalError = "Assistant returned no final output after retry cap; try switching models";
message.errorId = AIError.create();
message.errorMessage = finalError;
clearPinnedError.mockClear();
showPinnedError.mockClear();
await controller.handleEvent({
type: "auto_retry_end",
success: false,
attempt: 3,
finalError,
} as Extract<AgentSessionEvent, { type: "auto_retry_end" }>);
expect(clearPinnedError).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
expect(showPinnedError).toHaveBeenCalledWith(finalError);
});
it("keeps retry-attempt context when a terminal provider error is pinned", async () => {
const errorMessage = "Service unavailable";
const message = makeAssistantMessage({
stopReason: "error",
errorId: AIError.create(AIError.Flag.Transient),
errorMessage,
});
const { controller, showPinnedError, showError } = createFixture(message);
await controller.handleEvent({ type: "message_end", message } as Extract<
AgentSessionEvent,
{ type: "message_end" }
>);
showPinnedError.mockClear();
await controller.handleEvent({
type: "auto_retry_end",
success: false,
attempt: 3,
finalError: errorMessage,
} as Extract<AgentSessionEvent, { type: "auto_retry_end" }>);
expect(showError).not.toHaveBeenCalled();
expect(showPinnedError).toHaveBeenCalledWith("Retry failed after 3 attempts: Service unavailable");
});
it("surfaces a local continuation failure instead of the stale pinned provider error", async () => {
const providerError = "Service unavailable";
const message = makeAssistantMessage({
stopReason: "error",
errorId: AIError.create(AIError.Flag.Transient),
errorMessage: providerError,
});
const { controller, showPinnedError, clearPinnedError, showError, streamingComponent } = createFixture(message);
await controller.handleEvent({ type: "message_end", message } as Extract<
AgentSessionEvent,
{ type: "message_end" }
>);
await controller.handleEvent({
type: "auto_retry_start",
attempt: 1,
maxAttempts: 2,
delayMs: 0,
errorMessage: providerError,
errorId: message.errorId,
} as Extract<AgentSessionEvent, { type: "auto_retry_start" }>);
showPinnedError.mockClear();
clearPinnedError.mockClear();
showError.mockClear();
streamingComponent.setErrorPinned.mockClear();
const finalError = `Retry continuation failed locally: local hook failed. Original error: ${providerError}`;
await controller.handleEvent({
type: "auto_retry_end",
success: false,
attempt: 1,
finalError,
} as Extract<AgentSessionEvent, { type: "auto_retry_end" }>);
expect(showPinnedError).not.toHaveBeenCalled();
expect(clearPinnedError).toHaveBeenCalledTimes(1);
expect(streamingComponent.setErrorPinned).toHaveBeenCalledWith(false);
expect(showError).toHaveBeenCalledWith(`Retry failed after 1 attempts: ${finalError}`);
});
it("shows a failed retry banner when no terminal assistant error exists", async () => {
const { controller, showError } = createFixture();
await controller.handleEvent({
type: "auto_retry_end",
success: false,
attempt: 3,
finalError: "Assistant returned empty stop after retry cap",
} as Extract<AgentSessionEvent, { type: "auto_retry_end" }>);
expect(showError).toHaveBeenCalledWith(
"Retry failed after 3 attempts: Assistant returned empty stop after retry cap",
);
});
it("restores the transcript inline error when the next turn starts", async () => {
const errorMessage = "Output blocked by content filtering policy";
const message = makeAssistantMessage({ stopReason: "error", errorMessage });
@@ -97,6 +97,31 @@ describe("AssistantMessageComponent streaming fast path", () => {
}
});
it("repairs Gemini's lone closing fence when the streamed turn becomes final", () => {
const text = `=== PACED IP ROTATION SOAK RESULTS ===
Average Latency: 1,240 ms
\`\`\`
---
### Production Deployment Status
| Workload | Pod Status |
| :--- | :--- |
| google-scraper | **1/1 Running** |`;
const message = msg([{ type: "text", text }]);
const component = new AssistantMessageComponent();
component.updateContent(message, { transient: true });
expect(Bun.stripANSI(component.render(W).join("\n"))).toContain("| :--- | :--- |");
component.updateContent(message);
const finalized = Bun.stripANSI(component.render(W).join("\n"));
expect(finalized).not.toContain("| :--- | :--- |");
expect(finalized).toContain("google-scraper");
expect(finalized).toContain("1/1 Running");
});
// Regression: theme/symbol changes reach the component via invalidate()
// (InteractiveMode clears the markdown render cache and invalidates the
// tree). Reused fast-path children captured getMarkdownTheme() at
@@ -511,6 +511,24 @@ describe("TurnRecovery replay-unsafe output classification", () => {
expect(recoveryFor(message, []).isRetryableError(message)).toBe(true);
});
it("retries a malformed function call whose tool call provably never executed", () => {
const message = makeMessage([toolCall("call-1")], model);
message.errorMessage = "Generation failed with finish reason: MALFORMED_FUNCTION_CALL";
expect(recoveryFor(message, [syntheticResult("call-1")]).isRetryableError(message)).toBe(true);
});
it("does not retry a malformed function call whose tool call produced a real result", () => {
const message = makeMessage([toolCall("call-1")], model);
message.errorMessage = "Generation failed with finish reason: MALFORMED_FUNCTION_CALL";
expect(recoveryFor(message, [realResult("call-1")]).isRetryableError(message)).toBe(false);
});
it("does not retry a malformed function call that also committed visible text", () => {
const message = makeMessage([{ type: "text", text: "Let me fetch that page." }, toolCall("call-1")], model);
message.errorMessage = "Generation failed with finish reason: MALFORMED_FUNCTION_CALL";
expect(recoveryFor(message, [syntheticResult("call-1")]).isRetryableError(message)).toBe(false);
});
it("keeps a non-refusal error with an unexecuted tool call non-retriable", () => {
const message = makeMessage([toolCall("call-1")], model);
expect(recoveryFor(message, [syntheticResult("call-1")]).isRetryableError(message)).toBe(false);
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Gemini reports rendering their final headings and tables as one raw code block when the model emitted a lone closing Markdown fence without its opener.
## [17.3.1] - 2026-08-13
### Fixed
+68 -1
View File
@@ -43,6 +43,71 @@ function normalizeOsc8Terminators(text: string): string {
return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07");
}
const MARKDOWN_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6}[ \t]+\S/;
const FENCED_SOURCE_INTRO = /\b(?:code|example|markdown|output|snippet|source)\s*:?\s*$/i;
function isGfmTableDelimiter(line: string, headerLine: string | undefined): boolean {
if (!headerLine || !line.includes("|") || !headerLine.includes("|")) return false;
const delimiterCells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
const headerCells = headerLine.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
return (
delimiterCells.length >= 2 &&
headerCells.length === delimiterCells.length &&
delimiterCells.every(cell => /^:?-{3,}:?$/.test(cell.trim())) &&
headerCells.every(cell => cell.trim().length > 0)
);
}
/**
* Gemini can emit a bare closing fence without its opener, then continue with
* headings and tables. CommonMark must interpret that lone fence as an opener,
* which turns the rest of an otherwise valid report into one raw code block.
*
* Repair only the unambiguous rich-document shape at final render: one
* unmatched bare fence after prose, followed by both an ATX heading and a GFM
* table delimiter. Keep ordinary incomplete code blocks, fenced Markdown
* examples, and every matched fence untouched.
*/
function repairOrphanClosingFence(text: string): string {
const lines = text.split("\n");
let open: { index: number; marker: string; info: string } | undefined;
for (let index = 0; index < lines.length; index++) {
const match = MARKDOWN_FENCE_LINE.exec(lines[index]!);
if (!match) continue;
const marker = match[1]!;
const info = match[2]!.trim();
if (!open) {
open = { index, marker, info };
continue;
}
if (marker[0] === open.marker[0] && marker.length >= open.marker.length && info === "") {
open = undefined;
}
}
if (open?.info !== "") return text;
let previous = "";
for (let index = open.index - 1; index >= 0; index--) {
previous = lines[index]!.trim();
if (previous) break;
}
if (!previous || previous.endsWith(":") || FENCED_SOURCE_INTRO.test(previous)) return text;
let hasHeading = false;
let hasTableDelimiter = false;
for (let index = open.index + 1; index < lines.length; index++) {
const line = lines[index]!;
hasHeading ||= MARKDOWN_HEADING_LINE.test(line);
hasTableDelimiter ||= isGfmTableDelimiter(line, lines[index - 1]);
if (hasHeading && hasTableDelimiter) {
lines.splice(open.index, 1);
return lines.join("\n");
}
}
return text;
}
// OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
// unit by the H1 render path. Like image-protocol lines, they bypass ANSI
// wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping
@@ -1730,7 +1795,9 @@ export class Markdown
}
// Replace tabs with 3 spaces for consistent rendering
const normalizedText = replaceTabs(this.#text);
const normalizedText = this.transientRenderCache
? replaceTabs(this.#text)
: repairOrphanClosingFence(replaceTabs(this.#text));
const signature = this.#renderSignature(width, paddingX);
// L2: module-level LRU — survives component disposal/recreation across
+64
View File
@@ -254,6 +254,70 @@ describe("Markdown component", () => {
expect(plainLines.some(line => line.includes("-"))).toBeTruthy();
});
it("recovers rich Markdown after a lone closing fence from Gemini", () => {
const markdown = new Markdown(
`=== PACED IP ROTATION SOAK RESULTS ===
Total Queries: 20
Average Latency: 1,240 ms
\`\`\`
---
### Production Deployment Status
| Workload | Pod Status |
| :--- | :--- |
| google-scraper | **1/1 Running** |`,
0,
0,
defaultMarkdownTheme,
);
markdown.transientRenderCache = true;
markdown.render(80);
markdown.transientRenderCache = false;
const plainLines = markdown.render(80).map(line => stripVTControlCharacters(line).trimEnd());
expect(plainLines.some(line => line.includes("| :--- | :--- |"))).toBe(false);
expect(plainLines.filter(line => line.includes("+")).length).toBeGreaterThanOrEqual(2);
expect(plainLines.some(line => line.includes("google-scraper") && line.includes("1/1 Running"))).toBe(true);
});
it("keeps an intentional unclosed fenced Markdown example literal", () => {
const markdown = new Markdown(
`Markdown source:
\`\`\`
### Production Deployment Status
| Workload | Pod Status |
| :--- | :--- |
| google-scraper | 1/1 Running |`,
0,
0,
defaultMarkdownTheme,
);
const plainLines = markdown.render(80).map(line => stripVTControlCharacters(line).trimEnd());
expect(plainLines.some(line => line.includes("| :--- | :--- |"))).toBe(true);
expect(plainLines.filter(line => line.includes("+"))).toHaveLength(0);
});
it("keeps an unfinished code block with a rule and heading literal when no table follows", () => {
const markdown = new Markdown(
`The process printed this
\`\`\`
---
### Still inside the unfinished block`,
0,
0,
defaultMarkdownTheme,
);
const plainLines = markdown.render(80).map(line => stripVTControlCharacters(line).trimEnd());
expect(plainLines.some(line => line.includes("---"))).toBe(true);
expect(plainLines.some(line => line.includes("### Still inside the unfinished block"))).toBe(true);
});
it("should render row dividers between data rows", () => {
const markdown = new Markdown(
`| Name | Age |