From ea55947ac8e0f3948ff7ccd400e73b1adc16c87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= <80332200+samaluk@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:29:59 -0300 Subject: [PATCH] fix(codex): show neutral wording for exhausted HTTP 429 retries --- .../threads/UsageLimitRecoveryCard.tsx | 6 ++- .../src/features/threads/thread-work-log.tsx | 13 +++--- apps/mobile/src/lib/threadActivity.test.ts | 18 ++++++-- apps/mobile/src/lib/threadActivity.ts | 6 ++- apps/web/src/components/ChatView.tsx | 1 + .../ThreadNotificationCoordinator.tsx | 3 +- .../components/chat/TimelineWorkEntryRow.tsx | 11 +++-- .../chat/UsageLimitRecoveryBanner.test.tsx | 20 +++++++++ .../chat/UsageLimitRecoveryBanner.tsx | 6 ++- .../components/sidebar/SidebarThreadRow.tsx | 3 +- apps/web/src/session-logic.test.ts | 29 ++++++++++--- apps/web/src/session-logic.ts | 5 ++- .../provider-core/src/server/failure.test.ts | 43 +++++++++++++++++++ packages/provider-core/src/server/failure.ts | 5 ++- .../shared/src/orchestrationV2ThreadError.ts | 11 +++++ 15 files changed, 146 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 791dc2ab5d847..0b2afb1e7d952 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -3,6 +3,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import * as DateTime from "effect/DateTime"; import { useState } from "react"; import { View } from "react-native"; @@ -71,9 +72,10 @@ export function UsageLimitRecoveryCard({ return ( + {providerFailureTitle(thread.runtime.lastErrorClass, thread.runtime.lastErrorCode)}.{" "} {resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` - : "The provider did not report a reset time. Retry manually when your limit is available."} + ? `Resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` + : "No reset time reported; retry manually"} {canSchedule ? ( diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 71944a6fa054f..9ea00ba6bea5b 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -55,6 +55,7 @@ import { environmentThreadDetails, threadEnvironment } from "../../state/threads import { useAtomCommand } from "../../state/use-atom-command"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { AppText as Text } from "../../components/ui/AppText"; @@ -894,11 +895,11 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( }) : null; const label = warning - ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}` + ? `${providerFailureTitle(failureItem.failure.class, failureItem.failure.code)}.${resetTime ? ` Retry after ${resetTime}.` : ""}` : row.summary; return ( props.onCopyRow(row.id, row.getCopyText())} > @@ -937,11 +938,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( })} - {!warning ? ( - - {failureItem.failure.message} - - ) : null} + + {failureItem.failure.message} + {failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE && failureItem.runId !== null ? ( { +it.each([ + ["provider_error", null, "Provider error"], + ["usage_limit", "usageLimitExceeded", "Usage limit reached"], + ["usage_limit", "responseTooManyFailedAttempts", "Provider rate limit (HTTP 429)"], +] as const)( + "keeps a historical %s / %s failure and preceding work visible without disclosures", + (failureClass, code, summary) => { const at = "2026-06-20T00:00:03.000Z"; const error: OrchestrationV2TurnItem = { ...base("failure", at, 2), type: "error", status: "failed", + title: failureClass === "usage_limit" ? "Usage limit reached" : null, failure: { class: failureClass, message: "The provider stopped this turn.\nRetry later.", - code: null, + code, retryable: true, }, }; @@ -1651,11 +1656,16 @@ it.each(["provider_error", "usage_limit"] as const)( "failure", ]); expect(activities.at(-1)).toMatchObject({ + summary, detail: error.failure.message, createdAt: at, canExpand: false, prominent: true, }); + const recovered = buildThreadFeed([ + projected({ ...error, status: "completed", title: "Provider recovered" }, 0), + ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + expect(recovered[0]?.summary).toBe("Provider recovered"); }, ); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fc085a7c50a0d..10f6988177db4 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -64,6 +64,7 @@ import { } from "@t3tools/shared/toolActivity"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import { truncate } from "@t3tools/shared/String"; import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; import type { McpAppReference } from "@t3tools/shared/mcpApp"; @@ -655,6 +656,9 @@ function itemSummary( if (item.type === "notification") return item.summary; if (item.type === "system_notice") return item.message; if (item.type === "compaction") return contextCompactionLabel(item); + if (item.type === "error" && item.status === "failed" && item.failure.class === "usage_limit") { + return providerFailureTitle(item.failure.class, item.failure.code); + } const title = (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input, item.title) @@ -685,7 +689,7 @@ function itemSummary( case "run_interrupt_result": return "Run interrupted"; case "error": - return item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"; + return providerFailureTitle(item.failure.class, item.failure.code); case "handoff": return "Context handed off"; case "fork": diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253cc1ea2e84b..72e96bd218bab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2463,6 +2463,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.latestRun ? usageLimitRecoveryBannerItem({ runId: activeThreadShell.latestRun.runId, + failureCode: serverRuntime.lastErrorCode ?? null, resetAt: serverRuntime.usageLimitResetAt ?? null, stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fa4fdbb672faa..641f0b70fc72a 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -4,6 +4,7 @@ import { presentThreadShell, } from "@t3tools/client-runtime/state/models"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import type { AnyRouter } from "@tanstack/react-router"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; @@ -90,7 +91,7 @@ export function startThreadNotifications( : status === "approval" ? "Approval needed" : status === "limited" - ? "Usage limit reached" + ? providerFailureTitle(thread.runtime?.lastErrorClass, thread.runtime?.lastErrorCode) : status === "failed" ? "Thread failed" : status === "request" diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index a8d5e3e68de77..749701cae1f0e 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -24,6 +24,7 @@ import { turnItemNeedsDetailFetch, } from "@t3tools/client-runtime/work-log/item-detail"; import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import { memo, use, useState, type KeyboardEvent } from "react"; import { workEntryDisplayIndicatesToolFailure, @@ -209,7 +210,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { const resetAt = failureItem.failure.resetAt; const resetTime = resetAt ? formatUpcomingTimestamp(resetAt, timestampFormat) : null; const label = warning - ? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}` + ? `${providerFailureTitle(failureItem.failure.class, failureItem.failure.code)}.${resetTime ? ` Retry after ${resetTime}.` : ""}` : workEntry.label; const retryRunId = failureItem.runId !== null && @@ -242,11 +243,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { /> } > - {!warning ? ( -

- {failureItem.failure.message} -

- ) : null} +

+ {failureItem.failure.message} +

{retryRunId !== null && onRetryWorkspacePreparation ? (
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f905376dc341a..e84b0337d8251 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -131,13 +131,30 @@ describe("V2 session presentation", () => { }, } satisfies Extract; - expect( - providerErrorPresentation({ + for (const [code, label] of [ + ["responseTooManyFailedAttempts", "Provider rate limit (HTTP 429)"], + ["usageLimitExceeded", "Usage limit reached"], + ] as const) { + const failed = { ...retryItem, - status: "failed", - failure: { ...retryItem.failure, class: "usage_limit" }, - }), - ).toMatchObject({ label: "Usage limit reached after 2/10 retries" }); + status: "failed" as const, + title: "Usage limit reached", + failure: { + class: "usage_limit" as const, + code, + message: "exceeded retry limit, last status: 429 Too Many Requests", + retryable: null, + }, + }; + expect(providerErrorPresentation(failed)).toEqual({ + label: `${label} after 2/10 retries`, + detail: failed.failure.message, + }); + expect(providerErrorPresentation({ ...failed, retry: undefined })).toEqual({ + label, + detail: failed.failure.message, + }); + } const recoveredLimit = { ...retryItem, status: "completed" as const, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 37d7ad27be5a7..a68f961b160a5 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -24,6 +24,7 @@ import { formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import type { McpAppReference } from "@t3tools/shared/mcpApp"; import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; @@ -368,7 +369,7 @@ export function providerErrorPresentation( return { label: item.failure.class === "usage_limit" - ? "Usage limit reached" + ? providerFailureTitle(item.failure.class, item.failure.code) : item.title?.trim() || "Provider error", detail: item.failure.message, }; @@ -383,7 +384,7 @@ export function providerErrorPresentation( : item.status === "completed" ? `Provider recovered (${progress} retries)` : item.status === "failed" - ? `${item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"} after ${progress} retries` + ? `${providerFailureTitle(item.failure.class, item.failure.code)} after ${progress} retries` : `Provider retry stopped (${progress})`; const retryDelay = item.status === "running" && item.retry.retryDelayMs !== null && item.retry.retryDelayMs > 0 diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index 8fb2e153aa109..cf7e9b1dc4991 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -14,6 +14,7 @@ import * as Effect from "effect/Effect"; import { makeProviderFailure, makeProviderFailureTurnItem, + makeProviderRetryTurnItem, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH, ContextHandoffBudgetError, ContextHandoffBackgroundWorkError, @@ -174,6 +175,48 @@ it("does not serialize arbitrary provider causes", () => { }); }); +it.each([ + ["usage_limit", "responseTooManyFailedAttempts", "Provider rate limit (HTTP 429)"], + ["usage_limit", "usageLimitExceeded", "Usage limit reached"], + ["usage_limit", "rateLimitExceeded", "Usage limit reached"], + ["provider_error", "responseTooManyFailedAttempts", "Provider error"], +] as const)( + "titles %s / %s failures without changing recovery evidence", + (failureClass, code, title) => { + const failure = makeProviderFailure({ + class: failureClass, + code, + message: "Provider explanation retained.", + retryable: null, + }); + const occurredAt = DateTime.makeUnsafe("2026-10-28T15:03:08.000Z"); + const base = { + driver: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread:failure-title"), + runId: RunId.make("run:failure-title"), + nodeId: null, + providerThreadId: ProviderThreadId.make("provider-thread:failure-title"), + providerTurnId: ProviderTurnId.make("provider-turn:failure-title"), + itemOrdinal: 1, + failure, + }; + const terminal = makeProviderFailureTurnItem({ ...base, occurredAt }); + const exhausted = makeProviderRetryTurnItem({ + ...base, + retry: { attempt: 2, maxAttempts: null, retryDelayMs: null }, + status: "failed", + startedAt: occurredAt, + updatedAt: occurredAt, + }); + for (const item of [terminal, exhausted]) { + assert.equal(item.title, title); + assert.deepEqual(item.failure, failure); + assert.equal(item.status, "failed"); + assert.isUndefined(item.failure.resetAt); + } + }, +); + it.effect("keys terminal failure items by provider turn across retries and fallback paths", () => Effect.gen(function* () { const idAllocator = yield* IdAllocator.IdAllocatorV2; diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index b0ac1ed8b938d..437de0c64cff9 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -12,6 +12,7 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Cause from "effect/Cause"; +import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError"; import * as IdAllocator from "./IdAllocator.ts"; import type { ProviderAdapterFailureReason } from "./ProviderAdapter.ts"; @@ -272,7 +273,7 @@ export function makeProviderFailureTurnItem(input: { parentItemId: null, ordinal: input.itemOrdinal, status: "failed", - title: input.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error", + title: providerFailureTitle(input.failure.class, input.failure.code), startedAt: input.retryStartedAt ?? input.occurredAt, completedAt: input.occurredAt, updatedAt: input.occurredAt, @@ -304,7 +305,7 @@ export function makeProviderRetryTurnItem(input: { if (input.status === "completed") { title = "Provider recovered"; } else if (input.status === "failed") { - title = input.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"; + title = providerFailureTitle(input.failure.class, input.failure.code); } else if (input.status === "interrupted" || input.status === "cancelled") { title = "Provider retry stopped"; } diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index 5b9940729e2e1..12f9821110a91 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -5,6 +5,17 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +export function providerFailureTitle( + failureClass: OrchestrationV2ProviderFailure["class"] | null | undefined, + code: string | null | undefined, +): string { + if (failureClass !== "usage_limit") return "Provider error"; + // Codex's usage_limit retry exhaustion retains HTTP 429, but loses the upstream reason. + return code === "responseTooManyFailedAttempts" + ? "Provider rate limit (HTTP 429)" + : "Usage limit reached"; +} + type ErrorTurnItem = Extract; /** Only error items are read, so callers may pass items stripped of other fields. */ type FailureScanTurnItem =