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 797781d16a4c..55c6f8181c2e 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -67,6 +67,8 @@ const STATUS_LABEL_BY_STATUS: Partial< input: { label: "Input", className: "text-adaptive-indigo-600-300" }, 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 { @@ -890,13 +892,15 @@ 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 afc00ef9ab40..1e8f2735a6ab 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -163,6 +163,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 e1cb8e9ece7f..1cac66d2c0cb 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -34,7 +34,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: { @@ -145,7 +145,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 b5e6cb0cdd54..da560a9618f0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1355,6 +1355,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 1e7058742e25..44f33d9f4a01 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -387,6 +387,7 @@ function mapSessionRow( runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + ...(row.lastErrorClass !== null ? { lastErrorClass: row.lastErrorClass } : {}), updatedAt: row.updatedAt, }; } @@ -862,6 +863,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 @@ -883,6 +885,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 @@ -908,6 +911,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 @@ -1298,6 +1302,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 @@ -1616,6 +1621,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} @@ -2289,6 +2295,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 d61739f72c21..d6ff4422bf2a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3929,6 +3929,152 @@ 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("drops the usage-limit class when a session error replaces the classified one", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-limit-before-replace"), + provider: ProviderDriverKind.make("claude"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-replace"), + payload: { + message: "Claude usage limit reached.", + class: "usage_limit", + }, + }); + + await waitForThread( + harness.readModel, + (entry) => entry.session?.lastErrorClass === "usage_limit", + ); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-session-error-replace"), + provider: ProviderDriverKind.make("claude"), + threadId: asThreadId("thread-1"), + createdAt: now, + payload: { state: "error", reason: "Socket closed" }, + }); + + const replaced = await waitForThread( + harness.readModel, + (entry) => entry.session?.lastError === "Socket closed", + ); + expect(replaced.session?.status).toBe("error"); + expect(replaced.session?.lastErrorClass ?? null).toBeNull(); + }); + + it("drops the usage-limit class when a failed turn replaces the error", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-limit-before-turn-fail"), + provider: ProviderDriverKind.make("claude"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-replace"), + payload: { + message: "Claude usage limit reached.", + class: "usage_limit", + }, + }); + + await waitForThread( + harness.readModel, + (entry) => entry.session?.lastErrorClass === "usage_limit", + ); + + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-fail-replace"), + provider: ProviderDriverKind.make("claude"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-replace"), + payload: { state: "failed", errorMessage: "Transport closed" }, + }); + + const replaced = await waitForThread( + harness.readModel, + (entry) => entry.session?.lastError === "Transport closed", + ); + expect(replaced.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 84cb34a4e783..c4f7fd5a3da1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1886,6 +1886,16 @@ 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. An + // event that replaces the stored error describes a different failure, + // so the inherited class must not outlive the error it classified. + const lastErrorClass = + status === "ready" || + status === "interrupted" || + lastError !== (thread.session?.lastError ?? null) + ? null + : (thread.session?.lastErrorClass ?? null); if (shouldApplyThreadLifecycle) { if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { @@ -1922,6 +1932,7 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, + lastErrorClass, updatedAt: now, }, createdAt: now, @@ -2422,6 +2433,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 18aae09febf8..3a6e0e7de380 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; +import Migration0054 from "./Migrations/054_ProjectionThreadSessionsLastErrorClass.ts"; /** * Migration loader with all migrations defined inline. @@ -130,6 +131,7 @@ const migrationEntries = [ [51, "ProjectionThreadMessageContext", Migration0051], [52, "ProjectionThreadTitleState", Migration0052], [53, "PullRequestFilesViewed", Migration0053], + [54, "ProjectionThreadSessionsLastErrorClass", Migration0054], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts new file mode 100644 index 000000000000..ae3466603d8d --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_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.layer({ filename: ":memory:" }))); + +layer("054_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: 53 }); + yield* runMigrations({ toMigrationInclusive: 54 }); + + 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/054_ProjectionThreadSessionsLastErrorClass.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.ts new file mode 100644 index 000000000000..fbd74c9d6c3c --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_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 ca596e6501cf..b2a7c8775da7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2538,13 +2538,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), @@ -2591,12 +2599,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), @@ -2632,21 +2644,25 @@ describe("ClaudeAdapterLive", () => { name: "an assistant-only rate limit", messages: [rateLimitAssistant], expected: usageLimitMessage, + expectedClass: "usage_limit", }, { name: "a normal parent response after a rate limit", messages: [rateLimitAssistant, { ...rateLimitAssistant, error: undefined }], expected: genericApiErrorMessage, + expectedClass: "provider_error", }, { name: "a server error after a rate limit", messages: [rateLimitAssistant, { ...rateLimitAssistant, error: "server_error" }], expected: genericApiErrorMessage, + expectedClass: "provider_error", }, { name: "a subagent rate limit", messages: [{ ...rateLimitAssistant, parent_tool_use_id: "nested-tool" }], expected: genericApiErrorMessage, + expectedClass: "provider_error", }, { name: "a subagent response after a parent rate limit", @@ -2655,8 +2671,9 @@ describe("ClaudeAdapterLive", () => { { ...rateLimitAssistant, error: undefined, parent_tool_use_id: "nested-tool" }, ], expected: usageLimitMessage, + expectedClass: "usage_limit", }, - ])("classifies the terminal API failure after $name", ({ messages, expected }) => { + ])("classifies the terminal API failure after $name", ({ messages, expected, expectedClass }) => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -2680,6 +2697,7 @@ describe("ClaudeAdapterLive", () => { const errors = events.filter((event) => event.type === "runtime.error"); assert.equal(errors.length, 1); assert.equal(errors[0]?.payload.message, expected); + assert.equal(errors[0]?.payload.class, expectedClass); assert.equal(completedTurn(events).state, "failed"); assert.equal(completedTurn(events).errorMessage, expected); }).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 13645a33a05b..14e10276e0a9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -48,6 +48,7 @@ import { type TurnTokenUsage, type ProviderUserInputAnswers, type RuntimeContentStreamKind, + type RuntimeErrorClass, RuntimeItemId, RuntimeRequestId, RuntimeTaskId, @@ -2489,6 +2490,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context: ClaudeSessionContext, message: string, cause?: unknown, + errorClass: RuntimeErrorClass = "provider_error", ) { if (cause !== undefined) { void cause; @@ -2504,7 +2506,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), @@ -3489,15 +3491,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 48aff6cd707e..91fae4024a69 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -2913,6 +2913,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 ba61d14f5cf5..beec086d3118 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2389,7 +2389,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 79d393fc8d1f..e8f2c6d1e1de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1917,6 +1917,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 @@ -9851,6 +9857,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 76960f3f533b..c84c54c766a0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -409,6 +409,18 @@ describe("shouldRecedeSidebarThread", () => { }), ).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", () => { @@ -811,6 +823,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 fcedf46ce507..9d0062aa0df0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -815,6 +815,7 @@ export type SidebarThreadStatus = | "working" | "monitoring" | "failed" + | "limited" | "ready"; export function shouldRecedeSidebarThread(input: { @@ -850,7 +851,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 a03196fc3b8d..5bf7ad39ce46 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -423,9 +423,18 @@ function SidebarThreadTooltip({ ) : null} {thread.session?.lastError ? ( -
+
-
Error occurred
+
+ {thread.session.lastErrorClass === "usage_limit" ? "Limited" : "Error occurred"} +
) : null}
@@ -1174,19 +1183,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { icon: "failed" as const, 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({ @@ -1481,7 +1498,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? "text-secondary-label" : isUnread || isWoke || status === "input" ? "text-foreground" - : status === "failed" + : status === "failed" || status === "limited" ? "text-foreground/95" : "text-foreground/90", ) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 860b6389dc12..954b91852be1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ approval: false, sessionError: false, turnError: false, + usageLimit: false, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => "toast-1", @@ -40,7 +41,11 @@ vi.mock("@effect/atom-react", () => ({ archivedAt: state.archivedAt, hasPendingUserInput: state.input, hasPendingApprovals: state.approval, - session: state.sessionError ? { status: "error" } : null, + session: state.sessionError + ? { status: "error", lastErrorClass: state.usageLimit ? "usage_limit" : null } + : state.usageLimit + ? { status: "error", lastErrorClass: "usage_limit" } + : null, latestTurn: { turnId: "turn-1", state: state.turnError ? "error" : state.completedAt ? "completed" : "running", @@ -109,6 +114,7 @@ beforeEach(() => { approval: false, sessionError: false, turnError: false, + usageLimit: false, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", new EventTarget()); @@ -166,6 +172,7 @@ describe("thread notifications", () => { ["approval", "Approval needed"], ["sessionError", "Thread failed"], ["turnError", "Thread failed"], + ["usageLimit", "Usage limit reached"], ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => { state.mode = "notifications-and-sound"; await render(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 5feda71ea8f3..de43b331b1ed 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -112,7 +112,7 @@ function EnvironmentNotifications({ if (status === "ready" && thread.latestTurn?.state === "error") status = "failed"; const prior = previous.current.get(thread.id); const attention = - status === "input" || status === "approval" || status === "failed" + status === "input" || status === "approval" || status === "failed" || status === "limited" ? `${thread.latestTurn?.turnId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); @@ -138,7 +138,9 @@ function EnvironmentNotifications({ ? "Approval needed" : status === "failed" ? "Thread failed" - : "Input needed"; + : status === "limited" + ? "Usage limit reached" + : "Input needed"; if (hasNotificationSound(mode)) { void playNotificationSound(kind, () => hasNotificationSound(getClientSettings().notificationMode), 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 650954d7a586..e8b18385064e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -78,6 +78,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 d89e1fb2957c..875d5895ad54 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1332,6 +1332,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 cf3aad4ba73d..fa7d5e8df2e6 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -595,6 +595,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, @@ -603,6 +609,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 01a8f9ad8f83..3baea28ae736 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",