From bb14412e8741ffefe13b119a4c62af12c8c55fee Mon Sep 17 00:00:00 2001 From: Rohan Santhosh Kumar <181558744+Rohan5commit@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:35:04 +0800 Subject: [PATCH] fix(reply): classify billing cooldown summaries (#66363) Merged via squash. Prepared head SHA: 8cfc42a7ac31f98501789ac4dbd79f5a962e3611 Co-authored-by: Rohan5commit <181558744+Rohan5commit@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../reply/agent-runner-execution.test.ts | 58 +++++++++++++++++++ .../reply/agent-runner-execution.ts | 12 +++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a74904a2376..e70946065c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit. - Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit. - Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost`↔`127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit. +- Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit. ## 2026.4.14 diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 25838204dc9..7dfc2d142ce 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -912,6 +912,64 @@ describe("runAgentTurnWithFallback", () => { } }); + it("surfaces billing guidance for pure billing cooldown fallback exhaustion", async () => { + state.runWithModelFallbackMock.mockRejectedValueOnce( + Object.assign( + new Error( + "All models failed (2): anthropic/claude-opus-4-6: Provider anthropic has billing issue (skipping all models) (billing) | anthropic/claude-sonnet-4-6: Provider anthropic has billing issue (skipping all models) (billing)", + ), + { + name: "FallbackSummaryError", + attempts: [ + { + provider: "anthropic", + model: "claude-opus-4-6", + error: "Provider anthropic has billing issue (skipping all models)", + reason: "billing", + }, + { + provider: "anthropic", + model: "claude-sonnet-4-6", + error: "Provider anthropic has billing issue (skipping all models)", + reason: "billing", + }, + ], + soonestCooldownExpiry: Date.now() + 60_000, + }, + ), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + commandBody: "hello", + followupRun: createFollowupRun(), + sessionCtx: { + Provider: "whatsapp", + MessageSid: "msg", + } as unknown as TemplateContext, + opts: {}, + typingSignals: createMockTypingSignaler(), + blockReplyPipeline: null, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + applyReplyToMode: (payload) => payload, + shouldEmitToolResult: () => true, + shouldEmitToolOutput: () => false, + pendingToolTasks: new Set(), + resetSessionAfterCompactionFailure: async () => false, + resetSessionAfterRoleOrderingConflict: async () => false, + isHeartbeat: false, + sessionKey: "main", + getActiveSessionEntry: () => undefined, + resolvedVerboseLevel: "off", + }); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe("billing"); + } + }); + it("surfaces gateway restart text when fallback exhaustion wraps a drain error", async () => { const { replyOperation, failMock } = createMockReplyOperation(); state.runWithModelFallbackMock.mockRejectedValueOnce( diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 1dde3420210..c1e6c37c245 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -311,6 +311,14 @@ function isPureTransientRateLimitSummary(err: unknown): boolean { ); } +function isPureBillingSummary(err: unknown): boolean { + return ( + isFallbackSummaryError(err) && + err.attempts.length > 0 && + err.attempts.every((attempt) => attempt.reason === "billing") + ); +} + function isToolResultTurnMismatchError(message: string): boolean { const lower = normalizeLowercaseStringOrEmpty(message); return ( @@ -1320,7 +1328,9 @@ export async function runAgentTurnWithFallback(params: { continue; } const message = formatErrorMessage(err); - const isBilling = isBillingErrorMessage(message); + const isBilling = isFallbackSummaryError(err) + ? isPureBillingSummary(err) + : isBillingErrorMessage(message); const isContextOverflow = !isBilling && isLikelyContextOverflowError(message); const isCompactionFailure = !isBilling && isCompactionFailureError(message); const isSessionCorruption = /function call turn comes immediately after/i.test(message);