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 ae943c816140e..dca21df1360ed 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"; @@ -950,11 +951,11 @@ const ThreadWorkLogRow = function ThreadWorkLogRow( })} (${new Intl.DateTimeFormat().resolvedOptions().timeZone})` : 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())} > @@ -993,11 +994,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, }, }; @@ -1619,11 +1624,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 8cb5ccb043794..2de2ee0fa891c 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -63,6 +63,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"; @@ -581,6 +582,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) @@ -611,7 +615,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/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index a7134b481d131..306ac228b4896 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 { threadKey } from "@t3tools/client-runtime/state/entities"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; @@ -134,7 +135,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 95661d16b9307..a4139983ab1ff 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, @@ -216,7 +217,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? `${formatUpcomingTimestamp(resetAt, timestampFormat, undefined, timeZone)} (${timeZone})` : 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 && @@ -249,11 +250,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 df3eb85c3db38..95e812edf64b1 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 6cf2761d3d6d3..ef7ff82f655d3 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 04829ad8f263b..c2cf943ebf781 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -18,6 +18,7 @@ import { knownProviderFailureMessage, makeProviderFailure, makeProviderFailureTurnItem, + makeProviderRetryTurnItem, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH, ContextHandoffBudgetError, ContextHandoffBackgroundWorkError, @@ -258,6 +259,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 edaf0aa97a743..39bf61cf79911 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"; @@ -310,7 +311,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, @@ -342,7 +343,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 =