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 =