1import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 createInboxReturnTracker,
6 isThreadWorking,
7 sortWorkingThreadsBySend,
8} from "./threadInbox.ts";
9
10const environmentId = EnvironmentId.make("environment-1");
11
12function thread(id: string, working: boolean) {
13 return {
14 id: ThreadId.make(id),
15 environmentId,
16 createdAt: "2026-06-01T00:00:00.000Z",
17 unsettledAt: null,
18 latestRun: null,
19 hasActionableProposedPlan: false,
20 hasPendingApprovals: false,
21 hasPendingUserInput: false,
22 interactionMode: "default" as const,
23 runtime: working
24 ? {
25 status: "running" as const,
26 activeRunId: null,
27 providerInstanceId: ProviderInstanceId.make("codex"),
28 providerName: "Codex",
29 lastError: null,
30 updatedAt: "2026-06-01T00:00:00.000Z",
31 }
32 : null,
33 };
34}
35
36const 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
63describe("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
86describe("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
101 it("stamps a thread when it stops working, but never on the first observation", () => {
102 const tracker = createInboxReturnTracker();
103 tracker.observe([thread("a", true), thread("b", false)]);
104 expect(tracker.returnedAt(thread("a", true))).toBeUndefined();
105 expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
106
107 tracker.observe([thread("a", false), thread("b", false)]);
108 expect(tracker.returnedAt(thread("a", false))).toBeDefined();
109 expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
110 });
111
112 it("forgets deleted threads and resets when the beta turns off", () => {
113 const tracker = createInboxReturnTracker();
114 tracker.observe([thread("a", true), thread("b", true)]);
115 tracker.observe([thread("a", false), thread("b", false)]);
116 tracker.observe([thread("b", false)]);
117 expect(tracker.returnedAt(thread("a", false))).toBeUndefined();
118 expect(tracker.returnedAt(thread("b", false))).toBeDefined();
119
120 tracker.observe(null);
121 expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
122 // After a reset the next call is a fresh baseline again.
123 tracker.observe([thread("b", true)]);
124 tracker.observe([thread("b", false)]);
125 expect(tracker.returnedAt(thread("b", false))).toBeDefined();
126 });
127});
128
129describe("sortWorkingThreadsBySend", () => {
130 it("orders by the last message the user sent, not by later runs", () => {
131 const sentFirst = {
132 ...thread("sent-first", true),
133 latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z",
134 // A wake run requested after the other thread's send.
135 latestRun: {
136 runId: RunId.make("run:wake"),
137 status: "running" as const,
138 requestedAt: "2026-06-01T04:00:00.000Z",
139 startedAt: "2026-06-01T04:00:00.000Z",
140 completedAt: null,
141 assistantMessageId: null,
142 },
143 };
144 const sentLast = {
145 ...thread("sent-last", true),
146 latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z",
147 };
148 // Launched by an agent: no user message, so creation time is the send.
149 const launched = { ...thread("launched", true), latestUserAuthoredMessageAt: null };
150 expect(
151 sortWorkingThreadsBySend([launched, sentFirst, sentLast]).map((thread) => thread.id),
152 ).toEqual(["sent-last", "sent-first", "launched"]);
153 });
154});