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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+25
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user