pi-harness-runtime 1.1.13 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,8 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
- ### [1.1.13](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.12...v1.1.13) (2026-08-21)
5
+ ### [1.1.14](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.13...v1.1.14) (2026-08-24)
6
+
7
+
8
+ ### Features
6
9
 
10
+ * **glm-quota:** add robust GLM quota countdown with auto-resume ([cb78a2b](https://github.com/ManotLuijiu/pi-harness-runtime/commit/cb78a2bdf5ecf79fc028030d43e3ce40767253cb))
11
+
12
+ ### [1.1.13](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.12...v1.1.13) (2026-08-21)
7
13
 
8
14
  ### Bug Fixes
9
15
 
@@ -11,14 +17,12 @@ All notable changes to this project will be documented in this file. See [standa
11
17
 
12
18
  ### [1.1.12](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v1.1.11...v1.1.12) (2026-08-20)
13
19
 
14
-
15
20
  ### Bug Fixes
16
21
 
17
22
  * **runtime:** refresh minimax quota from provider mirror ([a8976b4](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a8976b4ff61e28b964bf76405142672798714647))
18
23
 
19
24
  ### [1.1.11](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.42...v1.1.11) (2026-08-20)
20
25
 
21
-
22
26
  ### Features
23
27
 
24
28
  * add quota scrapers for ChatGPT and GLM providers ([17f9d0e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/17f9d0e7d6f486210a30244f2fecc0131e8b7d57))
@@ -26,7 +30,6 @@ All notable changes to this project will be documented in this file. See [standa
26
30
  * index.ts ([d0fc002](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d0fc002d6d16e59318155677cb6128846bc993a4))
27
31
  * update workflow run name to show version number ([ee8c59f](https://github.com/ManotLuijiu/pi-harness-runtime/commit/ee8c59fec4c0116a5948b2ae04ad4609edaed3ac))
28
32
 
29
-
30
33
  ### Bug Fixes
31
34
 
32
35
  * add typescript as devDependency, build notification dist ([c76b522](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c76b5221c33262c38615c6117ecf9db13ab7b5d2))
@@ -49,7 +52,6 @@ All notable changes to this project will be documented in this file. See [standa
49
52
 
50
53
  ### [1.1.10](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.42...v1.1.10) (2026-08-20)
51
54
 
52
-
53
55
  ### Features
54
56
 
55
57
  * add quota scrapers for ChatGPT and GLM providers ([17f9d0e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/17f9d0e7d6f486210a30244f2fecc0131e8b7d57))
@@ -57,7 +59,6 @@ All notable changes to this project will be documented in this file. See [standa
57
59
  * index.ts ([d0fc002](https://github.com/ManotLuijiu/pi-harness-runtime/commit/d0fc002d6d16e59318155677cb6128846bc993a4))
58
60
  * update workflow run name to show version number ([ee8c59f](https://github.com/ManotLuijiu/pi-harness-runtime/commit/ee8c59fec4c0116a5948b2ae04ad4609edaed3ac))
59
61
 
60
-
61
62
  ### Bug Fixes
62
63
 
63
64
  * add typescript as devDependency, build notification dist ([c76b522](https://github.com/ManotLuijiu/pi-harness-runtime/commit/c76b5221c33262c38615c6117ecf9db13ab7b5d2))
@@ -79,7 +80,6 @@ All notable changes to this project will be documented in this file. See [standa
79
80
 
80
81
  ### [0.10.30](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.29...v0.10.30) (2026-08-11)
81
82
 
82
-
83
83
  ### Bug Fixes
84
84
 
85
85
  * **file-copy-helper:** require explicit intent for copy rule injection ([ddfece0](https://github.com/ManotLuijiu/pi-harness-runtime/commit/ddfece0bcbf440dfd4edc0d442b078e84e906c68))
@@ -98,13 +98,11 @@ All notable changes to this project will be documented in this file. See [standa
98
98
 
99
99
  ### [0.10.27](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.27) (2026-08-10)
100
100
 
101
-
102
101
  ### Features
103
102
 
104
103
  * create packages for missing RFC implementations ([e6ce2ff](https://github.com/ManotLuijiu/pi-harness-runtime/commit/e6ce2ffdd455b33c55212ec2b61eedcdcfb43b5e))
105
104
  * **file-copy-helper:** inject cp rule when user asks to mimic/copy files ([f089f0d](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f089f0d5e3e933231c99ee38cf18fa208c8eb8b2))
106
105
 
107
-
108
106
  ### Bug Fixes
109
107
 
110
108
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -120,12 +118,10 @@ All notable changes to this project will be documented in this file. See [standa
120
118
 
121
119
  ### [0.10.26](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.26) (2026-08-10)
122
120
 
123
-
124
121
  ### Features
125
122
 
126
123
  * **file-copy-helper:** inject cp rule when user asks to mimic/copy files ([f089f0d](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f089f0d5e3e933231c99ee38cf18fa208c8eb8b2))
127
124
 
128
-
129
125
  ### Bug Fixes
130
126
 
131
127
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -141,12 +137,10 @@ All notable changes to this project will be documented in this file. See [standa
141
137
 
142
138
  ### [0.10.25](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.25) (2026-08-10)
143
139
 
144
-
145
140
  ### Features
146
141
 
147
142
  * **file-copy-helper:** inject cp rule when user asks to mimic/copy files ([f089f0d](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f089f0d5e3e933231c99ee38cf18fa208c8eb8b2))
148
143
 
149
-
150
144
  ### Bug Fixes
151
145
 
152
146
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -161,12 +155,10 @@ All notable changes to this project will be documented in this file. See [standa
161
155
 
162
156
  ### [0.10.24](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.24) (2026-08-09)
163
157
 
164
-
165
158
  ### Features
166
159
 
167
160
  * **file-copy-helper:** inject cp rule when user asks to mimic/copy files ([f089f0d](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f089f0d5e3e933231c99ee38cf18fa208c8eb8b2))
168
161
 
169
-
170
162
  ### Bug Fixes
171
163
 
172
164
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -180,12 +172,10 @@ All notable changes to this project will be documented in this file. See [standa
180
172
 
181
173
  ### [0.10.23](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.23) (2026-08-09)
182
174
 
183
-
184
175
  ### Features
185
176
 
186
177
  * **file-copy-helper:** inject cp rule when user asks to mimic/copy files ([f089f0d](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f089f0d5e3e933231c99ee38cf18fa208c8eb8b2))
187
178
 
188
-
189
179
  ### Bug Fixes
190
180
 
191
181
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -198,7 +188,6 @@ All notable changes to this project will be documented in this file. See [standa
198
188
 
199
189
  ### [0.10.22](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.22) (2026-08-09)
200
190
 
201
-
202
191
  ### Bug Fixes
203
192
 
204
193
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -211,7 +200,6 @@ All notable changes to this project will be documented in this file. See [standa
211
200
 
212
201
  ### [0.10.21](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.21) (2026-08-08)
213
202
 
214
-
215
203
  ### Bug Fixes
216
204
 
217
205
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -223,7 +211,6 @@ All notable changes to this project will be documented in this file. See [standa
223
211
 
224
212
  ### [0.10.20](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.14...v0.10.20) (2026-08-08)
225
213
 
226
-
227
214
  ### Bug Fixes
228
215
 
229
216
  * align TS formatting with main (remove 3-line logError) ([2515ad5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/2515ad54f43f0566123c686d3ec4f92ad363ea13))
@@ -234,14 +221,12 @@ All notable changes to this project will be documented in this file. See [standa
234
221
 
235
222
  ### [0.10.11](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.10.9...v0.10.11) (2026-08-03)
236
223
 
237
-
238
224
  ### Features
239
225
 
240
226
  * add knowledge-retrieval package for TencentDB-Agent-Memory MCP (RFC-0105) ([a4558d0](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a4558d098c7b662b01d9502c8560219eafbf1941))
241
227
  * add okf-indexer package for SKILL.md indexing (RFC-0106) ([a3cc7da](https://github.com/ManotLuijiu/pi-harness-runtime/commit/a3cc7da0a390d5c8176d0d871418780facabcb5c))
242
228
  * extend a2a-adapter with discovery server (RFC-0104) ([0c4fc18](https://github.com/ManotLuijiu/pi-harness-runtime/commit/0c4fc1826fd55f10305bfbd894b32b5b55f1ab86))
243
229
 
244
-
245
230
  ### Bug Fixes
246
231
 
247
232
  * act on CONFLICTING immediately even when mergeStateStatus=DIRTY ([b7f290e](https://github.com/ManotLuijiu/pi-harness-runtime/commit/b7f290ece8e0e989635aacc456fffd4ee19183ec))
@@ -263,14 +248,12 @@ All notable changes to this project will be documented in this file. See [standa
263
248
 
264
249
  ## [0.10.0](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.9.24...v0.10.0) (2026-07-30)
265
250
 
266
-
267
251
  ### Features
268
252
 
269
253
  * blackboard-coordinated loop controller with early exit ([f3cf3ea](https://github.com/ManotLuijiu/pi-harness-runtime/commit/f3cf3ea479170dd75b7e9a7c3515dde230bb0da7))
270
254
  * herdr inter-agent event bus for autonomous code review ([1490cdd](https://github.com/ManotLuijiu/pi-harness-runtime/commit/1490cddec80dac812065c3c681984524ad3194e1))
271
255
  * loop controller for write-review cycles with early exit ([95066ce](https://github.com/ManotLuijiu/pi-harness-runtime/commit/95066ce8cb58c55eaa14d69fd826b5aaff680c16))
272
256
 
273
-
274
257
  ### Bug Fixes
275
258
 
276
259
  * **clipboard:** pass UTF-8 bytes directly to xclip via stdin, avoid pipeline corruption ([051ca18](https://github.com/ManotLuijiu/pi-harness-runtime/commit/051ca18fb1deb1169a8a4da16aa9314d2e6ec66d))
@@ -283,7 +266,6 @@ All notable changes to this project will be documented in this file. See [standa
283
266
 
284
267
  ### [0.9.25](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.9.21...v0.9.25) (2026-07-27)
285
268
 
286
-
287
269
  ### Bug Fixes
288
270
 
289
271
  * **ci:** explicitly list test files to exclude cookie auth tests in release workflow ([1c96002](https://github.com/ManotLuijiu/pi-harness-runtime/commit/1c9600232282a1ef9323300fbd763c962aff49f6))
@@ -294,7 +276,6 @@ All notable changes to this project will be documented in this file. See [standa
294
276
 
295
277
  ### [0.9.24](https://github.com/ManotLuijiu/pi-harness-runtime/compare/v0.9.21...v0.9.24) (2026-07-27)
296
278
 
297
-
298
279
  ### Bug Fixes
299
280
 
300
281
  * **ci:** skip browser auth tests in release workflow ([6117ed5](https://github.com/ManotLuijiu/pi-harness-runtime/commit/6117ed5af11357c6f12d11f4cd8d3441de519043))
package/footer-status.ts CHANGED
@@ -6,6 +6,51 @@ import {
6
6
  type ParsedContextWindowUsage,
7
7
  type ParsedQuotaUsageData,
8
8
  } from "./status-parsers.ts";
9
+
10
+ /**
11
+ * Format countdown seconds into a human-readable string for TUI display.
12
+ * Examples: "4h 32m", "45m 12s", "12s"
13
+ */
14
+ export function formatCountdownForDisplay(seconds: number): string {
15
+ if (seconds <= 0) return "RESET";
16
+
17
+ const days = Math.floor(seconds / 86400);
18
+ const hours = Math.floor((seconds % 86400) / 3600);
19
+ const minutes = Math.floor((seconds % 3600) / 60);
20
+ const secs = seconds % 60;
21
+
22
+ if (days > 0) {
23
+ return `${days}d ${hours}h`;
24
+ }
25
+ if (hours > 0) {
26
+ return `${hours}h ${minutes}m`;
27
+ }
28
+ if (minutes > 0) {
29
+ return `${minutes}m ${secs}s`;
30
+ }
31
+ return `${secs}s`;
32
+ }
33
+
34
+ /**
35
+ * Build a TUI-signal exhaustion status line with optional countdown.
36
+ * This is used when a provider (like GLM) hits its limit.
37
+ */
38
+ export function buildExhaustionStatusLine(
39
+ provider: string,
40
+ view: ProviderMirrorRecord,
41
+ countdownSeconds?: number,
42
+ ): string {
43
+ const label = providerDisplayName(provider);
44
+ const limitType = view.limitType ?? "tokens";
45
+ const reset = view.resets_at ?? view.h5_resets_at ?? "soon";
46
+
47
+ if (countdownSeconds !== undefined && countdownSeconds > 0) {
48
+ const countdown = formatCountdownForDisplay(countdownSeconds);
49
+ return `${label}: ⏳ ${countdown} (${limitType} hit)`;
50
+ }
51
+
52
+ return `${label}: limit hit (${limitType}), reset ${reset}`;
53
+ }
9
54
  import { getProviderLabel } from "./packages/types/src/ai-providers.js";
10
55
  import {
11
56
  providerHasContinuousScrape,
@@ -153,14 +198,14 @@ export function buildFooterStatusValue(
153
198
  const weeklyResets = view.weekly_resets_at ?? "soon";
154
199
 
155
200
  let statusLine: string;
156
- if (view.h5_used_pct !== undefined) {
201
+ if (view.h5_used_pct === undefined) {
202
+ // OpenAI: weekly-only (no 5h window)
203
+ statusLine = `week: ${weeklyLeft.toFixed(0)}% left (resets ${weeklyResets})`;
204
+ } else {
157
205
  // MiniMax: has both 5h and weekly windows
158
206
  const h5Pct = view.h5_used_pct;
159
207
  const h5Left = Math.max(0, 100 - h5Pct);
160
208
  statusLine = `5h: ${h5Left.toFixed(0)}% left · week: ${weeklyLeft.toFixed(0)}% left`;
161
- } else {
162
- // OpenAI: weekly-only (no 5h window)
163
- statusLine = `week: ${weeklyLeft.toFixed(0)}% left (resets ${weeklyResets})`;
164
209
  }
165
210
 
166
211
  const freshnessSuffix =
@@ -0,0 +1,323 @@
1
+ /**
2
+ * GLM Quota Countdown Tests
3
+ *
4
+ * Run with: bun test harness/glm-quota-countdown.test.ts
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
8
+ import {
9
+ parseGLMResetTime,
10
+ formatCountdown,
11
+ GLMQuotaCountdown,
12
+ } from "./glm-quota-countdown.js";
13
+
14
+ describe("parseGLMResetTime", () => {
15
+ it("parses full datetime format 'reset at 2026-08-25 01:47:16'", () => {
16
+ const result = parseGLMResetTime(
17
+ 'Error: 429: {"code":"1308","message":"Usage limit reached for 5 hour. Your limit will reset at 2026-08-25 01:47:16"}',
18
+ );
19
+ expect(result).not.toBeNull();
20
+ const date = new Date(result!);
21
+ expect(date.getUTCFullYear()).toBe(2026);
22
+ expect(date.getUTCMonth()).toBe(7); // August is 7 (0-indexed)
23
+ expect(date.getUTCDate()).toBe(25);
24
+ });
25
+
26
+ it("parses full datetime format with T separator", () => {
27
+ const result = parseGLMResetTime(
28
+ "Your limit will reset at 2026-08-25T01:47:16",
29
+ );
30
+ expect(result).not.toBeNull();
31
+ const date = new Date(result!);
32
+ expect(date.getUTCFullYear()).toBe(2026);
33
+ });
34
+
35
+ it("parses time-only format when date is implied", () => {
36
+ const result = parseGLMResetTime("will reset at 14:30:00");
37
+ expect(result).not.toBeNull();
38
+ const date = new Date(result!);
39
+ expect(date.getUTCHours()).toBe(14);
40
+ expect(date.getUTCMinutes()).toBe(30);
41
+ });
42
+
43
+ it("returns null for non-GLM messages", () => {
44
+ expect(parseGLMResetTime("Hello world")).toBeNull();
45
+ expect(parseGLMResetTime("OpenAI rate limit exceeded")).toBeNull();
46
+ });
47
+
48
+ it("handles GLM error with code 1308", () => {
49
+ const result = parseGLMResetTime(
50
+ '{"code":"1308","message":"Usage limit reached for 5 hour. Your limit will reset at 2026-08-25 01:47:16"}',
51
+ );
52
+ expect(result).not.toBeNull();
53
+ });
54
+
55
+ it("parses alternative format 'reset at 2026-08-25 01:47:16'", () => {
56
+ const result = parseGLMResetTime(
57
+ "reset at 2026-08-25 01:47:16 - please retry later",
58
+ );
59
+ expect(result).not.toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("formatCountdown", () => {
64
+ it("formats days, hours, minutes, seconds", () => {
65
+ expect(formatCountdown(90061)).toBe("1d 1h 1m 1s"); // 1 day, 1 hour, 1 min, 1 sec
66
+ });
67
+
68
+ it("formats hours and minutes when no days", () => {
69
+ expect(formatCountdown(3661)).toBe("1h 1m 1s");
70
+ });
71
+
72
+ it("formats minutes and seconds when no hours", () => {
73
+ expect(formatCountdown(125)).toBe("2m 5s");
74
+ });
75
+
76
+ it("formats seconds only when less than a minute", () => {
77
+ expect(formatCountdown(45)).toBe("45s");
78
+ });
79
+
80
+ it("returns 'RESET NOW' for zero or negative", () => {
81
+ expect(formatCountdown(0)).toBe("RESET NOW");
82
+ expect(formatCountdown(-100)).toBe("RESET NOW");
83
+ });
84
+
85
+ it("handles exactly 24 hours", () => {
86
+ expect(formatCountdown(86400)).toBe("1d 0h 0m 0s");
87
+ });
88
+
89
+ it("handles exactly 1 hour", () => {
90
+ expect(formatCountdown(3600)).toBe("1h 0m 0s");
91
+ });
92
+
93
+ it("handles exactly 1 minute", () => {
94
+ expect(formatCountdown(60)).toBe("1m 0s");
95
+ });
96
+ });
97
+
98
+ describe("GLMQuotaCountdown", () => {
99
+ let countdown: GLMQuotaCountdown;
100
+ let mockCheckpoint: any;
101
+ let mockTransition: any;
102
+ let mockReadProvider: any;
103
+
104
+ beforeEach(() => {
105
+ countdown = new GLMQuotaCountdown();
106
+
107
+ // Track calls for verification
108
+ mockCheckpoint = { jobId: "test-job", status: "paused_quota" };
109
+ mockTransition = { success: true };
110
+ mockReadProvider = null;
111
+ });
112
+
113
+ afterEach(() => {
114
+ countdown.dispose();
115
+ });
116
+
117
+ describe("hasCountdown / getCountdown", () => {
118
+ it("returns false initially for unknown job", () => {
119
+ expect(countdown.hasCountdown("unknown")).toBe(false);
120
+ expect(countdown.getCountdown("unknown")).toBeNull();
121
+ });
122
+ });
123
+
124
+ describe("startCountdown", () => {
125
+ it("starts countdown and stores state", async () => {
126
+ const futureDate = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
127
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
128
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
129
+
130
+ const state = await countdown.startCountdown(
131
+ "job-1",
132
+ futureDate,
133
+ mockMirror,
134
+ mockMachine,
135
+ );
136
+
137
+ expect(state.jobId).toBe("job-1");
138
+ expect(state.resetAt).toBe(futureDate);
139
+ expect(state.totalSeconds).toBeGreaterThan(3500); // ~1 hour
140
+ expect(countdown.hasCountdown("job-1")).toBe(true);
141
+ });
142
+
143
+ it("cancels existing countdown before starting new one", async () => {
144
+ const futureDate1 = new Date(Date.now() + 3600000).toISOString();
145
+ const futureDate2 = new Date(Date.now() + 7200000).toISOString();
146
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
147
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
148
+
149
+ await countdown.startCountdown(
150
+ "job-1",
151
+ futureDate1,
152
+ mockMirror,
153
+ mockMachine,
154
+ );
155
+
156
+ const state = await countdown.startCountdown(
157
+ "job-1",
158
+ futureDate2,
159
+ mockMirror,
160
+ mockMachine,
161
+ );
162
+
163
+ // Should have only one countdown
164
+ expect(countdown.getAllCountdowns().length).toBe(1);
165
+ expect(state.resetAt).toBe(futureDate2);
166
+ });
167
+
168
+ it("updates mirror store with reset time", async () => {
169
+ const futureDate = new Date(Date.now() + 3600000).toISOString();
170
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
171
+ let capturedRecord: any = null;
172
+ const mockMirror = createMockMirror(
173
+ () => mockReadProvider,
174
+ (_p, r) => {
175
+ capturedRecord = r;
176
+ },
177
+ );
178
+
179
+ await countdown.startCountdown("job-1", futureDate, mockMirror, mockMachine);
180
+
181
+ expect(capturedRecord).not.toBeNull();
182
+ expect(capturedRecord.provider).toBe("glm");
183
+ expect(capturedRecord.exhausted).toBe(true);
184
+ expect(capturedRecord.h5_resets_at).toBe(futureDate);
185
+ });
186
+ });
187
+
188
+ describe("startFromError", () => {
189
+ it("parses reset time from error message", async () => {
190
+ const errorMsg =
191
+ 'Error: 429: {"code":"1308","message":"Usage limit reached for 5 hour. Your limit will reset at 2099-12-31 23:59:59"}';
192
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
193
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
194
+
195
+ const state = await countdown.startFromError(
196
+ "job-1",
197
+ errorMsg,
198
+ mockMirror,
199
+ mockMachine,
200
+ );
201
+
202
+ expect(state).not.toBeNull();
203
+ expect(state!.jobId).toBe("job-1");
204
+ expect(countdown.hasCountdown("job-1")).toBe(true);
205
+ });
206
+
207
+ it("returns null when reset time cannot be parsed", async () => {
208
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
209
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
210
+
211
+ const state = await countdown.startFromError(
212
+ "job-1",
213
+ "Some unrelated error message",
214
+ mockMirror,
215
+ mockMachine,
216
+ );
217
+
218
+ expect(state).toBeNull();
219
+ });
220
+ });
221
+
222
+ describe("cancelCountdown", () => {
223
+ it("cancels active countdown", async () => {
224
+ const futureDate = new Date(Date.now() + 3600000).toISOString();
225
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
226
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
227
+
228
+ await countdown.startCountdown("job-1", futureDate, mockMirror, mockMachine);
229
+
230
+ expect(countdown.hasCountdown("job-1")).toBe(true);
231
+
232
+ countdown.cancelCountdown("job-1");
233
+
234
+ expect(countdown.hasCountdown("job-1")).toBe(false);
235
+ });
236
+
237
+ it("handles cancelling non-existent countdown", () => {
238
+ // Should not throw
239
+ expect(() => countdown.cancelCountdown("non-existent")).not.toThrow();
240
+ });
241
+ });
242
+
243
+ describe("onTick callback", () => {
244
+ it("receives tick events", async () => {
245
+ const ticks: any[] = [];
246
+ const futureDate = new Date(Date.now() + 3600000).toISOString();
247
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
248
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
249
+
250
+ const unsubscribe = countdown.onTick((event) => {
251
+ ticks.push(event);
252
+ });
253
+
254
+ await countdown.startCountdown("job-1", futureDate, mockMirror, mockMachine);
255
+
256
+ // Wait a bit for initial tick
257
+ await new Promise((r) => setTimeout(r, 100));
258
+
259
+ expect(ticks.length).toBeGreaterThan(0);
260
+ expect(ticks[0].jobId).toBe("job-1");
261
+ expect(ticks[0].remainingFormatted).toBeDefined();
262
+
263
+ unsubscribe();
264
+ });
265
+ });
266
+
267
+ describe("getAllCountdowns", () => {
268
+ it("returns all active countdowns", async () => {
269
+ const futureDate = new Date(Date.now() + 3600000).toISOString();
270
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
271
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
272
+
273
+ await countdown.startCountdown("job-1", futureDate, mockMirror, mockMachine);
274
+
275
+ await countdown.startCountdown("job-2", futureDate, mockMirror, mockMachine);
276
+
277
+ const all = countdown.getAllCountdowns();
278
+ expect(all.length).toBe(2);
279
+ expect(all.map((c) => c.jobId)).toContain("job-1");
280
+ expect(all.map((c) => c.jobId)).toContain("job-2");
281
+ });
282
+ });
283
+
284
+ describe("dispose", () => {
285
+ it("clears all timers and countdowns", async () => {
286
+ const futureDate = new Date(Date.now() + 3600000).toISOString();
287
+ const mockMachine = createMockMachine(mockCheckpoint, mockTransition);
288
+ const mockMirror = createMockMirror(() => mockReadProvider, noopWrite);
289
+
290
+ await countdown.startCountdown("job-1", futureDate, mockMirror, mockMachine);
291
+
292
+ await countdown.startCountdown("job-2", futureDate, mockMirror, mockMachine);
293
+
294
+ expect(countdown.getAllCountdowns().length).toBe(2);
295
+
296
+ countdown.dispose();
297
+
298
+ expect(countdown.getAllCountdowns().length).toBe(0);
299
+ });
300
+ });
301
+ });
302
+
303
+ // No-op write function for tests that don't need to verify write calls
304
+ function noopWrite(_provider: string, _record: any): void {}
305
+
306
+ // Helper: Create mock JobStateMachine
307
+ function createMockMachine(getCheckpointResult: any, transitionResult: any) {
308
+ return {
309
+ getCheckpoint: () => getCheckpointResult,
310
+ transition: async () => transitionResult,
311
+ } as any;
312
+ }
313
+
314
+ // Helper: Create mock MirrorStore
315
+ function createMockMirror(
316
+ readProviderFn: () => any,
317
+ writeProviderFn: (provider: string, record: any) => void,
318
+ ) {
319
+ return {
320
+ readProvider: readProviderFn,
321
+ writeProvider: writeProviderFn,
322
+ } as any;
323
+ }