Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,9 +72,10 @@ export function UsageLimitRecoveryCard({
return (
<View className="mx-3 mb-2 gap-2 rounded-xl border border-warning-foreground/25 bg-screen p-3">
<Text className="text-sm text-warning-foreground">
{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"}
</Text>
{canSchedule ? (
<View className="flex-row flex-wrap gap-2">
Expand Down
13 changes: 6 additions & 7 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<WorkLogPressable
accessibilityLabel={warning ? label : `${row.summary}: ${failureItem.failure.message}`}
accessibilityLabel={`${label}: ${failureItem.failure.message}`}
accessibilityHint="Long press to copy."
onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
>
Expand Down Expand Up @@ -993,11 +994,9 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
})}
</Text>
</View>
{!warning ? (
<Text selectable className="ml-7 text-sm text-foreground">
{failureItem.failure.message}
</Text>
) : null}
<Text selectable className="ml-7 text-sm text-foreground">
{failureItem.failure.message}
</Text>
{failureItem.failure.code === ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE &&
failureItem.runId !== null ? (
<WorkspacePreparationRetryButton
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1568,18 +1568,23 @@ it("stops stranded thinking after a steer and follows the next thought or tool",
expect(first.status).toBe("running");
});

it.each(["provider_error", "usage_limit"] as const)(
"keeps a historical %s failure and preceding work visible without disclosures",
(failureClass) => {
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,
},
};
Expand Down Expand Up @@ -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");
},
);

Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/components/chat/TimelineWorkEntryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -249,11 +250,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
/>
}
>
{!warning ? (
<p className="ms-7 whitespace-pre-wrap break-words py-1 text-sm leading-relaxed text-foreground/80">
{failureItem.failure.message}
</p>
) : null}
<p className="ms-7 whitespace-pre-wrap break-words py-1 text-sm leading-relaxed text-foreground/80">
{failureItem.failure.message}
</p>
{retryRunId !== null && onRetryWorkspacePreparation ? (
<div className="ms-7 pb-1">
<Button
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) {
}

describe("usageLimitRecoveryBannerItem", () => {
it.each([
["responseTooManyFailedAttempts", "Provider rate limit (HTTP 429)"],
["usageLimitExceeded", "Usage limit reached"],
] as const)("keeps %s manually recoverable without a reported reset", (errorCode, title) => {
const banner = usageLimitRecoveryBannerItem({
runId: RunId.make("run-1"),
errorCode,
resetAt: null,
stoppedAt: stoppedAt.toISOString(),
snoozedUntil: null,
recovery: null,
timestampFormat: "24-hour",
onChange: async () => {},
});
expect(banner.title).toBe(title);
expect(banner.description).toBe("No reset time reported; retry manually");
expect(banner.actions).toBeNull();
});

it.each([
["12-hour", "Resets 5:30 PM"],
["24-hour", "Resets 17:30"],
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@t3tools/contracts";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { deadlineStore } from "@t3tools/client-runtime/clock";
import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError";
import {
CHATGPT_USAGE_LIMIT_CODE,
CHATGPT_WORKSPACE_USAGE_LIMIT_CODE,
Expand Down Expand Up @@ -41,13 +42,13 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann
) : null;
const resetDescription = resetAt
? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}`
: "Reset time unavailable; retry manually";
: "No reset time reported; retry manually";
if (!chatGptLimit) {
return {
id: `usage-limit-recovery:${runId}`,
variant: "warning",
icon: <GaugeIcon />,
title: "Usage limit reached",
title: providerFailureTitle("usage_limit", errorCode),
description: resetDescription,
actions: recoveryActions,
};
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard";
import { providerFailureTitle } from "@t3tools/shared/orchestrationV2ThreadError";
import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators";
import { discardComposerDraft } from "../../lib/discardComposerDraft";
import { requestCustomSnooze } from "../CustomSnoozeDialog";
Expand Down Expand Up @@ -356,7 +357,7 @@ function SidebarThreadTooltip({
<CircleAlertIcon className="size-3 shrink-0 stroke-current" />
<div className="min-w-0 truncate">
{thread.runtime.lastErrorClass === "usage_limit"
? "Usage limit reached"
? providerFailureTitle(thread.runtime.lastErrorClass, thread.runtime.lastErrorCode)
: "Error occurred"}
</div>
</div>
Expand Down
29 changes: 23 additions & 6 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,30 @@ describe("V2 session presentation", () => {
},
} satisfies Extract<OrchestrationV2TurnItem, { readonly type: "error" }>;

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,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
};
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/provider-core/src/server/failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
knownProviderFailureMessage,
makeProviderFailure,
makeProviderFailureTurnItem,
makeProviderRetryTurnItem,
MAX_PROVIDER_FAILURE_MESSAGE_LENGTH,
ContextHandoffBudgetError,
ContextHandoffBackgroundWorkError,
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions packages/provider-core/src/server/failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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";
}
Expand Down
Loading
Loading