| author | |
| committer | |
| log | 6f7fee3541b9ef1fcedd996d055052a4be440bbb |
| tree | c88d659e8c62d2ba85ed1197269bcfa294ede75f |
| parent | 59d067bf9b4ea9cc94d4aeafeeba665cc3b06600 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol4 files changed, 134 insertions(+), 7 deletions(-)
apps/mobile/src/features/threads/threadListV2.test.ts+31| ... | ... | @@ -2363,6 +2363,37 @@ describe("Working section beta", () => { |
| 2363 | 2363 | expect(off.workingCount).toBe(0); |
| 2364 | 2364 | }); |
| 2365 | 2365 | |
| 2366 | it.each([true, false])("classifies a Limited thread with auto-resume %s", (autoResume) => { | |
| 2367 | const runId = RunId.make("limited-run"); | |
| 2368 | const resetAt = "2026-06-02T02:00:00.000Z"; | |
| 2369 | const limited = presentThreadShell( | |
| 2370 | environmentId, | |
| 2371 | makeRawThreadShell({ | |
| 2372 | id: ThreadId.make("limited"), | |
| 2373 | latestRunId: runId, | |
| 2374 | status: "failed", | |
| 2375 | latestRunCompletedAt: DateTime.makeUnsafe("2026-06-01T23:00:00.000Z"), | |
| 2376 | lastError: "Plan limit reached", | |
| 2377 | lastErrorClass: "usage_limit", | |
| 2378 | usageLimitResetAt: resetAt, | |
| 2379 | limitRecovery: { runId, resetAt, autoResume }, | |
| 2380 | }), | |
| 2381 | ); | |
| 2382 | const layout = build({ threads: [limited] }); | |
| 2383 | ||
| 2384 | expect(layout.workingCount).toBe(autoResume ? 1 : 0); | |
| 2385 | expect(ids(layout)).toEqual(autoResume ? [] : ["limited"]); | |
| 2386 | expect(resolveThreadListV2Status(limited)).toBe("limited"); | |
| 2387 | expect(ids(build({ threads: [limited], workingShelfExpanded: true }))).toEqual(["limited"]); | |
| 2388 | expect(ids(build({ threads: [limited], workingShelfEnabled: false }))).toEqual(["limited"]); | |
| 2389 | ||
| 2390 | const snoozed = build({ | |
| 2391 | threads: [{ ...limited, snoozedUntil: resetAt, snoozedAt: NOW }], | |
| 2392 | }); | |
| 2393 | expect(snoozed.snoozedCount).toBe(1); | |
| 2394 | expect(snoozed.workingCount).toBe(0); | |
| 2395 | }); | |
| 2396 | ||
| 2366 | 2397 | it("shows working rows as cards when expanded, or only the selected one when collapsed", () => { |
| 2367 | 2398 | expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({ |
| 2368 | 2399 | thread: { id: "working" }, |
apps/web/src/components/Sidebar.logic.test.ts+18-3| ... | ... | @@ -37,7 +37,6 @@ import { |
| 37 | 37 | resolveSidebarThreadStatus, |
| 38 | 38 | resolveSidebarV2TopStatus, |
| 39 | 39 | resolveThreadLastVisitedAt, |
| 40 | resolveThreadRowClassName, | |
| 41 | 40 | resolveThreadStatusPill, |
| 42 | 41 | resolveWorkingStartedAt, |
| 43 | 42 | pullRequestMatchesSearchQuery, |
| ... | ... | @@ -53,7 +52,6 @@ import { |
| 53 | 52 | sortProjectsForSidebar, |
| 54 | 53 | sortScopedProjectsForSidebar, |
| 55 | 54 | sortSidebarV2ProjectGroups, |
| 56 | shouldCreateNewThreadInCurrentProject, | |
| 57 | 55 | shouldNavigateAfterThreadPark, |
| 58 | 56 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 59 | 57 | type SidebarListItem, |
| ... | ... | @@ -62,7 +60,6 @@ import { |
| 62 | 60 | resolveSidebarDropVerb, |
| 63 | 61 | } from "./Sidebar.logic"; |
| 64 | 62 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 65 | import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; | |
| 66 | 63 | import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; |
| 67 | 64 | |
| 68 | 65 | import { |
| ... | ... | @@ -2176,6 +2173,24 @@ describe("Working shelf (beta)", () => { |
| 2176 | 2173 | ).toBe(false); |
| 2177 | 2174 | }); |
| 2178 | 2175 | |
| 2176 | it.each([true, false])("classifies a Limited shell with auto-resume %s", (autoResume) => { | |
| 2177 | const runId = RunId.make("limited-run"); | |
| 2178 | const resetAt = "2026-06-20T03:00:00.000Z"; | |
| 2179 | const thread = presentThreadShell(localEnvironmentId, { | |
| 2180 | ...makeThreadFixture().source, | |
| 2181 | latestRunId: runId, | |
| 2182 | status: "failed", | |
| 2183 | latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"), | |
| 2184 | lastError: "Plan limit reached", | |
| 2185 | lastErrorClass: "usage_limit", | |
| 2186 | usageLimitResetAt: resetAt, | |
| 2187 | limitRecovery: { runId, resetAt, autoResume }, | |
| 2188 | }); | |
| 2189 | ||
| 2190 | expect(isSidebarThreadWorking(thread)).toBe(autoResume); | |
| 2191 | expect(resolveSidebarThreadStatus(thread)).toBe("limited"); | |
| 2192 | }); | |
| 2193 | ||
| 2179 | 2194 | describe("sortInboxThreadsByReturn", () => { |
| 2180 | 2195 | const thread = ( |
| 2181 | 2196 | id: string, |
packages/client-runtime/src/state/threadInbox.test.ts+69-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts"; | |
| 4 | import { | |
| 5 | createInboxReturnTracker, | |
| 6 | isThreadWorking, | |
| 7 | sortWorkingThreadsBySend, | |
| 8 | } from "./threadInbox.ts"; | |
| 5 | 9 | |
| 6 | 10 | const environmentId = EnvironmentId.make("environment-1"); |
| 7 | 11 | |
| ... | ... | @@ -29,7 +33,71 @@ function thread(id: string, working: boolean) { |
| 29 | 33 | }; |
| 30 | 34 | } |
| 31 | 35 | |
| 36 | const limitedThread = { | |
| 37 | ...thread("limited", false), | |
| 38 | latestRun: { | |
| 39 | runId: RunId.make("limited-run"), | |
| 40 | status: "failed" as const, | |
| 41 | requestedAt: "2026-06-01T00:00:00.000Z", | |
| 42 | startedAt: "2026-06-01T00:00:00.000Z", | |
| 43 | completedAt: "2026-06-01T01:00:00.000Z", | |
| 44 | assistantMessageId: null, | |
| 45 | }, | |
| 46 | runtime: { | |
| 47 | status: "failed" as const, | |
| 48 | activeRunId: null, | |
| 49 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 50 | providerName: "Codex", | |
| 51 | lastError: "Plan limit reached", | |
| 52 | lastErrorClass: "usage_limit" as const, | |
| 53 | usageLimitResetAt: "2026-06-01T02:00:00.000Z", | |
| 54 | updatedAt: "2026-06-01T01:00:00.000Z", | |
| 55 | }, | |
| 56 | limitRecovery: { | |
| 57 | runId: RunId.make("limited-run"), | |
| 58 | resetAt: "2026-06-01T02:00:00.000Z", | |
| 59 | autoResume: true, | |
| 60 | }, | |
| 61 | }; | |
| 62 | ||
| 63 | describe("isThreadWorking usage-limit recovery", () => { | |
| 64 | it("keeps a scheduled retry working until recovery changes, even after the reset time", () => { | |
| 65 | expect(isThreadWorking(limitedThread)).toBe(true); | |
| 66 | }); | |
| 67 | ||
| 68 | it.each([ | |
| 69 | { limitRecovery: null }, | |
| 70 | { limitRecovery: { ...limitedThread.limitRecovery, autoResume: false, snooze: true } }, | |
| 71 | { limitRecovery: { ...limitedThread.limitRecovery, runId: RunId.make("previous-run") } }, | |
| 72 | { limitRecovery: { ...limitedThread.limitRecovery, resetAt: "2026-06-02T02:00:00.000Z" } }, | |
| 73 | { runtime: { ...limitedThread.runtime, lastErrorClass: "provider_error" as const } }, | |
| 74 | { runtime: { ...limitedThread.runtime, usageLimitResetAt: null } }, | |
| 75 | { runtime: { ...limitedThread.runtime, status: "completed" as const } }, | |
| 76 | { latestRun: null }, | |
| 77 | { hasPendingApprovals: true }, | |
| 78 | { hasPendingUserInput: true }, | |
| 79 | { pendingSubagentRequest: "approval" as const }, | |
| 80 | { interactionMode: "plan" as const, hasActionableProposedPlan: true }, | |
| 81 | ])("keeps actionable or obsolete recovery in the inbox (%j)", (override) => { | |
| 82 | expect(isThreadWorking({ ...limitedThread, ...override })).toBe(false); | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 32 | 86 | describe("createInboxReturnTracker", () => { |
| 87 | it("keeps an automatic limit wait out of inbox returns until auto-resume is cancelled", () => { | |
| 88 | const tracker = createInboxReturnTracker(); | |
| 89 | tracker.observe([ | |
| 90 | { ...limitedThread, runtime: { ...limitedThread.runtime, status: "running" } }, | |
| 91 | ]); | |
| 92 | tracker.observe([limitedThread]); | |
| 93 | expect(tracker.returnedAt(limitedThread)).toBeUndefined(); | |
| 94 | ||
| 95 | tracker.observe([ | |
| 96 | { ...limitedThread, limitRecovery: { ...limitedThread.limitRecovery, autoResume: false } }, | |
| 97 | ]); | |
| 98 | expect(tracker.returnedAt(limitedThread)).toBeDefined(); | |
| 99 | }); | |
| 100 | ||
| 33 | 101 | it("stamps a thread when it stops working, but never on the first observation", () => { |
| 34 | 102 | const tracker = createInboxReturnTracker(); |
| 35 | 103 | tracker.observe([thread("a", true), thread("b", false)]); |
packages/client-runtime/src/state/threadInbox.ts+16-3| ... | ... | @@ -11,18 +11,31 @@ type WorkingThreadInput = Pick< |
| 11 | 11 | | "hasPendingUserInput" |
| 12 | 12 | | "pendingSubagentRequest" |
| 13 | 13 | | "interactionMode" |
| 14 | | "limitRecovery" | |
| 14 | 15 | | "latestRun" |
| 15 | 16 | | "runtime" |
| 16 | 17 | >; |
| 17 | 18 | |
| 18 | 19 | /** Threads busy with work that does not need the user fold into the Working |
| 19 | 20 | section: a running run, or one stopped with background work that will wake |
| 20 | it. Approvals, questions (its own or a subagent's), plan prompts, and | |
| 21 | failures stay in the inbox. */ | |
| 21 | it, including scheduled usage-limit recovery. Approvals, questions (its | |
| 22 | own or a subagent's), plan prompts, and other failures stay in the inbox. */ | |
| 22 | 23 | export function isThreadWorking(thread: WorkingThreadInput): boolean { |
| 23 | 24 | if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; |
| 24 | 25 | if (thread.pendingSubagentRequest != null) return false; |
| 25 | if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; | |
| 26 | const recovery = thread.limitRecovery; | |
| 27 | const resumingAtUsageReset = | |
| 28 | thread.runtime?.status === "failed" && | |
| 29 | thread.runtime.lastErrorClass === "usage_limit" && | |
| 30 | recovery?.autoResume === true && | |
| 31 | recovery.runId === thread.latestRun?.runId && | |
| 32 | recovery.resetAt === thread.runtime.usageLimitResetAt; | |
| 33 | if ( | |
| 34 | !threadRuntimeIsActive(thread.runtime) && | |
| 35 | thread.runtime?.status !== "idle" && | |
| 36 | !resumingAtUsageReset | |
| 37 | ) | |
| 38 | return false; | |
| 26 | 39 | // A plan prompt outranks lingering background work: the user has to act on it. |
| 27 | 40 | const run = thread.latestRun; |
| 28 | 41 | const runSettled = |