feat: enabled auto-retry for AI thinking loops

- Improved thinking loop detection logic by refining text normalization and treating stalls as retryable errors.
- Instrumented the agent session to recognize thinking loop markers within retryable error conditions.
- Automated the clearing of stale error banners upon successful auto-retry execution.
- Added comprehensive test coverage for chunked thinking loop errors and banner management.
This commit is contained in:
can1357
2026-06-19 17:51:53 +02:00
parent 8351536641
commit b2dc706ee7
8 changed files with 397 additions and 29 deletions
@@ -9,6 +9,7 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import { ErrorBannerComponent } from "@oh-my-pi/pi-coding-agent/modes/components/error-banner";
@@ -60,15 +61,22 @@ function createFixture(streamingMessage?: AssistantMessage) {
};
const showPinnedError = vi.fn();
const clearPinnedError = vi.fn();
const statusContainer = {
clear: vi.fn(),
addChild: vi.fn(),
};
const session = { isTtsrAbortPending: false, retryAttempt: 0 };
const ctx = {
isInitialized: true,
init: vi.fn(async () => {}),
ui: { requestRender: vi.fn() },
ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() },
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
ensureLoadingAnimation: vi.fn(),
statusContainer,
loadingAnimation: undefined,
retryLoader: undefined,
editor: {},
streamingComponent: streamingMessage ? streamingComponent : undefined,
streamingMessage,
@@ -121,6 +129,35 @@ describe("EventController error banner", () => {
expect(streamingComponent.setErrorPinned).toHaveBeenCalledWith(false);
});
it("clears retryable thinking-loop banners without restoring the dropped inline error", async () => {
const errorMessage = `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content. Treating as a stream stall and retrying.`;
const message = makeAssistantMessage({ stopReason: "error", errorMessage });
const { controller, clearPinnedError, streamingComponent } = createFixture(message);
await controller.handleEvent({ type: "message_end", message } as Extract<
AgentSessionEvent,
{ type: "message_end" }
>);
clearPinnedError.mockClear();
streamingComponent.setErrorPinned.mockClear();
await controller.handleEvent({
type: "auto_retry_start",
attempt: 1,
maxAttempts: 2,
delayMs: 0,
errorMessage,
} as Extract<AgentSessionEvent, { type: "auto_retry_start" }>);
expect(clearPinnedError).toHaveBeenCalledTimes(1);
expect(streamingComponent.setErrorPinned).not.toHaveBeenCalledWith(false);
await controller.handleEvent({
type: "auto_retry_end",
success: true,
attempt: 1,
} as Extract<AgentSessionEvent, { type: "auto_retry_end" }>);
});
it("does not pin a banner for a normal assistant stop", async () => {
const message = makeAssistantMessage({ stopReason: "stop" });
const { controller, showPinnedError } = createFixture(message);