@ai-devkit/agent-manager 0.34.0 → 0.36.0

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.
Files changed (211) hide show
  1. package/dist/AgentManager.d.ts +2 -0
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +25 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/__tests__/AgentManager.test.js +74 -0
  6. package/dist/__tests__/AgentManager.test.js.map +1 -1
  7. package/dist/__tests__/adapters/AntigravityCliAdapter.test.js +381 -0
  8. package/dist/__tests__/adapters/AntigravityCliAdapter.test.js.map +1 -0
  9. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +28 -0
  10. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -1
  11. package/dist/__tests__/adapters/CodexAdapter.test.js +7 -0
  12. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
  13. package/dist/__tests__/adapters/KiroAdapter.test.js +607 -0
  14. package/dist/__tests__/adapters/KiroAdapter.test.js.map +1 -0
  15. package/dist/__tests__/capacity/claude.test.js +485 -0
  16. package/dist/__tests__/capacity/claude.test.js.map +1 -0
  17. package/dist/__tests__/capacity/openai.test.js +672 -0
  18. package/dist/__tests__/capacity/openai.test.js.map +1 -0
  19. package/dist/__tests__/capacity/wham.test.js +144 -0
  20. package/dist/__tests__/capacity/wham.test.js.map +1 -0
  21. package/dist/__tests__/providers/copilot/CopilotAdapter.test.js +18 -0
  22. package/dist/__tests__/providers/copilot/CopilotAdapter.test.js.map +1 -1
  23. package/dist/__tests__/providers/gemini/GeminiCliAdapter.test.js +36 -0
  24. package/dist/__tests__/providers/gemini/GeminiCliAdapter.test.js.map +1 -1
  25. package/dist/__tests__/providers/grok/GrokAgentMapper.test.js +56 -0
  26. package/dist/__tests__/providers/grok/GrokAgentMapper.test.js.map +1 -0
  27. package/dist/__tests__/{adapters → providers/grok}/GrokCliAdapter.test.js +19 -6
  28. package/dist/__tests__/providers/grok/GrokCliAdapter.test.js.map +1 -0
  29. package/dist/__tests__/providers/grok/GrokSessionLocator.test.js +133 -0
  30. package/dist/__tests__/providers/grok/GrokSessionLocator.test.js.map +1 -0
  31. package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js +24 -0
  32. package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js.map +1 -1
  33. package/dist/__tests__/providers/pi/PiAdapter.test.js +30 -0
  34. package/dist/__tests__/providers/pi/PiAdapter.test.js.map +1 -1
  35. package/dist/__tests__/utils/agents.test.js +6 -0
  36. package/dist/__tests__/utils/agents.test.js.map +1 -1
  37. package/dist/__tests__/utils/session.test.js +18 -1
  38. package/dist/__tests__/utils/session.test.js.map +1 -1
  39. package/dist/adapters/AgentAdapter.d.ts +9 -1
  40. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  41. package/dist/adapters/AgentAdapter.js.map +1 -1
  42. package/dist/adapters/AntigravityCliAdapter.d.ts +57 -0
  43. package/dist/adapters/AntigravityCliAdapter.d.ts.map +1 -0
  44. package/dist/adapters/AntigravityCliAdapter.js +273 -0
  45. package/dist/adapters/AntigravityCliAdapter.js.map +1 -0
  46. package/dist/adapters/KiroAdapter.d.ts +46 -0
  47. package/dist/adapters/KiroAdapter.d.ts.map +1 -0
  48. package/dist/adapters/KiroAdapter.js +344 -0
  49. package/dist/adapters/KiroAdapter.js.map +1 -0
  50. package/dist/adapters/index.d.ts +3 -1
  51. package/dist/adapters/index.d.ts.map +1 -1
  52. package/dist/adapters/index.js +3 -1
  53. package/dist/adapters/index.js.map +1 -1
  54. package/dist/capacity/claude.d.ts +17 -0
  55. package/dist/capacity/claude.d.ts.map +1 -0
  56. package/dist/capacity/claude.js +271 -0
  57. package/dist/capacity/claude.js.map +1 -0
  58. package/dist/capacity/codex.d.ts +1 -2
  59. package/dist/capacity/codex.d.ts.map +1 -1
  60. package/dist/capacity/codex.js +5 -47
  61. package/dist/capacity/codex.js.map +1 -1
  62. package/dist/capacity/index.d.ts +11 -0
  63. package/dist/capacity/index.d.ts.map +1 -1
  64. package/dist/capacity/index.js +17 -0
  65. package/dist/capacity/index.js.map +1 -1
  66. package/dist/capacity/openai.d.ts +48 -0
  67. package/dist/capacity/openai.d.ts.map +1 -0
  68. package/dist/capacity/openai.js +312 -0
  69. package/dist/capacity/openai.js.map +1 -0
  70. package/dist/capacity/wham.d.ts +16 -0
  71. package/dist/capacity/wham.d.ts.map +1 -0
  72. package/dist/capacity/wham.js +66 -0
  73. package/dist/capacity/wham.js.map +1 -0
  74. package/dist/index.d.ts +5 -3
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +4 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/providers/claude/ClaudeCodeAdapter.d.ts +2 -0
  79. package/dist/providers/claude/ClaudeCodeAdapter.d.ts.map +1 -1
  80. package/dist/providers/claude/ClaudeCodeAdapter.js +22 -19
  81. package/dist/providers/claude/ClaudeCodeAdapter.js.map +1 -1
  82. package/dist/providers/claude/ClaudeSessionLocator.d.ts +4 -0
  83. package/dist/providers/claude/ClaudeSessionLocator.d.ts.map +1 -1
  84. package/dist/providers/claude/ClaudeSessionLocator.js +18 -1
  85. package/dist/providers/claude/ClaudeSessionLocator.js.map +1 -1
  86. package/dist/providers/codex/CodexAdapter.d.ts +1 -0
  87. package/dist/providers/codex/CodexAdapter.d.ts.map +1 -1
  88. package/dist/providers/codex/CodexAdapter.js +8 -0
  89. package/dist/providers/codex/CodexAdapter.js.map +1 -1
  90. package/dist/providers/copilot/CopilotAdapter.d.ts +2 -0
  91. package/dist/providers/copilot/CopilotAdapter.d.ts.map +1 -1
  92. package/dist/providers/copilot/CopilotAdapter.js +24 -9
  93. package/dist/providers/copilot/CopilotAdapter.js.map +1 -1
  94. package/dist/providers/copilot/CopilotSessionLocator.d.ts +1 -0
  95. package/dist/providers/copilot/CopilotSessionLocator.d.ts.map +1 -1
  96. package/dist/providers/copilot/CopilotSessionLocator.js +9 -1
  97. package/dist/providers/copilot/CopilotSessionLocator.js.map +1 -1
  98. package/dist/providers/gemini/GeminiCliAdapter.d.ts +1 -0
  99. package/dist/providers/gemini/GeminiCliAdapter.d.ts.map +1 -1
  100. package/dist/providers/gemini/GeminiCliAdapter.js +9 -0
  101. package/dist/providers/gemini/GeminiCliAdapter.js.map +1 -1
  102. package/dist/providers/grok/GrokAgentMapper.d.ts +14 -0
  103. package/dist/providers/grok/GrokAgentMapper.d.ts.map +1 -0
  104. package/dist/providers/grok/GrokAgentMapper.js +36 -0
  105. package/dist/providers/grok/GrokAgentMapper.js.map +1 -0
  106. package/dist/providers/grok/GrokCliAdapter.d.ts +36 -0
  107. package/dist/providers/grok/GrokCliAdapter.d.ts.map +1 -0
  108. package/dist/providers/grok/GrokCliAdapter.js +98 -0
  109. package/dist/providers/grok/GrokCliAdapter.js.map +1 -0
  110. package/dist/providers/grok/GrokSessionLocator.d.ts +62 -0
  111. package/dist/providers/grok/GrokSessionLocator.d.ts.map +1 -0
  112. package/dist/providers/grok/GrokSessionLocator.js +140 -0
  113. package/dist/providers/grok/GrokSessionLocator.js.map +1 -0
  114. package/dist/providers/grok/GrokSessionParser.d.ts +54 -0
  115. package/dist/providers/grok/GrokSessionParser.d.ts.map +1 -0
  116. package/dist/providers/grok/GrokSessionParser.js +122 -0
  117. package/dist/providers/grok/GrokSessionParser.js.map +1 -0
  118. package/dist/providers/opencode/OpenCodeAdapter.d.ts +1 -0
  119. package/dist/providers/opencode/OpenCodeAdapter.d.ts.map +1 -1
  120. package/dist/providers/opencode/OpenCodeAdapter.js +3 -0
  121. package/dist/providers/opencode/OpenCodeAdapter.js.map +1 -1
  122. package/dist/providers/opencode/OpenCodeSessionLocator.d.ts +2 -0
  123. package/dist/providers/opencode/OpenCodeSessionLocator.d.ts.map +1 -1
  124. package/dist/providers/opencode/OpenCodeSessionLocator.js +31 -12
  125. package/dist/providers/opencode/OpenCodeSessionLocator.js.map +1 -1
  126. package/dist/providers/pi/PiAdapter.d.ts +1 -0
  127. package/dist/providers/pi/PiAdapter.d.ts.map +1 -1
  128. package/dist/providers/pi/PiAdapter.js +10 -0
  129. package/dist/providers/pi/PiAdapter.js.map +1 -1
  130. package/dist/providers/pi/PiSessionLocator.d.ts +1 -0
  131. package/dist/providers/pi/PiSessionLocator.d.ts.map +1 -1
  132. package/dist/providers/pi/PiSessionLocator.js +8 -1
  133. package/dist/providers/pi/PiSessionLocator.js.map +1 -1
  134. package/dist/readiness/AgentReadiness.d.ts +1 -1
  135. package/dist/readiness/AgentReadiness.d.ts.map +1 -1
  136. package/dist/readiness/AgentReadiness.js.map +1 -1
  137. package/dist/runtime/ManagedAgentRuntime.js +3 -1
  138. package/dist/runtime/ManagedAgentRuntime.js.map +1 -1
  139. package/dist/runtime/herdr/HerdrAgentDiscovery.js +10 -0
  140. package/dist/runtime/herdr/HerdrAgentDiscovery.js.map +1 -1
  141. package/dist/utils/agents.d.ts +1 -1
  142. package/dist/utils/agents.d.ts.map +1 -1
  143. package/dist/utils/agents.js +11 -0
  144. package/dist/utils/agents.js.map +1 -1
  145. package/dist/utils/session.d.ts +2 -0
  146. package/dist/utils/session.d.ts.map +1 -1
  147. package/dist/utils/session.js +3 -0
  148. package/dist/utils/session.js.map +1 -1
  149. package/package.json +1 -1
  150. package/src/AgentManager.ts +34 -0
  151. package/src/__tests__/AgentManager.test.ts +60 -0
  152. package/src/__tests__/adapters/AntigravityCliAdapter.test.ts +324 -0
  153. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +23 -0
  154. package/src/__tests__/adapters/CodexAdapter.test.ts +7 -0
  155. package/src/__tests__/adapters/KiroAdapter.test.ts +534 -0
  156. package/src/__tests__/capacity/claude.test.ts +467 -0
  157. package/src/__tests__/capacity/fixtures/claude-credentials.json +7 -0
  158. package/src/__tests__/capacity/fixtures/claude-usage-malformed.json +1 -0
  159. package/src/__tests__/capacity/fixtures/claude-usage-partial.json +15 -0
  160. package/src/__tests__/capacity/fixtures/claude-usage.json +63 -0
  161. package/src/__tests__/capacity/fixtures/openai-auth.json +6 -0
  162. package/src/__tests__/capacity/fixtures/openai-codex-auth.json +9 -0
  163. package/src/__tests__/capacity/fixtures/openai-usage-completions.json +67 -0
  164. package/src/__tests__/capacity/fixtures/openai-usage-responses.json +20 -0
  165. package/src/__tests__/capacity/fixtures/wham-usage.json +32 -0
  166. package/src/__tests__/capacity/openai.test.ts +570 -0
  167. package/src/__tests__/capacity/wham.test.ts +104 -0
  168. package/src/__tests__/providers/copilot/CopilotAdapter.test.ts +12 -0
  169. package/src/__tests__/providers/gemini/GeminiCliAdapter.test.ts +21 -0
  170. package/src/__tests__/providers/grok/GrokAgentMapper.test.ts +57 -0
  171. package/src/__tests__/{adapters → providers/grok}/GrokCliAdapter.test.ts +18 -9
  172. package/src/__tests__/providers/grok/GrokSessionLocator.test.ts +106 -0
  173. package/src/__tests__/providers/opencode/OpenCodeAdapter.test.ts +18 -0
  174. package/src/__tests__/providers/pi/PiAdapter.test.ts +15 -0
  175. package/src/__tests__/utils/agents.test.ts +7 -0
  176. package/src/__tests__/utils/session.test.ts +11 -1
  177. package/src/adapters/AgentAdapter.ts +11 -0
  178. package/src/adapters/AntigravityCliAdapter.ts +356 -0
  179. package/src/adapters/KiroAdapter.ts +456 -0
  180. package/src/adapters/index.ts +3 -1
  181. package/src/capacity/claude.ts +313 -0
  182. package/src/capacity/codex.ts +25 -134
  183. package/src/capacity/index.ts +33 -4
  184. package/src/capacity/openai.ts +361 -0
  185. package/src/capacity/wham.ts +84 -0
  186. package/src/index.ts +11 -2
  187. package/src/providers/claude/ClaudeCodeAdapter.ts +28 -23
  188. package/src/providers/claude/ClaudeSessionLocator.ts +20 -0
  189. package/src/providers/codex/CodexAdapter.ts +8 -0
  190. package/src/providers/copilot/CopilotAdapter.ts +26 -10
  191. package/src/providers/copilot/CopilotSessionLocator.ts +7 -1
  192. package/src/providers/gemini/GeminiCliAdapter.ts +8 -0
  193. package/src/providers/grok/GrokAgentMapper.ts +41 -0
  194. package/src/providers/grok/GrokCliAdapter.ts +125 -0
  195. package/src/providers/grok/GrokSessionLocator.ts +179 -0
  196. package/src/providers/grok/GrokSessionParser.ts +166 -0
  197. package/src/providers/opencode/OpenCodeAdapter.ts +4 -0
  198. package/src/providers/opencode/OpenCodeSessionLocator.ts +43 -15
  199. package/src/providers/pi/PiAdapter.ts +12 -0
  200. package/src/providers/pi/PiSessionLocator.ts +16 -1
  201. package/src/readiness/AgentReadiness.ts +4 -1
  202. package/src/runtime/ManagedAgentRuntime.ts +2 -0
  203. package/src/runtime/herdr/HerdrAgentDiscovery.ts +5 -1
  204. package/src/utils/agents.ts +11 -1
  205. package/src/utils/session.ts +11 -0
  206. package/dist/__tests__/adapters/GrokCliAdapter.test.js.map +0 -1
  207. package/dist/adapters/GrokCliAdapter.d.ts +0 -80
  208. package/dist/adapters/GrokCliAdapter.d.ts.map +0 -1
  209. package/dist/adapters/GrokCliAdapter.js +0 -310
  210. package/dist/adapters/GrokCliAdapter.js.map +0 -1
  211. package/src/adapters/GrokCliAdapter.ts +0 -408
