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 797781d16a4c8..55c6f8181c2e8 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 afc00ef9ab403..1e8f2735a6ab8 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 e1cb8e9ece7f0..1cac66d2c0cb1 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 b5e6cb0cdd543..da560a9618f0e 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 1e7058742e25c..44f33d9f4a01c 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 d61739f72c210..d6ff4422bf2ae 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 84cb34a4e7834..c4f7fd5a3da10 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 dcb750983a003..66ad6af7ad5db 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 18aae09febf8e..3a6e0e7de3806 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 0000000000000..ae3466603d8d4 --- /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 0000000000000..fbd74c9d6c3ce --- /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 7cecac33eb6a7..0d7d4bea08a8f 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 ca596e6501cfb..b2a7c8775da72 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 13645a33a05bb..14e10276e0a9b 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 48aff6cd707e7..91fae4024a697 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 ba61d14f5cf56..beec086d31188 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 79d393fc8d1f1..e8f2c6d1e1dee 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 76960f3f533bc..c84c54c766a0b 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 fcedf46ce5078..9d0062aa0df09 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 a03196fc3b8d0..5bf7ad39ce461 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 860b6389dc128..954b91852be17 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 5feda71ea8f34..de43b331b1ed3 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 29a6a6db18462..9c608325e0d34 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 650954d7a586a..e8b18385064e8 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 d89e1fb2957c5..875d5895ad54c 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 cf3aad4ba73d3..fa7d5e8df2e60 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 01a8f9ad8f834..3baea28ae736c 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",