1import * as CodexErrors from "effect-codex-app-server/errors";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 codexRateLimitsFailureMessage,
6 codexRateLimitsToLimits,
7 codexRateLimitsToUpdate,
8 codexResetCreditsToContract,
9 codexUsageLimitMessage,
10 mergeCodexRateLimits,
11} from "./codexUsageLimits.ts";
12
13const checkedAt = "2026-07-18T10:00:00.000Z";
14
15describe("codexRateLimitsToLimits", () => {
16 it("maps primary and secondary onto the session and weekly windows", () => {
17 expect(
18 codexRateLimitsToLimits({
19 checkedAt,
20 snapshot: {
21 planType: "plus",
22 primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 },
23 secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 },
24 },
25 }),
26 ).toEqual({
27 checkedAt,
28 windows: [
29 {
30 id: "primary",
31 kind: "session",
32 label: "Session",
33 usedPercent: 12,
34 windowDurationMins: 300,
35 resetsAt: "2026-07-14T03:33:20.000Z",
36 },
37 {
38 id: "secondary",
39 kind: "weekly",
40 label: "Weekly",
41 usedPercent: 47,
42 windowDurationMins: 10080,
43 resetsAt: "2026-07-19T22:26:40.000Z",
44 },
45 ],
46 });
47 });
48
49 it("treats a lone duration-less primary as monthly on Free and Go", () => {
50 expect(
51 codexRateLimitsToLimits({
52 checkedAt,
53 snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } },
54 }).windows,
55 ).toEqual([
56 {
57 id: "primary",
58 kind: "monthly",
59 label: "Monthly",
60 usedPercent: 80,
61 windowDurationMins: 43_200,
62 },
63 ]);
64 });
65
66 it("selects the main Codex allowance and leaves Spark out", () => {
67 const spark = {
68 limitId: "codex_bengalfox",
69 primary: { usedPercent: 0, windowDurationMins: 300 },
70 secondary: { usedPercent: 90, windowDurationMins: 10080 },
71 };
72 expect(
73 codexRateLimitsToLimits({
74 checkedAt,
75 snapshot: spark,
76 rateLimitsByLimitId: {
77 codex_bengalfox: spark,
78 codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } },
79 },
80 }).windows,
81 ).toEqual([
82 {
83 id: "secondary",
84 kind: "weekly",
85 label: "Weekly",
86 usedPercent: 42,
87 windowDurationMins: 10080,
88 },
89 ]);
90 });
91
92 it.each([undefined, null, {}])(
93 "supports legacy reads with no bucket map: %j",
94 (rateLimitsByLimitId) => {
95 const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } };
96 expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual(
97 codexRateLimitsToLimits({ checkedAt, snapshot }),
98 );
99 },
100 );
101
102 it("does not show a model-specific legacy snapshot as the main allowance", () => {
103 expect(
104 codexRateLimitsToLimits({
105 checkedAt,
106 snapshot: {
107 limitId: "codex_bengalfox",
108 secondary: { usedPercent: 90 },
109 },
110 }).windows,
111 ).toEqual([]);
112 });
113});
114
115describe("codexRateLimitsToUpdate", () => {
116 it("carries only the windows the notification names", () => {
117 expect(
118 codexRateLimitsToUpdate({
119 secondary: { usedPercent: 51, windowDurationMins: 10080 },
120 }),
121 ).toEqual({
122 windows: [
123 {
124 id: "secondary",
125 kind: "weekly",
126 label: "Weekly",
127 usedPercent: 51,
128 windowDurationMins: 10080,
129 },
130 ],
131 });
132 expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined();
133 });
134
135 it("ignores Spark notifications so they cannot overwrite the main allowance", () => {
136 expect(
137 codexRateLimitsToUpdate({
138 limitId: "codex_bengalfox",
139 primary: { usedPercent: 0, windowDurationMins: 300 },
140 secondary: { usedPercent: 90, windowDurationMins: 10080 },
141 }),
142 ).toBeUndefined();
143 expect(
144 codexRateLimitsToUpdate({
145 limitId: "codex",
146 secondary: { usedPercent: 42, windowDurationMins: 10080 },
147 })?.windows,
148 ).toEqual([
149 {
150 id: "secondary",
151 kind: "weekly",
152 label: "Weekly",
153 usedPercent: 42,
154 windowDurationMins: 10080,
155 },
156 ]);
157 });
158});
159
160describe("codexRateLimitsFailureMessage", () => {
161 it("keeps the JSON-RPC code and nothing else from a request failure", () => {
162 expect(
163 codexRateLimitsFailureMessage(
164 new CodexErrors.CodexAppServerRequestError({
165 code: -32603,
166 errorMessage:
167 "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized",
168 }),
169 ),
170 ).toBe("Codex could not read usage (JSON-RPC -32603).");
171 });
172
173 it("phrases a dead process differently from a bad answer", () => {
174 expect(
175 codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })),
176 ).toBe("Codex exited before it could report usage.");
177 });
178});
179
180describe("codexResetCreditsToContract", () => {
181 it("counts available credits and reports the soonest expiry", () => {
182 expect(
183 codexResetCreditsToContract({
184 availableCount: 2,
185 credits: [
186 { status: "available", expiresAt: 1_784_500_000 },
187 { status: "redeemed", expiresAt: 1_700_000_000 },
188 { status: "available", expiresAt: 1_784_000_000 },
189 ],
190 }),
191 ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" });
192 expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 });
193 expect(codexResetCreditsToContract(null)).toBeUndefined();
194 });
195
196 it("rides along on the probe's limits", () => {
197 expect(
198 codexRateLimitsToLimits({
199 checkedAt,
200 snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } },
201 resetCredits: { availableCount: 1 },
202 }).resetCredits,
203 ).toEqual({ availableCount: 1 });
204 });
205});
206
207describe("codexUsageLimitMessage", () => {
208 const at = "2026-01-01T00:00:00.000Z";
209 const atSeconds = Date.parse(at) / 1000;
210
211 it("names the exhausted window and the workspace's missing credits", () => {
212 expect(
213 codexUsageLimitMessage(
214 {
215 limitId: "codex",
216 rateLimitReachedType: "workspace_owner_credits_depleted",
217 primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 },
218 secondary: {
219 usedPercent: 100,
220 resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600,
221 windowDurationMins: 10_080,
222 },
223 },
224 at,
225 ),
226 ).toBe(
227 "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.",
228 );
229 });
230
231 it("points a reached spend cap at the workspace owner", () => {
232 expect(
233 codexUsageLimitMessage(
234 {
235 limitId: "codex",
236 rateLimitReachedType: "workspace_member_usage_limit_reached",
237 primary: {
238 usedPercent: 100,
239 resetsAt: atSeconds + 3 * 3_600 + 20 * 60,
240 windowDurationMins: 300,
241 },
242 },
243 at,
244 ),
245 ).toBe(
246 "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.",
247 );
248 });
249
250 it("names no window when credits run out without one", () => {
251 expect(
252 codexUsageLimitMessage(
253 { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" },
254 at,
255 ),
256 ).toBe(
257 "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.",
258 );
259 });
260
261 it("says only what it knows without a snapshot", () => {
262 expect(codexUsageLimitMessage(undefined, at)).toBe(
263 "Codex usage limit reached. Send the message again once the limit resets.",
264 );
265 });
266});
267
268describe("mergeCodexRateLimits", () => {
269 it("keeps windows an update does not carry", () => {
270 const merged = mergeCodexRateLimits(
271 {
272 limitId: "codex",
273 planType: "business",
274 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
275 },
276 { rateLimitReachedType: "rate_limit_reached" },
277 );
278
279 expect(merged).toEqual({
280 limitId: "codex",
281 planType: "business",
282 rateLimitReachedType: "rate_limit_reached",
283 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
284 });
285 });
286
287 it("ignores a model-specific snapshot so it cannot replace the main allowance", () => {
288 const main = {
289 limitId: "codex",
290 primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 },
291 };
292 expect(
293 mergeCodexRateLimits(main, {
294 limitId: "spark",
295 primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 },
296 }),
297 ).toBe(main);
298 });
299});
300
301import { codexUsageLimitResetAt } from "./codexUsageLimits.ts";
302
303describe("codexUsageLimitResetAt", () => {
304 it("waits for every exhausted window and never invents an unknown reset", () => {
305 expect(
306 codexUsageLimitResetAt({
307 primary: { usedPercent: 100, resetsAt: 2000000000 },
308 secondary: { usedPercent: 100, resetsAt: 2000100000 },
309 }),
310 ).toBe("2033-05-19T07:20:00.000Z");
311 expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull();
312 expect(
313 codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }),
314 ).toBeNull();
315 });
316});