From 6f7fee3541b9ef1fcedd996d055052a4be440bbb Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 14:58:49 -0700 Subject: [PATCH] fix: keep usage-limit blocked agents as "working" Clover wants chats that will resume automatically after a usage reset to stay out of the inbox until they need her. Classify a failed usage-limit run as Working only when its persisted automatic recovery matches the current run and reset time. Share the classification across web, desktop, and mobile. Keep manual or stale recovery, approvals, questions, and ready plans actionable, preserving pinned, snoozed, and settled placement. Assisted-by: gpt-6.1-sol --- .../src/features/threads/threadListV2.test.ts | 31 ++++++++ apps/web/src/components/Sidebar.logic.test.ts | 21 +++++- .../src/state/threadInbox.test.ts | 70 ++++++++++++++++++- .../client-runtime/src/state/threadInbox.ts | 19 ++++- 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index d5946e62ca159098f85036e370be178bc9ef1029..a779006bbf185056edd7071be645f63dc111a0bb 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -2363,6 +2363,37 @@ describe("Working section beta", () => { expect(off.workingCount).toBe(0); }); + it.each([true, false])("classifies a Limited thread with auto-resume %s", (autoResume) => { + const runId = RunId.make("limited-run"); + const resetAt = "2026-06-02T02:00:00.000Z"; + const limited = presentThreadShell( + environmentId, + makeRawThreadShell({ + id: ThreadId.make("limited"), + latestRunId: runId, + status: "failed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-06-01T23:00:00.000Z"), + lastError: "Plan limit reached", + lastErrorClass: "usage_limit", + usageLimitResetAt: resetAt, + limitRecovery: { runId, resetAt, autoResume }, + }), + ); + const layout = build({ threads: [limited] }); + + expect(layout.workingCount).toBe(autoResume ? 1 : 0); + expect(ids(layout)).toEqual(autoResume ? [] : ["limited"]); + expect(resolveThreadListV2Status(limited)).toBe("limited"); + expect(ids(build({ threads: [limited], workingShelfExpanded: true }))).toEqual(["limited"]); + expect(ids(build({ threads: [limited], workingShelfEnabled: false }))).toEqual(["limited"]); + + const snoozed = build({ + threads: [{ ...limited, snoozedUntil: resetAt, snoozedAt: NOW }], + }); + expect(snoozed.snoozedCount).toBe(1); + expect(snoozed.workingCount).toBe(0); + }); + it("shows working rows as cards when expanded, or only the selected one when collapsed", () => { expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({ thread: { id: "working" }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 616ff8c4ec052ee88e506cae04c352ef4ae7ca74..5522c10ff0e8e5057bbc23d9febe3c2719ed22c1 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -37,7 +37,6 @@ import { resolveSidebarThreadStatus, resolveSidebarV2TopStatus, resolveThreadLastVisitedAt, - resolveThreadRowClassName, resolveThreadStatusPill, resolveWorkingStartedAt, pullRequestMatchesSearchQuery, @@ -53,7 +52,6 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, sortSidebarV2ProjectGroups, - shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, THREAD_JUMP_HINT_SHOW_DELAY_MS, type SidebarListItem, @@ -62,7 +60,6 @@ import { resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; import { @@ -2176,6 +2173,24 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); + it.each([true, false])("classifies a Limited shell with auto-resume %s", (autoResume) => { + const runId = RunId.make("limited-run"); + const resetAt = "2026-06-20T03:00:00.000Z"; + const thread = presentThreadShell(localEnvironmentId, { + ...makeThreadFixture().source, + latestRunId: runId, + status: "failed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"), + lastError: "Plan limit reached", + lastErrorClass: "usage_limit", + usageLimitResetAt: resetAt, + limitRecovery: { runId, resetAt, autoResume }, + }); + + expect(isSidebarThreadWorking(thread)).toBe(autoResume); + expect(resolveSidebarThreadStatus(thread)).toBe("limited"); + }); + describe("sortInboxThreadsByReturn", () => { const thread = ( id: string, diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 2ae5670933c78757dcac09f241acb4509bece6f1..be6d79396db12b87d80235514616d57e8090949b 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -1,7 +1,11 @@ import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts"; +import { + createInboxReturnTracker, + isThreadWorking, + sortWorkingThreadsBySend, +} from "./threadInbox.ts"; const environmentId = EnvironmentId.make("environment-1"); @@ -29,7 +33,71 @@ function thread(id: string, working: boolean) { }; } +const limitedThread = { + ...thread("limited", false), + latestRun: { + runId: RunId.make("limited-run"), + status: "failed" as const, + requestedAt: "2026-06-01T00:00:00.000Z", + startedAt: "2026-06-01T00:00:00.000Z", + completedAt: "2026-06-01T01:00:00.000Z", + assistantMessageId: null, + }, + runtime: { + status: "failed" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: "Plan limit reached", + lastErrorClass: "usage_limit" as const, + usageLimitResetAt: "2026-06-01T02:00:00.000Z", + updatedAt: "2026-06-01T01:00:00.000Z", + }, + limitRecovery: { + runId: RunId.make("limited-run"), + resetAt: "2026-06-01T02:00:00.000Z", + autoResume: true, + }, +}; + +describe("isThreadWorking usage-limit recovery", () => { + it("keeps a scheduled retry working until recovery changes, even after the reset time", () => { + expect(isThreadWorking(limitedThread)).toBe(true); + }); + + it.each([ + { limitRecovery: null }, + { limitRecovery: { ...limitedThread.limitRecovery, autoResume: false, snooze: true } }, + { limitRecovery: { ...limitedThread.limitRecovery, runId: RunId.make("previous-run") } }, + { limitRecovery: { ...limitedThread.limitRecovery, resetAt: "2026-06-02T02:00:00.000Z" } }, + { runtime: { ...limitedThread.runtime, lastErrorClass: "provider_error" as const } }, + { runtime: { ...limitedThread.runtime, usageLimitResetAt: null } }, + { runtime: { ...limitedThread.runtime, status: "completed" as const } }, + { latestRun: null }, + { hasPendingApprovals: true }, + { hasPendingUserInput: true }, + { pendingSubagentRequest: "approval" as const }, + { interactionMode: "plan" as const, hasActionableProposedPlan: true }, + ])("keeps actionable or obsolete recovery in the inbox (%j)", (override) => { + expect(isThreadWorking({ ...limitedThread, ...override })).toBe(false); + }); +}); + describe("createInboxReturnTracker", () => { + it("keeps an automatic limit wait out of inbox returns until auto-resume is cancelled", () => { + const tracker = createInboxReturnTracker(); + tracker.observe([ + { ...limitedThread, runtime: { ...limitedThread.runtime, status: "running" } }, + ]); + tracker.observe([limitedThread]); + expect(tracker.returnedAt(limitedThread)).toBeUndefined(); + + tracker.observe([ + { ...limitedThread, limitRecovery: { ...limitedThread.limitRecovery, autoResume: false } }, + ]); + expect(tracker.returnedAt(limitedThread)).toBeDefined(); + }); + it("stamps a thread when it stops working, but never on the first observation", () => { const tracker = createInboxReturnTracker(); tracker.observe([thread("a", true), thread("b", false)]); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 8f73ea0504d094080fc6e081ddf0ca9848d6c4e0..fd800719ad16386564fc5981f2dda2f006f0a921 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -11,18 +11,31 @@ type WorkingThreadInput = Pick< | "hasPendingUserInput" | "pendingSubagentRequest" | "interactionMode" + | "limitRecovery" | "latestRun" | "runtime" >; /** Threads busy with work that does not need the user fold into the Working section: a running run, or one stopped with background work that will wake - it. Approvals, questions (its own or a subagent's), plan prompts, and - failures stay in the inbox. */ + it, including scheduled usage-limit recovery. Approvals, questions (its + own or a subagent's), plan prompts, and other failures stay in the inbox. */ export function isThreadWorking(thread: WorkingThreadInput): boolean { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.pendingSubagentRequest != null) return false; - if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; + const recovery = thread.limitRecovery; + const resumingAtUsageReset = + thread.runtime?.status === "failed" && + thread.runtime.lastErrorClass === "usage_limit" && + recovery?.autoResume === true && + recovery.runId === thread.latestRun?.runId && + recovery.resetAt === thread.runtime.usageLimitResetAt; + if ( + !threadRuntimeIsActive(thread.runtime) && + thread.runtime?.status !== "idle" && + !resumingAtUsageReset + ) + return false; // A plan prompt outranks lingering background work: the user has to act on it. const run = thread.latestRun; const runSettled = -- 2.54.0