diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 518804365879..98f1120f6191 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -59,6 +59,8 @@ const STATUS_LABEL_BY_STATUS: Partial< input: { label: "Input", className: "text-foreground-secondary" }, working: { label: "Working", className: "text-adaptive-sky-600-400" }, failed: { label: "Failed", className: "text-danger-foreground" }, + // A usage limit is a wait, not a break, so it takes the approval tone. + limited: { label: "Limited", className: "text-warning-foreground" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -778,7 +780,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} - {status === "failed" && thread.session?.lastError ? ( + {(status === "failed" || status === "limited") && thread.session?.lastError ? ( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 0ee78b2e3fa6..bf4fcbf2c6fc 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -159,6 +159,28 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status(thread)).toBe("approval"); }); + it("resolves limited only when a usage limit stopped the session", () => { + const errored = (lastErrorClass: "usage_limit" | null) => + makeThread({ + id: ThreadId.make("t"), + title: "t", + session: { + threadId: ThreadId.make("t"), + status: "error", + providerName: "Claude", + providerInstanceId: ProviderInstanceId.make("claude"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: "stopped", + lastErrorClass, + updatedAt: NOW, + }, + }); + + expect(resolveThreadListV2Status(errored("usage_limit"))).toBe("limited"); + expect(resolveThreadListV2Status(errored(null))).toBe("failed"); + }); + it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3629e63df462..c1c410cd08db 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -33,7 +33,7 @@ export { snoozeWakeLabel }; * (approval), "in motion" (working), and "broken" (failed). Ready is the * unlabeled resting state. */ -export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; +export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "limited" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; export function resolveThreadListV2SnoozeMenuSelection(input: { @@ -144,7 +144,7 @@ export function resolveThreadListV2Status( return "working"; } if (thread.session?.status === "error") { - return "failed"; + return thread.session.lastErrorClass === "usage_limit" ? "limited" : "failed"; } return "ready"; } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e4638a329b6c..84953725e323 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1237,6 +1237,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti runtimeMode: event.payload.session.runtimeMode, activeTurnId: event.payload.session.activeTurnId, lastError: event.payload.session.lastError, + lastErrorClass: event.payload.session.lastErrorClass ?? null, updatedAt: event.payload.session.updatedAt, }); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5f82a26e2a36..b3920c58da87 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -365,6 +365,7 @@ function mapSessionRow( runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + ...(row.lastErrorClass !== null ? { lastErrorClass: row.lastErrorClass } : {}), updatedAt: row.updatedAt, }; } @@ -688,6 +689,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + last_error_class AS "lastErrorClass", updated_at AS "updatedAt" FROM projection_thread_sessions ORDER BY thread_id ASC @@ -709,6 +711,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.runtime_mode AS "runtimeMode", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", + sessions.last_error_class AS "lastErrorClass", sessions.updated_at AS "updatedAt" FROM projection_thread_sessions sessions INNER JOIN projection_threads threads @@ -734,6 +737,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.runtime_mode AS "runtimeMode", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", + sessions.last_error_class AS "lastErrorClass", sessions.updated_at AS "updatedAt" FROM projection_thread_sessions sessions INNER JOIN projection_threads threads @@ -1117,6 +1121,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sessions.runtime_mode AS "runtimeMode", sessions.active_turn_id AS "activeTurnId", sessions.last_error AS "lastError", + sessions.last_error_class AS "lastErrorClass", sessions.updated_at AS "updatedAt" FROM projection_threads AS threads LEFT JOIN projection_thread_sessions AS sessions @@ -1376,6 +1381,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + last_error_class AS "lastErrorClass", updated_at AS "updatedAt" FROM projection_thread_sessions WHERE thread_id = ${threadId} @@ -2037,6 +2043,7 @@ pending_approval_requests AS ( runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + ...(row.lastErrorClass !== null ? { lastErrorClass: row.lastErrorClass } : {}), updatedAt: row.updatedAt, }); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..a31af18127f6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3367,6 +3367,74 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("runtime exploded"); + expect(thread.session?.lastErrorClass ?? null).toBeNull(); + }); + + it("carries a usage-limit class from runtime.error through the failed turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-limit-turn-started"), + provider: ProviderDriverKind.make("claude"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-limit"), + }); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-limit-runtime-error"), + provider: ProviderDriverKind.make("claude"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-limit"), + payload: { + message: "Claude usage limit reached.", + class: "usage_limit", + }, + }); + + await waitForThread( + harness.readModel, + (entry) => + entry.session?.status === "error" && entry.session?.lastErrorClass === "usage_limit", + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-limit-turn-completed"), + provider: ProviderDriverKind.make("claude"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-limit"), + payload: { + state: "failed", + errorMessage: "Claude usage limit reached.", + }, + }); + + const failed = await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "error" && entry.session?.activeTurnId === null, + ); + expect(failed.session?.lastErrorClass).toBe("usage_limit"); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-limit-session-ready"), + provider: ProviderDriverKind.make("claude"), + threadId: asThreadId("thread-1"), + createdAt: now, + payload: { state: "ready" }, + }); + + const ready = await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "ready", + ); + expect(ready.session?.lastErrorClass ?? null).toBeNull(); }); it("records runtime.error activities from the typed payload message", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d34fee4f981..1ded878a7b25 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1605,6 +1605,12 @@ const make = Effect.gen(function* () { : status === "ready" || status === "interrupted" ? null : (thread.session?.lastError ?? null); + // Set by the runtime.error that precedes a failed turn.completed, so + // it rides along with lastError instead of being re-derived here. + const lastErrorClass = + status === "ready" || status === "interrupted" + ? null + : (thread.session?.lastErrorClass ?? null); if (shouldApplyThreadLifecycle) { if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { @@ -1641,6 +1647,7 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, + lastErrorClass, updatedAt: now, }, createdAt: now, @@ -1936,6 +1943,7 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: eventTurnId ?? null, lastError: runtimeErrorMessage, + lastErrorClass: event.payload.class === "usage_limit" ? "usage_limit" : null, updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index dcb750983a00..66ad6af7ad5d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -28,6 +28,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode, active_turn_id, last_error, + last_error_class, updated_at ) VALUES ( @@ -38,6 +39,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ${row.runtimeMode}, ${row.activeTurnId}, ${row.lastError}, + ${row.lastErrorClass}, ${row.updatedAt} ) ON CONFLICT (thread_id) @@ -48,6 +50,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode = excluded.runtime_mode, active_turn_id = excluded.active_turn_id, last_error = excluded.last_error, + last_error_class = excluded.last_error_class, updated_at = excluded.updated_at `, }); @@ -65,6 +68,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { runtime_mode AS "runtimeMode", active_turn_id AS "activeTurnId", last_error AS "lastError", + last_error_class AS "lastErrorClass", updated_at AS "updatedAt" FROM projection_thread_sessions WHERE thread_id = ${threadId} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index bc176f62cc3c..cf7929027787 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps. import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; +import Migration0050 from "./Migrations/050_ProjectionThreadSessionsLastErrorClass.ts"; /** * Migration loader with all migrations defined inline. @@ -122,6 +123,7 @@ const migrationEntries = [ [47, "ProjectionProjectIcon", Migration0047], [48, "ProjectionThreadBranchPullRequest", Migration0048], [49, "ProjectionThreadsActiveOrderKey", Migration0049], + [50, "ProjectionThreadSessionsLastErrorClass", Migration0050], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadSessionsLastErrorClass.test.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadSessionsLastErrorClass.test.ts new file mode 100644 index 000000000000..a2ff7bc09cde --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadSessionsLastErrorClass.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("050_ProjectionThreadSessionsLastErrorClass", (it) => { + it.effect("adds the nullable last error class to thread session projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 49 }); + yield* runMigrations({ toMigrationInclusive: 50 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_thread_sessions) + `; + const lastErrorClass = columns.find((column) => column.name === "last_error_class"); + + assert.equal(lastErrorClass?.name, "last_error_class"); + assert.equal(lastErrorClass?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadSessionsLastErrorClass.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadSessionsLastErrorClass.ts new file mode 100644 index 000000000000..fbd74c9d6c3c --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadSessionsLastErrorClass.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + if (!columns.some((column) => column.name === "last_error_class")) { + yield* sql` + ALTER TABLE projection_thread_sessions + ADD COLUMN last_error_class TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 7cecac33eb6a..0d7d4bea08a8 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -9,6 +9,7 @@ import { RuntimeMode, IsoDateTime, + OrchestrationSessionErrorClass, OrchestrationSessionStatus, ProviderInstanceId, ThreadId, @@ -29,6 +30,7 @@ export const ProjectionThreadSession = Schema.Struct({ runtimeMode: RuntimeMode, activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), + lastErrorClass: Schema.NullOr(OrchestrationSessionErrorClass), updatedAt: IsoDateTime, }); export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 288db92799bc..df292181afff 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2302,13 +2302,21 @@ describe("ClaudeAdapterLive", () => { uuid: "result-auth", } as unknown as SDKMessage); - const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber))); + const events = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const payload = completedTurn(events); assert.equal(payload.state, state); if (errorMessage === undefined) { assert.equal(payload.errorMessage, undefined); } else { assert.match(payload.errorMessage ?? "", errorMessage); } + // Only a usage limit is classed as one; every other failure stays a + // provider error so clients keep reading it as Failed. + for (const event of events) { + if (event.type === "runtime.error") { + assert.equal(event.payload.class, "provider_error"); + } + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -2355,12 +2363,16 @@ describe("ClaudeAdapterLive", () => { uuid: "result-limit", } as unknown as SDKMessage); - const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber))); + const events = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const payload = completedTurn(events); assert.equal(payload.state, "failed"); assert.equal( payload.errorMessage, "Claude usage limit reached. Send the message again once the limit resets.", ); + const runtimeError = events.find((event) => event.type === "runtime.error"); + assert(runtimeError?.type === "runtime.error"); + assert.equal(runtimeError.payload.class, "usage_limit"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81a4a197e9db..d0da5b88d413 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -43,6 +43,7 @@ import { type TurnTokenUsage, type ProviderUserInputAnswers, type RuntimeContentStreamKind, + type RuntimeErrorClass, RuntimeItemId, RuntimeRequestId, RuntimeTaskId, @@ -2274,6 +2275,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context: ClaudeSessionContext, message: string, cause?: unknown, + errorClass: RuntimeErrorClass = "provider_error", ) { if (cause !== undefined) { void cause; @@ -2289,7 +2291,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { message, - class: "provider_error", + class: errorClass, ...(cause !== undefined ? { detail: cause } : {}), }, providerRefs: nativeProviderRefs(context), @@ -3266,15 +3268,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const turn = context.turnState; + const usageLimited = + turn !== undefined && + (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited); const failureHint = turn?.authenticationFailureMessage ?? - (turn && (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited) + (usageLimited ? "Claude usage limit reached. Send the message again once the limit resets." : undefined); const { status, errorMessage } = resultOutcome(message, failureHint); if (status === "failed") { - yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); + // Classed so clients can show the stop as Limited; the turn still fails. + yield* emitRuntimeError( + context, + errorMessage ?? "Claude turn failed.", + undefined, + usageLimited || message.terminal_reason === "blocking_limit" + ? "usage_limit" + : "provider_error", + ); } yield* completeTurn(context, status, errorMessage, message); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index f7c6036885d9..e6d59bdbf7d6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -2865,6 +2865,7 @@ usageLimitLayer("CodexAdapterLive usage limits", (it) => { if (event.type === "runtime.error") { NodeAssert.equal(event.payload.message, expected); NodeAssert.equal(event.payload.detail, CODEX_OUT_OF_CREDITS); + NodeAssert.equal(event.payload.class, "usage_limit"); } if (event.type === "turn.completed") { NodeAssert.equal(event.payload.errorMessage, expected); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2d88e58dc1fb..fff6ec172d06 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2386,7 +2386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( type: "runtime.error", payload: { message: usageLimitMessage, - class: "provider_error", + class: "usage_limit", ...(turnError.message ? { detail: turnError.message } : {}), }, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..5a3758e6859d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1802,6 +1802,12 @@ export default function ChatView(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; + // The class describes the session's error; a newer local error shown in + // its place is an ordinary failure. + const threadErrorClass = + isServerThread && localServerError === null + ? (activeServerThread?.session?.lastErrorClass ?? null) + : null; // Dismissals can only mask the shown error, never clear it: a server thread // keeps its error in session.lastError, so clearing the local shadow would // just fall through to the persisted one. Mask the current error until a @@ -8210,6 +8216,7 @@ export default function ChatView(props: ChatViewProps) { /> { setThreadError(activeThread.id, null); dismissThreadErrorBannerForSession(threadErrorBannerKey); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4a0584821a9b..a1596da24b46 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -395,6 +395,18 @@ describe("shouldRecedeSidebarThread", () => { expect(shouldRecedeSidebarThread({ ...input, isActive: true })).toBe(false); expect(shouldRecedeSidebarThread({ ...input, isSelected: true })).toBe(false); }); + + it.each(["failed", "limited"] as const)("keeps a %s thread prominent", (status) => { + expect( + shouldRecedeSidebarThread({ + status, + isUnread: false, + isWoke: false, + isActive: false, + isSelected: false, + }), + ).toBe(false); + }); }); describe("createThreadJumpHintVisibilityController", () => { @@ -797,6 +809,31 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("ready"); }); + it("reports limited when a usage limit stopped the session", () => { + expect( + resolveSidebarThreadStatus({ + ...idle, + session: { + ...session, + status: "error" as const, + lastError: "Claude usage limit reached.", + lastErrorClass: "usage_limit" as const, + }, + }), + ).toBe("limited"); + expect( + resolveSidebarThreadStatus({ + ...idle, + session: { + ...session, + status: "error" as const, + lastError: "boom", + lastErrorClass: null, + }, + }), + ).toBe("failed"); + }); + it("defaults to ready with no session", () => { expect(resolveSidebarThreadStatus({ ...idle, session: null })).toBe("ready"); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9b59675e2eb1..20349c3ae258 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -793,6 +793,7 @@ export type SidebarThreadStatus = | "working" | "monitoring" | "failed" + | "limited" | "ready"; export function shouldRecedeSidebarThread(input: { @@ -828,7 +829,7 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si // A failed session outranks lingering background liveness: the user must // see the failure, not a stale Working (review finding). if (thread.session?.status === "error") { - return "failed"; + return thread.session.lastErrorClass === "usage_limit" ? "limited" : "failed"; } // Background work outlives the turn: fleets read as working; monitoring // only when watch loops are the sole live work. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ebc1078e672b..aaa5316b3059 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1134,19 +1134,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: null, className: "text-red-700 dark:text-red-300", } - : isWoke + : status === "limited" ? { - label: "Woke", - icon: "woke" as const, + // A usage limit is a wait, not a break: it takes the + // waiting tone Approval uses, not the failure red. + label: "Limited", + icon: null, className: "text-amber-700 dark:text-amber-300", } - : isUnread + : isWoke ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", + label: "Woke", + icon: "woke" as const, + className: "text-amber-700 dark:text-amber-300", } - : null; + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; const isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ @@ -1435,7 +1443,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? "text-secondary-label" : isUnread || isWoke ? "text-foreground" - : status === "failed" + : status === "failed" || status === "limited" ? "text-foreground/95" : "text-foreground/90", ) diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 29a6a6db1846..9c608325e0d3 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,7 +1,8 @@ +import type { OrchestrationSessionErrorClass } from "@t3tools/contracts"; import { memo } from "react"; import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; -import { CircleAlertIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { @@ -35,21 +36,25 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null) export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, + errorClass, onDismiss, }: { error: string | null; + /** A usage limit is a wait, not a break, so it takes the warning tone. */ + errorClass?: OrchestrationSessionErrorClass | null | undefined; onDismiss?: () => void; }) { if (!error) return null; + const variant = errorClass === "usage_limit" ? "warning" : "error"; return (
- + {variant === "warning" ? : } }>{error} @@ -61,7 +66,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ {onDismiss && ( )} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b2c244a6123e..5f443ea865c4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -66,6 +66,12 @@ If dragging is unavailable for one environment, update the T3 Code server runnin environment. Pinned and active reordering require server support. Threads from older servers keep their default order until the server is updated. +## Thread status + +A thread whose agent stopped on an error shows **Failed**. When the provider's usage +limit stopped it, the status reads **Limited** instead, and the thread can continue +once that limit resets. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index db520ffe8808..26f984798939 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1166,6 +1166,23 @@ it.effect("decodes orchestration session runtime mode defaults", () => updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); + // Sessions from servers predating the classification still decode. + assert.strictEqual(parsed.lastErrorClass, undefined); + }), +); + +it.effect("decodes a usage-limited orchestration session", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationSession({ + threadId: "thread-1", + status: "error", + providerName: "claude", + activeTurnId: null, + lastError: "Claude usage limit reached.", + lastErrorClass: "usage_limit", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.lastErrorClass, "usage_limit"); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4d2f80a1101a..164c139e3a58 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -520,6 +520,12 @@ export const OrchestrationSessionStatus = Schema.Literals([ ]); export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; +/** The session only needs "limit or not", so it carries this narrow literal + instead of the runtime's error class (providerRuntime.ts already imports + from this module, so importing back would be a cycle). */ +export const OrchestrationSessionErrorClass = Schema.Literals(["usage_limit"]); +export type OrchestrationSessionErrorClass = typeof OrchestrationSessionErrorClass.Type; + export const OrchestrationSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, @@ -528,6 +534,8 @@ export const OrchestrationSession = Schema.Struct({ runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), + // Optional so payloads from servers predating the field still decode. + lastErrorClass: Schema.optional(Schema.NullOr(OrchestrationSessionErrorClass)), updatedAt: IsoDateTime, }); export type OrchestrationSession = typeof OrchestrationSession.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index af1baac74f9d..795acf8dc3ba 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -96,6 +96,7 @@ export type RuntimeSessionExitKind = typeof RuntimeSessionExitKind.Type; const RuntimeErrorClass = Schema.Literals([ "provider_error", + "usage_limit", "transport_error", "permission_error", "validation_error",