diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4dac863e1..205813f94 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed `/goal` threshold auto-compaction skipping real sessions through three paths: per-turn supersede/drop-useless pruning no longer deflates the threshold trigger below the last provider-billed context; active-goal text stops now attempt threshold maintenance before unexpected-stop retry continuations can return from post-turn handling; and empty `toolUse` stops keep the existing cleanup pass that strips the orphan assistant from active context + session history before any compaction continuation. Added `agent_end maintenance routing` and `Auto-compaction threshold decision` debug logs so future no-start reports identify the exact early-return branch and the billed/stored/resolved/post-maintenance token counts that fed `shouldCompact`. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174)) +- Fixed `/goal` threshold auto-compaction skipping real sessions through three paths: per-turn supersede/drop-useless pruning no longer deflates the threshold trigger below the last provider-billed context; active-goal text stops now attempt threshold maintenance before unexpected-stop retry continuations can return from post-turn handling; and empty `toolUse` stops keep the existing cleanup pass that strips the orphan assistant from active context + session history before any compaction continuation. Active-goal compaction continuations now also resolve completed retry gates before returning, preventing `isRetrying` from staying stuck after a retry succeeds over the threshold. Added `agent_end maintenance routing` and `Auto-compaction threshold decision` debug logs so future no-start reports identify the exact early-return branch and the billed/stored/resolved/post-maintenance token counts that fed `shouldCompact`. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174)) ## [16.1.10] - 2026-06-21 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 8498ff38b..38db5736b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2855,6 +2855,7 @@ export class AgentSession { deferredHandoff: compactionResult.deferredHandoff, continuationScheduled: compactionResult.continuationScheduled, }); + this.#resolveRetry(); await emitAgentEndNotification(); return; } diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index b8b7b1fdc..fa24ca3e6 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; +import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -472,6 +473,105 @@ describe("AgentSession auto-compaction queue resume", () => { expect(getRuntimeSignals()).toContain("compaction:start:threshold"); }); + it("resolves a pending retry before active-goal compaction continuation returns", async () => { + // Codex review on #3175: a retry can succeed with a non-empty text stop + // that is already over the active-goal compaction threshold. If the + // compaction pre-empt schedules its own continuation before the normal + // bottom-of-handler `#resolveRetry()` call runs, the session stays + // `isRetrying` and later prompt/idle gates remain blocked. + vi.useRealTimers(); + const now = Date.now(); + session.setGoalModeState({ + enabled: true, + mode: "active", + goal: { + id: "goal-retry-threshold", + objective: "recover from retry and compact", + status: "active", + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: now, + updatedAt: now, + }, + }); + session.settings.set("compaction.thresholdTokens", 76384); + session.settings.set("compaction.thresholdPercent", -1); + session.settings.set("compaction.autoContinue", true); + session.settings.set("contextPromotion.enabled", false); + session.settings.set("retry.enabled", true); + session.settings.set("retry.baseDelayMs", 5); + session.settings.set("retry.maxDelayMs", 5_000); + session.settings.set("retry.maxRetries", 1); + session.settings.set("retry.modelFallback", false); + + vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); + vi.spyOn(session.agent, "continue").mockImplementation(async () => { + session.agent.clearAllQueues(); + }); + + const { promise: retryStarted, resolve: onRetryStarted } = Promise.withResolvers(); + const { promise: retryEnded, resolve: onRetryEnded } = Promise.withResolvers(); + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_retry_start") onRetryStarted(); + if (event.type === "auto_retry_end") onRetryEnded(); + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + + const retryableError = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Transient provider failure." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "error" as const, + errorMessage: "503 service unavailable: overloaded_error retry-after-ms=50", + usage: { + input: 100, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: now - 1, + }; + session.agent.emitExternalEvent({ type: "message_end", message: retryableError }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [retryableError] }); + + await withTimeout(retryStarted, 1000, "Retry start timed out"); + expect(session.isRetrying).toBe(true); + + const recoveredOverThreshold = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Recovered; continuing the active goal." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 5000, + output: 1000, + cacheRead: 85000, + cacheWrite: 0, + totalTokens: 91000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: now, + }; + session.agent.emitExternalEvent({ type: "message_end", message: recoveredOverThreshold }); + await withTimeout(retryEnded, 1000, "Retry end timed out"); + expect(session.isRetrying).toBe(true); + + session.agent.emitExternalEvent({ type: "agent_end", messages: [recoveredOverThreshold] }); + + await withTimeout(compactionDone, 1000, "Compaction end timed out"); + await session.waitForIdle(); + + expect(getRuntimeSignals()).toContain("compaction:start:threshold"); + expect(session.isRetrying).toBe(false); + }); + it("removes orphan toolUse assistant before active-goal threshold compaction continuation", async () => { // Codex review on #3175: when an active goal turn is over threshold AND // stops with an empty `toolUse` (no tool call), the new ordering must NOT