@@ -0,0 +1,570 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import {
6
+ buildOpenAiReport,
7
+ parseOpenAiUsage,
8
+ probeOpenAiCapacity,
9
+ resolveOpenAiApiKey,
10
+ resolveOpenAiCredential,
11
+ } from "../../capacity/openai.js";
12
+
13
+ const checkedAt = "2026-09-26T10:00:00.000Z";
14
+ const fixture = (name: string) =>
15
+ readFile(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), "utf8");
16
+ const fixtureString = (name: string) =>
17
+ readFileSync(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), "utf8");
18
+
19
+ describe("OpenAI credential resolution", () => {
20
+ it("prefers OPENAI_API_KEY without reading Pi auth", async () => {
21
+ const readAuth = vi.fn(async () => fixture("openai-auth.json"));
22
+ await expect(
23
+ resolveOpenAiApiKey({
24
+ env: { OPENAI_API_KEY: "env-openai-test-key" },
25
+ readFile: readAuth,
26
+ }),
27
+ ).resolves.toBe("env-openai-test-key");
28
+ expect(readAuth).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("reads the Pi openai api_key credential from ~/.pi/agent/auth.json", async () => {
32
+ const readAuth = vi.fn(async () => fixture("openai-auth.json"));
33
+ await expect(
34
+ resolveOpenAiApiKey({ env: { HOME: "/users/test" }, readFile: readAuth }),
35
+ ).resolves.toBe("pi-openai-test-key");
36
+ expect(readAuth).toHaveBeenCalledWith("/users/test/.pi/agent/auth.json", "utf8");
37
+ });
38
+
39
+ it.each([
40
+ [
41
+ "missing",
42
+ async () => Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
43
+ ],
44
+ ["invalid JSON", async () => "{"],
45
+ ["wrong credential type", async () => JSON.stringify({ openai: { type: "oauth", key: "x" } })],
46
+ ["missing key", async () => JSON.stringify({ openai: { type: "api_key" } })],
47
+ ])("fails clearly for %s credentials without exposing file content", async (_name, readAuth) => {
48
+ await expect(
49
+ resolveOpenAiApiKey({
50
+ env: { HOME: "/users/test" },
51
+ readFile: readAuth,
52
+ }),
53
+ ).rejects.toThrow(/OpenAI credentials not found|OpenAI Pi auth file/);
54
+ });
55
+ });
56
+
57
+ describe("OpenAI tiered credential resolution", () => {
58
+ const oauthFixture = () => fixture("openai-codex-auth.json");
59
+ const bothCredentials = JSON.stringify({
60
+ openai: { type: "api_key", key: "pi-openai-test-key" },
61
+ "openai-codex": {
62
+ type: "oauth",
63
+ access: "fixture-header.eyJleHAiOjE3OTMwMDAwMDB9.fixture-signature",
64
+ refresh: "fixture-refresh-token",
65
+ expires: 1793000000000,
66
+ accountId: "fixture-chatgpt-account-id",
67
+ },
68
+ });
69
+
70
+ it("prefers the Pi openai platform credential over the openai-codex OAuth login", async () => {
71
+ await expect(
72
+ resolveOpenAiCredential({
73
+ env: { HOME: "/users/test" },
74
+ readFile: async () => bothCredentials,
75
+ }),
76
+ ).resolves.toEqual({ kind: "platform", key: "pi-openai-test-key" });
77
+ });
78
+
79
+ it("resolves the Pi openai-codex OAuth login when no platform credential exists", async () => {
80
+ await expect(
81
+ resolveOpenAiCredential({
82
+ env: { HOME: "/users/test" },
83
+ readFile: async () => oauthFixture(),
84
+ }),
85
+ ).resolves.toEqual({
86
+ kind: "oauth",
87
+ access: "fixture-header.eyJleHAiOjE3OTMwMDAwMDB9.fixture-signature",
88
+ accountId: "fixture-chatgpt-account-id",
89
+ expiresMs: 1793000000000,
90
+ });
91
+ });
92
+
93
+ it("normalizes second-based expires values to epoch milliseconds", async () => {
94
+ const auth = JSON.stringify({
95
+ "openai-codex": {
96
+ type: "oauth",
97
+ access: "fixture-access",
98
+ refresh: "fixture-refresh",
99
+ expires: 1793000000,
100
+ accountId: "fixture-chatgpt-account-id",
101
+ },
102
+ });
103
+ await expect(
104
+ resolveOpenAiCredential({ env: { HOME: "/users/test" }, readFile: async () => auth }),
105
+ ).resolves.toMatchObject({ kind: "oauth", expiresMs: 1793000000000 });
106
+ });
107
+
108
+ it("keeps a null expiry for OAuth logins without usable expires metadata", async () => {
109
+ const auth = JSON.stringify({
110
+ "openai-codex": {
111
+ type: "oauth",
112
+ access: "fixture-access",
113
+ accountId: "fixture-chatgpt-account-id",
114
+ },
115
+ });
116
+ await expect(
117
+ resolveOpenAiCredential({ env: { HOME: "/users/test" }, readFile: async () => auth }),
118
+ ).resolves.toMatchObject({ kind: "oauth", expiresMs: null });
119
+ });
120
+
121
+ it.each([
122
+ ["wrong credential type", JSON.stringify({ "openai-codex": { type: "api_key", key: "x" } })],
123
+ ["missing access token", JSON.stringify({ "openai-codex": { type: "oauth", accountId: "a" } })],
124
+ ["missing account id", JSON.stringify({ "openai-codex": { type: "oauth", access: "t" } })],
125
+ ])(
126
+ "skips a malformed openai-codex credential (%s) with a sanitized error",
127
+ async (_name, auth) => {
128
+ await expect(
129
+ resolveOpenAiCredential({ env: { HOME: "/users/test" }, readFile: async () => auth }),
130
+ ).rejects.toThrow("OpenAI credentials not found");
131
+ },
132
+ );
133
+
134
+ it("still throws for OAuth-only machines on the legacy platform-key resolver", async () => {
135
+ await expect(
136
+ resolveOpenAiApiKey({
137
+ env: { HOME: "/users/test" },
138
+ readFile: async () => oauthFixture(),
139
+ }),
140
+ ).rejects.toThrow("OpenAI credentials not found");
141
+ });
142
+ });
143
+
144
+ describe("OpenAI OAuth capacity probe", () => {
145
+ const freshOauth = () => fixture("openai-codex-auth.json");
146
+ const whamResponse = () => new Response(fixtureString("wham-usage.json"), { status: 200 });
147
+
148
+ it("reports wham usage windows for a fresh Pi OAuth login", async () => {
149
+ const fetch = vi.fn(async () => whamResponse());
150
+ const report = await probeOpenAiCapacity({
151
+ checkedAt,
152
+ env: { HOME: "/users/test" },
153
+ readFile: async () => freshOauth(),
154
+ fetch,
155
+ });
156
+ expect(fetch).toHaveBeenCalledWith(
157
+ "https://chatgpt.com/backend-api/wham/usage",
158
+ expect.objectContaining({
159
+ method: "GET",
160
+ headers: {
161
+ Authorization: "Bearer fixture-header.eyJleHAiOjE3OTMwMDAwMDB9.fixture-signature",
162
+ "ChatGPT-Account-Id": "fixture-chatgpt-account-id",
163
+ },
164
+ }),
165
+ );
166
+ expect(report).toMatchObject({
167
+ harness: "pi",
168
+ provider: "openai",
169
+ generatedAt: checkedAt,
170
+ authenticated: true,
171
+ available: "yes",
172
+ creditsRemaining: 4.5,
173
+ });
174
+ expect(report.windows.map((window) => window.id)).toEqual(["session", "weekly"]);
175
+ expect(JSON.stringify(report)).not.toContain("fixture-signature");
176
+ });
177
+
178
+ it("does not fetch and reports an authenticated-but-stale state for expired logins", async () => {
179
+ const staleAuth = JSON.stringify({
180
+ "openai-codex": {
181
+ type: "oauth",
182
+ access: "stale-fixture-access",
183
+ expires: 1790000000000,
184
+ accountId: "fixture-chatgpt-account-id",
185
+ },
186
+ });
187
+ const fetch = vi.fn();
188
+ const report = await probeOpenAiCapacity({
189
+ checkedAt,
190
+ env: { HOME: "/users/test" },
191
+ readFile: async () => staleAuth,
192
+ fetch,
193
+ });
194
+ expect(fetch).not.toHaveBeenCalled();
195
+ expect(report).toEqual({
196
+ harness: "pi",
197
+ provider: "openai",
198
+ generatedAt: checkedAt,
199
+ authenticated: true,
200
+ available: "unknown",
201
+ windows: [],
202
+ creditsRemaining: null,
203
+ });
204
+ });
205
+
206
+ it("treats second-based expiry metadata as stale the same way", async () => {
207
+ const staleAuth = JSON.stringify({
208
+ "openai-codex": {
209
+ type: "oauth",
210
+ access: "stale-fixture-access",
211
+ expires: 1790000000,
212
+ accountId: "fixture-chatgpt-account-id",
213
+ },
214
+ });
215
+ const fetch = vi.fn();
216
+ const report = await probeOpenAiCapacity({
217
+ checkedAt,
218
+ env: { HOME: "/users/test" },
219
+ readFile: async () => staleAuth,
220
+ fetch,
221
+ });
222
+ expect(fetch).not.toHaveBeenCalled();
223
+ expect(report.available).toBe("unknown");
224
+ });
225
+
226
+ it("falls back to the JWT exp when expires metadata is missing", async () => {
227
+ const expiredJwtAuth = JSON.stringify({
228
+ "openai-codex": {
229
+ type: "oauth",
230
+ access: "h.eyJleHAiOjE3ODkwMDAwMDB9.s",
231
+ accountId: "fixture-chatgpt-account-id",
232
+ },
233
+ });
234
+ const noFetch = vi.fn();
235
+ await expect(
236
+ probeOpenAiCapacity({
237
+ checkedAt,
238
+ env: { HOME: "/users/test" },
239
+ readFile: async () => expiredJwtAuth,
240
+ fetch: noFetch,
241
+ }),
242
+ ).resolves.toMatchObject({ authenticated: true, available: "unknown" });
243
+ expect(noFetch).not.toHaveBeenCalled();
244
+
245
+ const futureJwtAuth = JSON.stringify({
246
+ "openai-codex": {
247
+ type: "oauth",
248
+ access: "h.eyJleHAiOjE3OTMwMDAwMDB9.s",
249
+ accountId: "fixture-chatgpt-account-id",
250
+ },
251
+ });
252
+ const fetch = vi.fn(async () => whamResponse());
253
+ await expect(
254
+ probeOpenAiCapacity({
255
+ checkedAt,
256
+ env: { HOME: "/users/test" },
257
+ readFile: async () => futureJwtAuth,
258
+ fetch,
259
+ }),
260
+ ).resolves.toMatchObject({ authenticated: true, available: "yes" });
261
+ expect(fetch).toHaveBeenCalledOnce();
262
+ });
263
+
264
+ it("treats unparseable JWT payloads as unknown expiry and probes anyway", async () => {
265
+ const auth = JSON.stringify({
266
+ "openai-codex": {
267
+ type: "oauth",
268
+ access: "fixture-header.bm90LWpzb24.fixture-signature",
269
+ accountId: "fixture-chatgpt-account-id",
270
+ },
271
+ });
272
+ const fetch = vi.fn(async () => whamResponse());
273
+ await expect(
274
+ probeOpenAiCapacity({
275
+ checkedAt,
276
+ env: { HOME: "/users/test" },
277
+ readFile: async () => auth,
278
+ fetch,
279
+ }),
280
+ ).resolves.toMatchObject({ authenticated: true, available: "yes" });
281
+ expect(fetch).toHaveBeenCalledOnce();
282
+ });
283
+
284
+ it("treats non-JWT and exp-less access tokens as unknown expiry and probes anyway", async () => {
285
+ for (const access of ["opaque-fixture-access", "h.eyJzdWIiOiJ4In0.s"]) {
286
+ const auth = JSON.stringify({
287
+ "openai-codex": {
288
+ type: "oauth",
289
+ access,
290
+ accountId: "fixture-chatgpt-account-id",
291
+ },
292
+ });
293
+ const fetch = vi.fn(async () => whamResponse());
294
+ await expect(
295
+ probeOpenAiCapacity({
296
+ checkedAt,
297
+ env: { HOME: "/users/test" },
298
+ readFile: async () => auth,
299
+ fetch,
300
+ }),
301
+ ).resolves.toMatchObject({ authenticated: true, available: "yes" });
302
+ expect(fetch).toHaveBeenCalledOnce();
303
+ }
304
+ });
305
+
306
+ it("reports unknown availability when wham returns no usable windows", async () => {
307
+ const report = await probeOpenAiCapacity({
308
+ checkedAt,
309
+ env: { HOME: "/users/test" },
310
+ readFile: async () => freshOauth(),
311
+ fetch: vi.fn(async () => new Response("{}", { status: 200 })),
312
+ });
313
+ expect(report).toMatchObject({ authenticated: true, available: "unknown", windows: [] });
314
+ });
315
+
316
+ it("reports unauthenticated when wham rejects the OAuth token", async () => {
317
+ for (const status of [401, 403]) {
318
+ const report = await probeOpenAiCapacity({
319
+ checkedAt,
320
+ env: { HOME: "/users/test" },
321
+ readFile: async () => freshOauth(),
322
+ fetch: vi.fn(async () => new Response("denied", { status })),
323
+ });
324
+ expect(report).toMatchObject({
325
+ authenticated: false,
326
+ available: "unknown",
327
+ windows: [],
328
+ creditsRemaining: null,
329
+ });
330
+ }
331
+ });
332
+
333
+ it("throws sanitized errors for transport failures without leaking tokens", async () => {
334
+ const probe = (fetch: unknown) =>
335
+ probeOpenAiCapacity({
336
+ checkedAt,
337
+ env: { HOME: "/users/test" },
338
+ readFile: async () => freshOauth(),
339
+ fetch: fetch as typeof globalThis.fetch,
340
+ timeoutMs: 10,
341
+ });
342
+ await expect(
343
+ probe(
344
+ (_url: string, init: RequestInit) =>
345
+ new Promise((_resolve, reject) =>
346
+ init.signal?.addEventListener("abort", () => reject(new Error("aborted"))),
347
+ ),
348
+ ),
349
+ ).rejects.toThrow("OpenAI usage request failed");
350
+ await expect(probe(vi.fn(async () => new Response("boom", { status: 503 })))).rejects.toThrow(
351
+ "OpenAI usage request failed: HTTP 503",
352
+ );
353
+ await expect(
354
+ probe(vi.fn(async () => new Response("not-json", { status: 200 }))),
355
+ ).rejects.toThrow("OpenAI usage response is not valid JSON");
356
+ try {
357
+ await probe(vi.fn(async () => new Response("boom", { status: 503 })));
358
+ } catch (error) {
359
+ expect(String(error)).not.toContain("fixture-signature");
360
+ expect(String(error)).not.toContain("fixture-chatgpt-account-id");
361
+ }
362
+ });
363
+ });
364
+
365
+ describe("OpenAI usage mapping", () => {
366
+ it("sums per-model input and output tokens into UTC day buckets", async () => {
367
+ const buckets = parseOpenAiUsage(JSON.parse(await fixture("openai-usage-completions.json")));
368
+ expect(buckets).toEqual([
369
+ { startMs: 1790380800000, tokens: 2000 },
370
+ { startMs: 1790294400000, tokens: 5000 },
371
+ { startMs: 1789862400000, tokens: 1000 },
372
+ { startMs: 1789689600000, tokens: 19998 },
373
+ ]);
374
+ });
375
+
376
+ it("keeps empty buckets and skips entries it cannot place in time", () => {
377
+ expect(
378
+ parseOpenAiUsage({
379
+ object: "list",
380
+ data: [
381
+ null,
382
+ 42,
383
+ { aggregation_timestamp: 1790380800 },
384
+ { aggregation_timestamp: "yesterday", results: [] },
385
+ { aggregation_timestamp: 1790294400, results: null },
386
+ ],
387
+ }),
388
+ ).toEqual([
389
+ { startMs: 1790380800000, tokens: 0 },
390
+ { startMs: 1790294400000, tokens: 0 },
391
+ ]);
392
+ });
393
+
394
+ it("rejects responses without a usage data array", () => {
395
+ expect(() => parseOpenAiUsage({ object: "list" })).toThrow("Invalid OpenAI usage response");
396
+ expect(() => parseOpenAiUsage({ data: {} })).toThrow("Invalid OpenAI usage response");
397
+ });
398
+
399
+ it("builds today and 7-day windows without faked limits", async () => {
400
+ const buckets = [
401
+ ...parseOpenAiUsage(JSON.parse(await fixture("openai-usage-completions.json"))),
402
+ ...parseOpenAiUsage(JSON.parse(await fixture("openai-usage-responses.json"))),
403
+ ];
404
+ const report = buildOpenAiReport(buckets, checkedAt);
405
+ expect(report).toMatchObject({
406
+ harness: "pi",
407
+ provider: "openai",
408
+ generatedAt: checkedAt,
409
+ authenticated: true,
410
+ available: "yes",
411
+ creditsRemaining: null,
412
+ });
413
+ expect(report.windows).toEqual([
414
+ {
415
+ id: "openai:tokens:today",
416
+ label: "Tokens · today (UTC)",
417
+ limitType: "TOKENS_LIMIT",
418
+ durationMinutes: 1440,
419
+ usedPercent: null,
420
+ resetsAt: "2026-09-27T00:00:00.000Z",
421
+ total: null,
422
+ current: 2000,
423
+ remaining: null,
424
+ },
425
+ {
426
+ id: "openai:tokens:week",
427
+ label: "Tokens · 7 days",
428
+ limitType: "TOKENS_LIMIT",
429
+ durationMinutes: 10080,
430
+ usedPercent: null,
431
+ resetsAt: null,
432
+ total: null,
433
+ current: 13000,
434
+ remaining: null,
435
+ },
436
+ ]);
437
+ });
438
+
439
+ it("reports zero usage for days without buckets", () => {
440
+ const report = buildOpenAiReport([], checkedAt);
441
+ expect(report.windows.map((window) => window.current)).toEqual([0, 0]);
442
+ expect(report.windows[0].resetsAt).toBe("2026-09-27T00:00:00.000Z");
443
+ });
444
+ });
445
+
446
+ describe("OpenAI request", () => {
447
+ it("GETs both usage endpoints for the trailing week with only Bearer authentication", async () => {
448
+ const fetch = vi.fn(async (url: string) =>
449
+ url.includes("/responses?")
450
+ ? new Response(await fixture("openai-usage-responses.json"), {
451
+ status: 200,
452
+ })
453
+ : new Response(await fixture("openai-usage-completions.json"), {
454
+ status: 200,
455
+ }),
456
+ );
457
+ const report = await probeOpenAiCapacity({
458
+ checkedAt,
459
+ env: { OPENAI_API_KEY: "request-test-key" },
460
+ fetch,
461
+ });
462
+ const query = "start_time=1789812000&end_time=1790416800";
463
+ expect(fetch).toHaveBeenCalledTimes(2);
464
+ expect(fetch).toHaveBeenCalledWith(
465
+ `https://api.openai.com/v1/organization/usage/completions?${query}`,
466
+ expect.objectContaining({
467
+ method: "GET",
468
+ headers: { Authorization: "Bearer request-test-key" },
469
+ }),
470
+ );
471
+ expect(fetch).toHaveBeenCalledWith(
472
+ `https://api.openai.com/v1/organization/usage/responses?${query}`,
473
+ expect.objectContaining({
474
+ headers: { Authorization: "Bearer request-test-key" },
475
+ }),
476
+ );
477
+ expect(report.windows.map((window) => window.current)).toEqual([2000, 13000]);
478
+ expect(JSON.stringify(report)).not.toContain("request-test-key");
479
+ });
480
+
481
+ it("reports unauthenticated for rejected keys", async () => {
482
+ const report = await probeOpenAiCapacity({
483
+ checkedAt,
484
+ env: { OPENAI_API_KEY: "rejected-key" },
485
+ fetch: vi.fn(async () => new Response("denied", { status: 401 })),
486
+ });
487
+ expect(report).toMatchObject({
488
+ authenticated: false,
489
+ available: "unknown",
490
+ windows: [],
491
+ creditsRemaining: null,
492
+ });
493
+ });
494
+
495
+ it("degrades to authentication-only when usage requires an admin key", async () => {
496
+ const fetch = vi.fn(async (url: string) =>
497
+ url.endsWith("/models")
498
+ ? new Response("{}", { status: 200 })
499
+ : new Response("forbidden", { status: 403 }),
500
+ );
501
+ const report = await probeOpenAiCapacity({
502
+ checkedAt,
503
+ env: { OPENAI_API_KEY: "member-key" },
504
+ fetch,
505
+ });
506
+ expect(fetch).toHaveBeenCalledWith(
507
+ "https://api.openai.com/v1/models",
508
+ expect.objectContaining({
509
+ headers: { Authorization: "Bearer member-key" },
510
+ }),
511
+ );
512
+ expect(report).toMatchObject({
513
+ authenticated: true,
514
+ available: "unknown",
515
+ windows: [],
516
+ });
517
+ });
518
+
519
+ it("falls back to unauthenticated when the degraded check rejects the key", async () => {
520
+ const fetch = vi.fn(async (url: string) =>
521
+ url.endsWith("/models")
522
+ ? new Response("denied", { status: 401 })
523
+ : new Response("forbidden", { status: 403 }),
524
+ );
525
+ const report = await probeOpenAiCapacity({
526
+ checkedAt,
527
+ env: { OPENAI_API_KEY: "revoked-key" },
528
+ fetch,
529
+ });
530
+ expect(report).toMatchObject({ authenticated: false, windows: [] });
531
+ });
532
+
533
+ it("sanitizes network failures on the platform key path", async () => {
534
+ await expect(
535
+ probeOpenAiCapacity({
536
+ checkedAt,
537
+ env: { OPENAI_API_KEY: "network-key" },
538
+ fetch: vi.fn(async () => Promise.reject(new Error("ECONNRESET secret"))),
539
+ }),
540
+ ).rejects.toThrow("OpenAI usage request failed");
541
+ await expect(
542
+ probeOpenAiCapacity({
543
+ checkedAt,
544
+ env: { OPENAI_API_KEY: "network-key" },
545
+ fetch: vi.fn(async (url: string) =>
546
+ url.endsWith("/models")
547
+ ? Promise.reject(new Error("ECONNRESET secret"))
548
+ : new Response("forbidden", { status: 403 }),
549
+ ),
550
+ }),
551
+ ).rejects.toThrow("OpenAI usage request failed");
552
+ });
553
+
554
+ it("rejects other statuses and malformed JSON responses with sanitized errors", async () => {
555
+ await expect(
556
+ probeOpenAiCapacity({
557
+ checkedAt,
558
+ env: { OPENAI_API_KEY: "never-expose-this-key" },
559
+ fetch: vi.fn(async () => new Response("private body", { status: 503 })),
560
+ }),
561
+ ).rejects.toThrow("OpenAI usage request failed: HTTP 503");
562
+ await expect(
563
+ probeOpenAiCapacity({
564
+ checkedAt,
565
+ env: { OPENAI_API_KEY: "never-expose-this-key" },
566
+ fetch: vi.fn(async () => new Response("not-json", { status: 200 })),
567
+ }),
568
+ ).rejects.toThrow("OpenAI usage response is not valid JSON");
569
+ });
570
+ });
@@ -0,0 +1,104 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { parseWhamUsage, toRateWindow } from "../../capacity/wham.js";
5
+
6
+ const fixture = (name: string) =>
7
+ readFile(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), "utf8");
8
+
9
+ describe("wham usage parsing", () => {
10
+ it("maps primary and secondary rate windows onto session and weekly", async () => {
11
+ const snapshot = parseWhamUsage(JSON.parse(await fixture("wham-usage.json")));
12
+ expect(snapshot.windows).toEqual([
13
+ {
14
+ id: "session",
15
+ label: "Session",
16
+ durationMinutes: 300,
17
+ usedPercent: 12.5,
18
+ resetsAt: "2026-09-27T13:52:07.000Z",
19
+ },
20
+ {
21
+ id: "weekly",
22
+ label: "Weekly",
23
+ durationMinutes: 10080,
24
+ usedPercent: 43.2,
25
+ resetsAt: "2026-10-04T08:52:07.000Z",
26
+ },
27
+ ]);
28
+ expect(snapshot.creditsRemaining).toBe(4.5);
29
+ });
30
+
31
+ it("accepts epoch seconds and ISO strings for reset_at", () => {
32
+ expect(toRateWindow({ used_percent: 1, reset_at: 1_787_220_000 }, "a", "A")?.resetsAt).toBe(
33
+ "2026-08-20T10:00:00.000Z",
34
+ );
35
+ expect(
36
+ toRateWindow({ used_percent: 1, reset_at: "2026-08-20T10:00:00Z" }, "a", "A")?.resetsAt,
37
+ ).toBe("2026-08-20T10:00:00.000Z");
38
+ expect(toRateWindow({ used_percent: 1, reset_at: "nope" }, "a", "A")?.resetsAt).toBeNull();
39
+ });
40
+
41
+ it("maps additional_rate_limits into scoped windows and tolerates non-array values", () => {
42
+ const scoped = parseWhamUsage({
43
+ rate_limit: {},
44
+ additional_rate_limits: [
45
+ {
46
+ limit_name: "code-review",
47
+ rate_limit: {
48
+ primary_window: { used_percent: 10, limit_window_seconds: 3600, reset_at: 100 },
49
+ secondary_window: { used_percent: 20, limit_window_seconds: 7200, reset_at: 200 },
50
+ },
51
+ },
52
+ ],
53
+ });
54
+ expect(scoped.windows.map((window) => window.id)).toEqual([
55
+ "code-review:primary",
56
+ "code-review:secondary",
57
+ ]);
58
+ expect(parseWhamUsage({ additional_rate_limits: null }).windows).toEqual([]);
59
+ });
60
+
61
+ it("falls back to positional ids and inline windows for degraded extra limits", () => {
62
+ const scoped = parseWhamUsage({
63
+ additional_rate_limits: [
64
+ null,
65
+ 42,
66
+ { primary_window: { used_percent: 5 } },
67
+ { limit_name: "not/safe", primary_window: { used_percent: 6 } },
68
+ { limit_name: "token-123456", primary_window: { used_percent: 7 } },
69
+ { limit_name: "ok", secondary_window: { used_percent: 8 } },
70
+ ],
71
+ });
72
+ expect(scoped.windows.map((window) => window.id)).toEqual([
73
+ "extra-3:primary",
74
+ "extra-4:primary",
75
+ "extra-5:primary",
76
+ "ok:secondary",
77
+ ]);
78
+ });
79
+
80
+ it("parses non-object payloads into an empty snapshot", () => {
81
+ expect(parseWhamUsage(null)).toEqual({ windows: [], creditsRemaining: null });
82
+ expect(parseWhamUsage(42)).toEqual({ windows: [], creditsRemaining: null });
83
+ });
84
+
85
+ it("never invents usage for windows it cannot parse", () => {
86
+ const snapshot = parseWhamUsage({
87
+ rate_limit: {
88
+ primary_window: { limit_window_seconds: 18000 },
89
+ secondary_window: null,
90
+ },
91
+ });
92
+ expect(snapshot.windows).toEqual([
93
+ {
94
+ id: "session",
95
+ label: "Session",
96
+ durationMinutes: 300,
97
+ usedPercent: null,
98
+ resetsAt: null,
99
+ },
100
+ ]);
101
+ expect(parseWhamUsage({}).creditsRemaining).toBeNull();
102
+ expect(parseWhamUsage({ credits: { balance: "nan" } }).creditsRemaining).toBeNull();
103
+ });
104
+ });
@@ -552,6 +552,18 @@ describe("CopilotAdapter", () => {
552
552
  });
553
553
  });
554
554
 
555
+ it("finds an exact session ID from its session directory", async () => {
556
+ writeSession("wanted", { events: [sessionStart("wanted", "/repo")] });
557
+ writeSession("other", { events: [sessionStart("other", "/other")] });
558
+
559
+ await expect(adapter.findSessionsById("wanted")).resolves.toEqual([
560
+ expect.objectContaining({
561
+ sessionId: "wanted",
562
+ sessionFilePath: path.join(sessionStateDir, "wanted", "events.jsonl"),
563
+ }),
564
+ ]);
565
+ });
566
+
555
567
  it("applies strict cwd filter", async () => {
556
568
  writeSession("keep", { events: [sessionStart("keep", "/repo")] });
557
569
  writeSession("drop", { events: [sessionStart("drop", "/other")] });