From 492c528aa316fa5a1aa5da545d76e4e434e9987c Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 18 Jun 2026 00:25:17 +0000 Subject: [PATCH] fix(agent): skipped compaction timeout retries Stopped auto context-full maintenance from retrying repeated summarization timeouts on the same model before fallback. Added a regression test for timeout fast-fallback behavior.\n\nFixes #2913 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/session/agent-session.ts | 44 +++++++++++++------ .../test/agent-session-handoff.test.ts | 41 +++++++++++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5c830974a..627aa1100 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -42,6 +42,7 @@ - Fixed the `tools.format` setting schema so `minimax` can be selected as an owned tool-calling dialect, and taught auto mode to route tool-less MiniMax-family models to the MiniMax owned dialect. ([#2759](https://github.com/can1357/oh-my-pi/issues/2759)) - Fixed WSL2 TUI stutter by adding a `git.enabled` setting and skipping footer/status-line git probes when disabled or when no git-backed status segment is visible ([#2847](https://github.com/can1357/oh-my-pi/issues/2847)). - Fixed JSON-mode startup notices (export/resume/session-picker messages) writing to stdout before the JSON event stream; they now route to stderr so stdout remains newline-delimited JSON. +- Fixed auto context-full maintenance to stop retrying the same summarization timeout before falling back to the next compaction model ([#2913](https://github.com/can1357/oh-my-pi/issues/2913)). ## [16.0.4] - 2026-06-17 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 0571e10f9..92438d80d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9225,7 +9225,9 @@ export class AgentSession { let compactResult: CompactionResult | undefined; let lastError: unknown; - for (const candidate of candidates) { + for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) { + const candidate = candidates[candidateIndex]; + const hasMoreCandidates = candidateIndex < candidates.length - 1; const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) continue; @@ -9264,6 +9266,20 @@ export class AgentSession { lastError = this.#buildCompactionAuthError(); break; } + if (this.#isCompactionSummarizationTimeoutMessage(message)) { + logger.warn( + hasMoreCandidates + ? "Auto-compaction summarization timed out, trying next model" + : "Auto-compaction summarization timed out, not retrying same model", + { + error: message, + model: `${candidate.provider}/${candidate.id}`, + }, + ); + lastError = error; + break; + } + const retryAfterMs = this.#parseRetryAfterMsFromError(message); const shouldRetry = retrySettings.enabled && @@ -9281,19 +9297,15 @@ export class AgentSession { // If retry delay is too long (>30s), try next candidate instead of waiting const maxAcceptableDelayMs = 30_000; - if (delayMs > maxAcceptableDelayMs) { - const hasMoreCandidates = candidates.indexOf(candidate) < candidates.length - 1; - if (hasMoreCandidates) { - logger.warn("Auto-compaction retry delay too long, trying next model", { - delayMs, - retryAfterMs, - error: message, - model: `${candidate.provider}/${candidate.id}`, - }); - lastError = error; - break; // Exit retry loop, continue to next candidate - } - // No more candidates - we have to wait + if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) { + logger.warn("Auto-compaction retry delay too long, trying next model", { + delayMs, + retryAfterMs, + error: message, + model: `${candidate.provider}/${candidate.id}`, + }); + lastError = error; + break; // Exit retry loop, continue to next candidate } attempt++; @@ -9709,6 +9721,10 @@ export class AgentSession { return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage); } + #isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean { + return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage); + } + #isTransientTransportErrorMessage(errorMessage: string): boolean { // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed, diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index dbfaea752..76c947997 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -239,6 +239,47 @@ describe("AgentSession handoff", () => { expect(events).toContainEqual({ type: "auto_compaction_start", reason: "threshold", action: "context-full" }); expect(events.some(event => event.type === "auto_compaction_end" && event.aborted === false)).toBe(true); }); + + it("falls back after one auto-compaction timeout instead of retrying the same model", async () => { + session.settings.set("compaction.thresholdTokens", 50); + session.settings.set("compaction.keepRecentTokens", 1); + session.settings.set("contextPromotion.enabled", false); + session.settings.set("retry.baseDelayMs", 1); + + let firstCandidateKey: string | undefined; + let fallbackCandidateKey: string | undefined; + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, candidate) => { + const candidateKey = `${candidate.provider}/${candidate.id}`; + firstCandidateKey ??= candidateKey; + if (candidateKey === firstCandidateKey) { + throw new Error("Summarization failed: The operation timed out."); + } + fallbackCandidateKey = candidateKey; + return { + summary: "fallback compacted", + shortSummary: undefined, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + }; + }); + const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(async () => { + expect(sessionManager.getEntries().some(entry => entry.type === "compaction")).toBe(true); + }); + + await session.prompt("pending prompt ".repeat(120)); + await waitFor( + () => + fallbackCandidateKey !== undefined && + events.some(event => event.type === "auto_compaction_end" && event.aborted === false), + ); + + expect( + compactSpy.mock.calls.filter(call => `${call[1].provider}/${call[1].id}` === firstCandidateKey), + ).toHaveLength(1); + expect(fallbackCandidateKey).toBeDefined(); + expect(promptSpy).toHaveBeenCalledTimes(1); + }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key");