From 928ce9afb8af8c223f257c1e1afd687241292d03 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 7 Sep 2026 20:00:20 +0200 Subject: [PATCH 1/9] feat(threads): show a usage-limit stop as Limited instead of Failed A thread stopped because the account is out of quota read as broken: the session carried only the error text, so both clients labelled it Failed in red. The adapters now class the limit error, the session keeps that class beside the message, and the sidebar, the mobile list, and the thread banner show it as Limited in the waiting tone. Co-Authored-By: Claude Fable 5.1 --- .../features/threads/thread-list-v2-items.tsx | 8 ++- .../src/features/threads/threadListV2.test.ts | 22 ++++++ .../src/features/threads/threadListV2.ts | 4 +- .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 7 ++ .../Layers/ProviderRuntimeIngestion.test.ts | 68 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 5 ++ .../Layers/ProjectionThreadSessions.ts | 4 ++ apps/server/src/persistence/Migrations.ts | 2 + ...ectionThreadSessionsLastErrorClass.test.ts | 28 ++++++++ ..._ProjectionThreadSessionsLastErrorClass.ts | 15 ++++ .../Services/ProjectionThreadSessions.ts | 2 + .../src/provider/Layers/ClaudeAdapter.test.ts | 16 ++++- .../src/provider/Layers/ClaudeAdapter.ts | 19 +++++- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/Sidebar.logic.test.ts | 37 ++++++++++ apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 26 ++++--- .../src/components/chat/ThreadErrorBanner.tsx | 15 ++-- docs/user/thread-sidebar.md | 6 ++ packages/contracts/src/orchestration.test.ts | 17 +++++ packages/contracts/src/orchestration.ts | 8 +++ packages/contracts/src/providerRuntime.ts | 1 + 23 files changed, 291 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts create mode 100644 apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.ts 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..7b098bc1fe5e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3929,6 +3929,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 84cb34a4e783..9018f0545f67 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1886,6 +1886,9 @@ 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" ? null : (thread.session?.lastErrorClass ?? null); if (shouldApplyThreadLifecycle) { if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { @@ -1922,6 +1925,7 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, + lastErrorClass, updatedAt: now, }, createdAt: now, @@ -2422,6 +2426,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..393f95764496 --- /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.layerMemory())); + +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..42541a52e32a 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), 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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79d393fc8d1f..b68ae2622340 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9851,6 +9851,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..41e2a87993d8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1174,19 +1174,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 +1489,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/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", From 0550867375ab3d232c5b337f731a3f847819ca4c Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 7 Sep 2026 20:11:26 +0200 Subject: [PATCH 2/9] fix(web): class the thread banner only for the session's own error A newer local error shown in place of the session error is an ordinary failure, so it must not inherit the usage-limit tone. --- apps/web/src/components/ChatView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b68ae2622340..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,7 +9857,7 @@ export default function ChatView(props: ChatViewProps) { /> { setThreadError(activeThread.id, null); dismissThreadErrorBannerForSession(threadErrorBannerKey); From 850e1f072daae46cfd67214e02f68f50a5fd104b Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 7 Sep 2026 20:15:29 +0200 Subject: [PATCH 3/9] fix(server): clear the error class whenever the lifecycle clears the error An interrupted session drops lastError, so it must drop lastErrorClass too or an aborted thread would read as Limited. --- .../src/orchestration/Layers/ProviderRuntimeIngestion.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 9018f0545f67..d36978d4d9b8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1888,7 +1888,10 @@ const make = Effect.gen(function* () { : (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" ? null : (thread.session?.lastErrorClass ?? null); + const lastErrorClass = + status === "ready" || status === "interrupted" + ? null + : (thread.session?.lastErrorClass ?? null); if (shouldApplyThreadLifecycle) { if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { From 88fccb083f697d190248b36b9e6c1a2e59cce559 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Tue, 8 Sep 2026 04:17:21 +0200 Subject: [PATCH 4/9] feat(codex): class the usage-limit stop so it reads as Limited #10473 composes the Codex limit error; give it the same class the Claude adapter sends so both providers' limit stops share the Limited state. --- apps/server/src/provider/Layers/CodexAdapter.test.ts | 1 + apps/server/src/provider/Layers/CodexAdapter.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 } : {}), }, }; From be0c17ee5e3be5862dd3fbeaec37f398cef4d5c9 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Mon, 14 Sep 2026 06:09:11 +0200 Subject: [PATCH 5/9] fix(web): keep a Limited stop as a thread notification The notification coordinator gates on the resolved status string, so a usage-limit stop, now classified as limited, silently dropped out of thread notifications. Treat it as an attention event titled "Usage limit reached", in the warning tone. --- .../components/ThreadNotificationCoordinator.test.tsx | 9 ++++++++- .../web/src/components/ThreadNotificationCoordinator.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) 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), From 3815eadfb9950b977b85172b32bdb89153ad8c4b Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Tue, 15 Sep 2026 13:23:58 +0200 Subject: [PATCH 6/9] fix(server): drop the usage-limit class when a later error replaces it The class was only cleared on ready/interrupted, so a later session error with a different reason, or a failed turn.completed with a replacement message, kept the stale usage_limit class and read as Limited with warning styling. Clear the inherited class whenever the lifecycle event supplies a different error than the stored one, which preserves it for the matching usage-limit completion. The server-side counterpart of the ChatView guard. --- .../Layers/ProviderRuntimeIngestion.test.ts | 78 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 8 +- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 7b098bc1fe5e..d6ff4422bf2a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3999,6 +3999,84 @@ describe("ProviderRuntimeIngestion", () => { 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 () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d36978d4d9b8..c4f7fd5a3da1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1887,9 +1887,13 @@ const make = Effect.gen(function* () { ? 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. + // 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" + status === "ready" || + status === "interrupted" || + lastError !== (thread.session?.lastError ?? null) ? null : (thread.session?.lastErrorClass ?? null); From 74089d7f2b1216fbf09329e13f56d0fe0fb45441 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Tue, 15 Sep 2026 13:39:37 +0200 Subject: [PATCH 7/9] fix(web): keep the sidebar tooltip in the Limited tone The row tooltip still rendered a red "Error occurred" entry for a usage-limit stop, because a limited session carries lastError. Read the class and use the amber Limited entry instead, matching the row label. --- apps/web/src/components/Sidebar.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 41e2a87993d8..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}
From 3fc8cc9537b4a65fbb793f1fe9ecd0c0e6958cad Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Tue, 15 Sep 2026 13:39:37 +0200 Subject: [PATCH 8/9] test(claude): assert the runtime error class on terminal failures CodeRabbit: the classification cases asserted the message and failed state but not the class. Pin usage_limit for the rate-limit cases and provider_error for the generic ones. --- apps/server/src/provider/Layers/ClaudeAdapter.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 42541a52e32a..b2a7c8775da7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2644,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", @@ -2667,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; @@ -2692,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( From 409bba8dbec755d139b186eafa53ab3a427655ba Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sat, 19 Sep 2026 06:20:19 +0200 Subject: [PATCH 9/9] test(server): use the current in-memory sqlite layer helper Main replaced NodeSqliteClient.layerMemory() with layer({ filename: ":memory:" }); match it so the migration test typechecks. --- .../054_ProjectionThreadSessionsLastErrorClass.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts index 393f95764496..ae3466603d8d 100644 --- a/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadSessionsLastErrorClass.test.ts @@ -6,7 +6,7 @@ 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())); +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", () =>