From b1ce77c109d3173832b1f1e996c3375510b4f3bb Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Fri, 14 Aug 2026 01:35:21 +0000 Subject: [PATCH 1/4] fix(gemini): recover malformed and thought-only turns --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/error/flags.ts | 13 +- packages/ai/src/error/provider.ts | 15 ++- .../ai/src/providers/google-gemini-cli.ts | 25 +++- packages/ai/test/error-aierr.test.ts | 3 +- .../test/google-empty-response-retry.test.ts | 46 ++++--- packages/coding-agent/CHANGELOG.md | 6 + .../src/modes/controllers/event-controller.ts | 48 ++++++-- .../src/prompts/system/empty-stop-retry.md | 2 +- .../coding-agent/src/session/agent-session.ts | 9 +- .../coding-agent/src/session/turn-recovery.ts | 114 +++++++++--------- .../agent-session-empty-stop-guard.test.ts | 70 +++++++++++ .../test/agent-session-retry-fallback.test.ts | 50 ++++++-- .../event-controller-error-banner.test.ts | 81 ++++++++++++- .../test/turn-recovery-replay-unsafe.test.ts | 18 +++ 15 files changed, 399 insertions(+), 105 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c8618e503..73e888566 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Distinguished Gemini thought-only `STOP` responses from empty transports, avoiding repeated identical reasoning requests and surfacing the missing final output for session-level recovery. + ## [17.3.2] - 2026-08-13 ### Fixed diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 663fa44e5..374c83a2a 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -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,8 @@ 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 = + /\b(?:returned an empty response|empty response body|thought-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 +206,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 +350,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; diff --git a/packages/ai/src/error/provider.ts b/packages/ai/src/error/provider.ts index d8b312688..d248c1523 100644 --- a/packages/ai/src/error/provider.ts +++ b/packages/ai/src/error/provider.ts @@ -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,12 +40,13 @@ 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. - else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient)); + // An incomplete stream, empty body, or logically empty output produced no + // final content, so all are transient. Preserve the empty distinction so + // session recovery can request an actual final answer instead of replaying + // the same prompt. + else if (this.kind === "empty-body" || this.kind === "empty-output") + attach(this, create(Flag.Transient, Flag.EmptyResponse)); + else if (this.kind === "incomplete-stream") attach(this, create(Flag.Transient)); } } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index befcd3dfd..da568aeb1 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -932,6 +932,11 @@ 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]; @@ -1033,6 +1038,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( 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 (hasThinkingOutput()) break; + if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) { resetOutput(); } @@ -1046,10 +1057,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) { diff --git a/packages/ai/test/error-aierr.test.ts b/packages/ai/test/error-aierr.test.ts index 88f85fe97..6ccdd298e 100644 --- a/packages/ai/test/error-aierr.test.ts +++ b/packages/ai/test/error-aierr.test.ts @@ -88,7 +88,7 @@ describe("AIError.classify — structural provider errors", () => { expect(AIError.retriable(id)).toBe(true); }); - it("classifies an empty provider response as transient + retryable", () => { + it("classifies an empty provider response as transient + empty-response + 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. @@ -98,6 +98,7 @@ describe("AIError.classify — structural provider errors", () => { }); 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); }); diff --git a/packages/ai/test/google-empty-response-retry.test.ts b/packages/ai/test/google-empty-response-retry.test.ts index 2ee1c3515..c4e9d6b19 100644 --- a/packages/ai/test/google-empty-response-retry.test.ts +++ b/packages/ai/test/google-empty-response-retry.test.ts @@ -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 () => { @@ -436,12 +464,7 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { errorMessage: result.errorMessage, text: textOf(result), }).toEqual({ - requestedEndpoints: [ - ANTIGRAVITY_DAILY_ENDPOINT, - ANTIGRAVITY_DAILY_ENDPOINT, - ANTIGRAVITY_DAILY_ENDPOINT, - ANTIGRAVITY_SANDBOX_ENDPOINT, - ], + requestedEndpoints: [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT], stopReason: "stop", errorMessage: undefined, text: "Recovered.", @@ -464,14 +487,9 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { }); 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, - ]); + // A complete thought-only STOP skips same-request retries, then fails over; + // the sandbox (final) endpoint records valid Advisor silence. + expect(requestedEndpoints).toEqual([ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT]); expect(result.stopReason).toBe("stop"); expect(result.errorMessage).toBeUndefined(); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 38f921d82..5fc5d9f57 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 terminal retry errors in one pinned banner instead of rendering a second generic retry-failure notice. + ## [17.3.2] - 2026-08-13 ### Fixed diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 1f26efbdf..299852828 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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(); #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,31 @@ export class EventController { this.ctx.retryLoader = undefined; this.ctx.statusContainer.disposeChildren(); } + const terminalFailurePinned = !event.success && this.#pinnedErrorComponent !== undefined; 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 && (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.#pinnedErrorMessage?.errorMessage ?? 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(); diff --git a/packages/coding-agent/src/prompts/system/empty-stop-retry.md b/packages/coding-agent/src/prompts/system/empty-stop-retry.md index c71865858..63c140e39 100644 --- a/packages/coding-agent/src/prompts/system/empty-stop-retry.md +++ b/packages/coding-agent/src/prompts/system/empty-stop-retry.md @@ -1,4 +1,4 @@ -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}} diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 831aa5aa7..cd5679440 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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 diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index d389e4376..f1220df61 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -395,7 +395,7 @@ export class TurnRecovery { } /** Handles empty terminal assistant turns and schedules bounded recovery. */ - handleEmptyAssistantStop(message: AssistantMessage): Promise { + 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 { - 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(); 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(); } diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index 1fbee1f9b..18a3d0456 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -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 an empty response", + }; +} + function signedThinkingOnlyStop(): MockResponse { const content: ThinkingContent = { type: "thinking", thinking: "", thinkingSignature: "nonempty" }; return { @@ -245,6 +253,68 @@ 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(JSON.stringify(reminderMessages(session.agent.state.messages))).toContain("user-visible final answer"); + 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> = []; + const retryEndEvents: Array> = []; + 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(), diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index cd39c4aa6..a3f832410 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -4233,7 +4233,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 +4241,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 = { + 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 +4276,7 @@ describe("AgentSession retry fallback", () => { initialState: { model, systemPrompt: ["Test"], - tools: [], + tools: [tool], messages: [], }, streamFn: (requestedModel, context, options) => { @@ -4284,15 +4303,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}`); } diff --git a/packages/coding-agent/test/event-controller-error-banner.test.ts b/packages/coding-agent/test/event-controller-error-banner.test.ts index bda1dfb79..d8f9c9782 100644 --- a/packages/coding-agent/test/event-controller-error-banner.test.ts +++ b/packages/coding-agent/test/event-controller-error-banner.test.ts @@ -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,82 @@ 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); + + expect(streamingComponent.setErrorPinned).not.toHaveBeenCalled(); + }); + + it("keeps a terminal retry error pinned without adding a duplicate failure banner", async () => { + const errorMessage = "Retry budget exhausted after 2 retries: Cloud Code Assist API returned an empty response"; + const message = makeAssistantMessage({ stopReason: "error", errorMessage }); + const { controller, showPinnedError, clearPinnedError, showError } = createFixture(message); + + await controller.handleEvent({ type: "message_end", message } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + clearPinnedError.mockClear(); + showPinnedError.mockClear(); + + await controller.handleEvent({ + type: "auto_retry_end", + success: false, + attempt: 2, + finalError: "Cloud Code Assist API returned an empty response", + retryErrors: [ + { + entryId: "prior-attempt", + persistenceKey: "prior-attempt", + note: "error; retried", + retryRecovery: { + kind: "auto-retry", + status: "superseded", + attempt: 1, + recovery: "plain", + note: "error; retried", + }, + }, + ], + } as Extract); + + expect(clearPinnedError).not.toHaveBeenCalled(); + expect(showError).not.toHaveBeenCalled(); + expect(showPinnedError).toHaveBeenCalledWith(errorMessage); + }); + + 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); + + 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 }); diff --git a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts index 74247b0ca..73f95ffe7 100644 --- a/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts +++ b/packages/coding-agent/test/turn-recovery-replay-unsafe.test.ts @@ -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); From bdbea3f7f37fe28aae7b137ce6a6919ae5d64152 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Fri, 14 Aug 2026 02:03:18 +0000 Subject: [PATCH 2/4] fix(tui): recover reports after orphan fences --- ...sistant-message-streaming-fastpath.test.ts | 25 ++++++++ packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/markdown.ts | 63 ++++++++++++++++++- packages/tui/test/markdown.test.ts | 48 ++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts b/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts index 0f961c90c..e8fa653b7 100644 --- a/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts +++ b/packages/coding-agent/test/modes/components/assistant-message-streaming-fastpath.test.ts @@ -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 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 06f97341d..82d1764b7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index dfdd7ab8a..fd39d42fb 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -43,6 +43,65 @@ 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): boolean { + const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); + const cells = trimmed.split("|"); + return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell.trim())); +} + +/** + * 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 || 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); + 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 +1789,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 diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index f4d099f77..c5fcfacd2 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -254,6 +254,54 @@ 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("should render row dividers between data rows", () => { const markdown = new Markdown( `| Name | Age | From fe33232298133b489418c340b092d0110c6a1993 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Fri, 14 Aug 2026 02:48:18 +0000 Subject: [PATCH 3/4] fix(gemini): preserve retry and rendering boundaries --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/error/flags.ts | 3 +- packages/ai/src/error/provider.ts | 12 +-- .../ai/src/providers/google-gemini-cli.ts | 27 +++-- packages/ai/test/error-aierr.test.ts | 23 ++-- .../test/google-empty-response-retry.test.ts | 50 +++++---- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/controllers/event-controller.ts | 26 ++++- .../agent-session-empty-stop-guard.test.ts | 2 +- .../test/agent-session-retry-fallback.test.ts | 46 +++++++- .../event-controller-error-banner.test.ts | 101 ++++++++++++++---- packages/tui/src/components/markdown.ts | 16 ++- packages/tui/test/markdown.test.ts | 16 +++ 13 files changed, 243 insertions(+), 83 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 73e888566..3db3a7773 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Distinguished Gemini thought-only `STOP` responses from empty transports, avoiding repeated identical reasoning requests and surfacing the missing final output for session-level recovery. +- 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 diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 374c83a2a..6167e6f5a 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -111,8 +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 = - /\b(?:returned an empty response|empty response body|thought-only response without final output)\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; diff --git a/packages/ai/src/error/provider.ts b/packages/ai/src/error/provider.ts index d248c1523..cec0b59c1 100644 --- a/packages/ai/src/error/provider.ts +++ b/packages/ai/src/error/provider.ts @@ -40,13 +40,11 @@ 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, empty body, or logically empty output produced no - // final content, so all are transient. Preserve the empty distinction so - // session recovery can request an actual final answer instead of replaying - // the same prompt. - else if (this.kind === "empty-body" || this.kind === "empty-output") - attach(this, create(Flag.Transient, Flag.EmptyResponse)); - else if (this.kind === "incomplete-stream") attach(this, create(Flag.Transient)); + // 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)); } } diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index da568aeb1..c1db05bdf 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -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); } @@ -943,7 +938,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const isLastEndpoint = i === endpoints.length - 1; try { started = false; - emittedVisibleContent = false; resetOutput(); // Per attempt: arm a pre-response (TTFT) timer, cleared the instant @@ -1027,12 +1021,15 @@ 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; @@ -1042,7 +1039,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( // transiently empty transport. Replaying the identical request // burns another full reasoning pass; let session recovery add // an explicit final-output reminder instead. - if (hasThinkingOutput()) break; + if (thoughtOnly) break; if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) { resetOutput(); @@ -1098,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") && diff --git a/packages/ai/test/error-aierr.test.ts b/packages/ai/test/error-aierr.test.ts index 6ccdd298e..3cdb67932 100644 --- a/packages/ai/test/error-aierr.test.ts +++ b/packages/ai/test/error-aierr.test.ts @@ -88,16 +88,27 @@ describe("AIError.classify — structural provider errors", () => { expect(AIError.retriable(id)).toBe(true); }); - it("classifies an empty provider response as transient + empty-response + 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); }); diff --git a/packages/ai/test/google-empty-response-retry.test.ts b/packages/ai/test/google-empty-response-retry.test.ts index c4e9d6b19..c6a6124f0 100644 --- a/packages/ai/test/google-empty-response-retry.test.ts +++ b/packages/ai/test/google-empty-response-retry.test.ts @@ -438,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); @@ -456,42 +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_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(); - // A complete thought-only STOP skips same-request retries, then fails over; - // the sandbox (final) endpoint records valid Advisor silence. - expect(requestedEndpoints).toEqual([ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT]); - expect(result.stopReason).toBe("stop"); - expect(result.errorMessage).toBeUndefined(); + 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.", + }), + ]); }); for (const { mode, endpoint } of [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5fc5d9f57..37a9b34c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,7 +6,7 @@ - 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 terminal retry errors in one pinned banner instead of rendering a second generic retry-failure notice. +- 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 diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 299852828..e97b099c5 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1983,7 +1983,21 @@ export class EventController { this.ctx.retryLoader = undefined; this.ctx.statusContainer.disposeChildren(); } - const terminalFailurePinned = !event.success && this.#pinnedErrorComponent !== undefined; + 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); @@ -1996,13 +2010,19 @@ export class EventController { } appliedRetryUpdate = true; } - if (!terminalFailurePinned && (appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0)) { + if ( + !terminalFailurePinned && + !stalePinnedErrorCleared && + (appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0) + ) { this.ctx.clearPinnedError(); } this.#clearRetrySupersededAssistantComponents(); if (!event.success) { if (terminalFailurePinned) { - const terminalError = this.#pinnedErrorMessage?.errorMessage ?? event.finalError; + 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 { diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index 18a3d0456..af3a1377b 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -81,7 +81,7 @@ function emptyProviderResponse(): MockResponse { return { content: [{ type: "thinking", thinking: "I finished reasoning but omitted the final answer." }], stopReason: "error", - errorMessage: "Cloud Code Assist API returned an empty response", + errorMessage: "Cloud Code Assist API returned a thought-only response without final output", }; } diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index a3f832410..9782fa0e0 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -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"); diff --git a/packages/coding-agent/test/event-controller-error-banner.test.ts b/packages/coding-agent/test/event-controller-error-banner.test.ts index d8f9c9782..323b898b3 100644 --- a/packages/coding-agent/test/event-controller-error-banner.test.ts +++ b/packages/coding-agent/test/event-controller-error-banner.test.ts @@ -179,42 +179,103 @@ describe("EventController error banner", () => { expect(streamingComponent.setErrorPinned).not.toHaveBeenCalled(); }); - it("keeps a terminal retry error pinned without adding a duplicate failure banner", async () => { - const errorMessage = "Retry budget exhausted after 2 retries: Cloud Code Assist API returned an empty response"; - const message = makeAssistantMessage({ stopReason: "error", errorMessage }); + 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: 2, - finalError: "Cloud Code Assist API returned an empty response", - retryErrors: [ - { - entryId: "prior-attempt", - persistenceKey: "prior-attempt", - note: "error; retried", - retryRecovery: { - kind: "auto-retry", - status: "superseded", - attempt: 1, - recovery: "plain", - note: "error; retried", - }, - }, - ], + attempt: 3, + finalError, } as Extract); expect(clearPinnedError).not.toHaveBeenCalled(); expect(showError).not.toHaveBeenCalled(); - expect(showPinnedError).toHaveBeenCalledWith(errorMessage); + 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); + + 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); + 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); + + 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 () => { diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index fd39d42fb..cef398b0d 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -47,10 +47,16 @@ 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): boolean { - const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); - const cells = trimmed.split("|"); - return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell.trim())); +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) + ); } /** @@ -93,7 +99,7 @@ function repairOrphanClosingFence(text: string): string { for (let index = open.index + 1; index < lines.length; index++) { const line = lines[index]!; hasHeading ||= MARKDOWN_HEADING_LINE.test(line); - hasTableDelimiter ||= isGfmTableDelimiter(line); + hasTableDelimiter ||= isGfmTableDelimiter(line, lines[index - 1]); if (hasHeading && hasTableDelimiter) { lines.splice(open.index, 1); return lines.join("\n"); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index c5fcfacd2..edea7de8d 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -302,6 +302,22 @@ Average Latency: 1,240 ms 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 | From ed108dec8b11f83c7214b42e388200b187c736ad Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Fri, 14 Aug 2026 02:54:25 +0000 Subject: [PATCH 4/4] test(gemini): keep retry assertion behavioral --- .../coding-agent/test/agent-session-empty-stop-guard.test.ts | 1 - packages/tui/src/components/markdown.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index af3a1377b..579f2d51d 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -265,7 +265,6 @@ describe("AgentSession empty stop guard", () => { 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(JSON.stringify(reminderMessages(session.agent.state.messages))).toContain("user-visible final answer"); expect( session.agent.state.messages.some(message => message.role === "assistant" && message.stopReason === "error"), ).toBe(false); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index cef398b0d..1e6ee8ae0 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -85,7 +85,7 @@ function repairOrphanClosingFence(text: string): string { open = undefined; } } - if (!open || open.info !== "") return text; + if (open?.info !== "") return text; let previous = ""; for (let index = open.index - 1; index >= 0; index--) {