@katyella/legio 0.1.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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * Tests for MetricsStore (SQLite-backed session metrics storage).
3
+ *
4
+ * Uses real better-sqlite3 with temp files. No mocks.
5
+ * Philosophy: "never mock what you can use for real" (mx-252b16).
6
+ */
7
+
8
+ import { mkdtemp } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
13
+ import type { SessionMetrics } from "../types.ts";
14
+ import { createMetricsStore, type MetricsStore } from "./store.ts";
15
+
16
+ let tempDir: string;
17
+ let dbPath: string;
18
+ let store: MetricsStore;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "legio-metrics-test-"));
22
+ dbPath = join(tempDir, "metrics.db");
23
+ store = createMetricsStore(dbPath);
24
+ });
25
+
26
+ afterEach(async () => {
27
+ store.close();
28
+ await cleanupTempDir(tempDir);
29
+ });
30
+
31
+ /** Helper to create a SessionMetrics object with optional overrides. */
32
+ function makeSession(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
33
+ return {
34
+ agentName: "test-agent",
35
+ beadId: "test-task-123",
36
+ capability: "builder",
37
+ startedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
38
+ completedAt: new Date("2026-01-01T00:05:00Z").toISOString(),
39
+ durationMs: 300_000,
40
+ exitCode: 0,
41
+ mergeResult: "auto-resolve",
42
+ parentAgent: "coordinator",
43
+ runId: null,
44
+ inputTokens: 0,
45
+ outputTokens: 0,
46
+ cacheReadTokens: 0,
47
+ cacheCreationTokens: 0,
48
+ estimatedCostUsd: null,
49
+ modelUsed: null,
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // === recordSession ===
55
+
56
+ describe("recordSession", () => {
57
+ test("inserts a session and retrieves it via getRecentSessions", () => {
58
+ const session = makeSession();
59
+ store.recordSession(session);
60
+
61
+ const retrieved = store.getRecentSessions(10);
62
+ expect(retrieved).toHaveLength(1);
63
+ expect(retrieved[0]).toEqual(session);
64
+ });
65
+
66
+ test("INSERT OR REPLACE: same (agent_name, bead_id) key overwrites previous row", () => {
67
+ const session1 = makeSession({ durationMs: 100_000 });
68
+ const session2 = makeSession({ durationMs: 200_000 });
69
+
70
+ store.recordSession(session1);
71
+ store.recordSession(session2);
72
+
73
+ const retrieved = store.getRecentSessions(10);
74
+ expect(retrieved).toHaveLength(1);
75
+ expect(retrieved[0]?.durationMs).toBe(200_000);
76
+ });
77
+
78
+ test("all fields roundtrip correctly (camelCase TS → snake_case SQLite → camelCase TS)", () => {
79
+ const session = makeSession({
80
+ agentName: "special-agent",
81
+ beadId: "task-xyz",
82
+ capability: "reviewer",
83
+ startedAt: "2026-02-01T12:00:00Z",
84
+ completedAt: "2026-02-01T12:30:00Z",
85
+ durationMs: 1_800_000,
86
+ exitCode: 42,
87
+ mergeResult: "ai-resolve",
88
+ parentAgent: "lead-agent",
89
+ });
90
+
91
+ store.recordSession(session);
92
+ const retrieved = store.getRecentSessions(10);
93
+
94
+ expect(retrieved).toHaveLength(1);
95
+ expect(retrieved[0]).toEqual(session);
96
+ });
97
+
98
+ test("null fields (completedAt, exitCode, mergeResult, parentAgent) stored and retrieved as null", () => {
99
+ const session = makeSession({
100
+ completedAt: null,
101
+ exitCode: null,
102
+ mergeResult: null,
103
+ parentAgent: null,
104
+ });
105
+
106
+ store.recordSession(session);
107
+ const retrieved = store.getRecentSessions(10);
108
+
109
+ expect(retrieved).toHaveLength(1);
110
+ expect(retrieved[0]?.completedAt).toBeNull();
111
+ expect(retrieved[0]?.exitCode).toBeNull();
112
+ expect(retrieved[0]?.mergeResult).toBeNull();
113
+ expect(retrieved[0]?.parentAgent).toBeNull();
114
+ });
115
+ });
116
+
117
+ // === getRecentSessions ===
118
+
119
+ describe("getRecentSessions", () => {
120
+ test("returns sessions ordered by started_at DESC (most recent first)", () => {
121
+ const session1 = makeSession({
122
+ beadId: "task-1",
123
+ startedAt: "2026-01-01T10:00:00Z",
124
+ });
125
+ const session2 = makeSession({
126
+ beadId: "task-2",
127
+ startedAt: "2026-01-01T12:00:00Z",
128
+ });
129
+ const session3 = makeSession({
130
+ beadId: "task-3",
131
+ startedAt: "2026-01-01T11:00:00Z",
132
+ });
133
+
134
+ store.recordSession(session1);
135
+ store.recordSession(session2);
136
+ store.recordSession(session3);
137
+
138
+ const retrieved = store.getRecentSessions(10);
139
+ expect(retrieved).toHaveLength(3);
140
+ expect(retrieved[0]?.beadId).toBe("task-2"); // most recent
141
+ expect(retrieved[1]?.beadId).toBe("task-3");
142
+ expect(retrieved[2]?.beadId).toBe("task-1"); // oldest
143
+ });
144
+
145
+ test("default limit is 20", () => {
146
+ // Insert 25 sessions
147
+ for (let i = 0; i < 25; i++) {
148
+ store.recordSession(
149
+ makeSession({
150
+ beadId: `task-${i}`,
151
+ startedAt: new Date(Date.now() + i * 1000).toISOString(),
152
+ }),
153
+ );
154
+ }
155
+
156
+ const retrieved = store.getRecentSessions();
157
+ expect(retrieved).toHaveLength(20);
158
+ });
159
+
160
+ test("custom limit works (e.g., limit=2 returns only 2)", () => {
161
+ store.recordSession(makeSession({ beadId: "task-1" }));
162
+ store.recordSession(makeSession({ beadId: "task-2" }));
163
+ store.recordSession(makeSession({ beadId: "task-3" }));
164
+
165
+ const retrieved = store.getRecentSessions(2);
166
+ expect(retrieved).toHaveLength(2);
167
+ });
168
+
169
+ test("empty DB returns empty array", () => {
170
+ const retrieved = store.getRecentSessions(10);
171
+ expect(retrieved).toEqual([]);
172
+ });
173
+ });
174
+
175
+ // === getSessionsByAgent ===
176
+
177
+ describe("getSessionsByAgent", () => {
178
+ test("filters by agent name correctly", () => {
179
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1" }));
180
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2" }));
181
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-3" }));
182
+
183
+ const retrieved = store.getSessionsByAgent("agent-a");
184
+ expect(retrieved).toHaveLength(2);
185
+ expect(retrieved[0]?.agentName).toBe("agent-a");
186
+ expect(retrieved[1]?.agentName).toBe("agent-a");
187
+ });
188
+
189
+ test("returns empty array for unknown agent", () => {
190
+ store.recordSession(makeSession({ agentName: "known-agent" }));
191
+
192
+ const retrieved = store.getSessionsByAgent("unknown-agent");
193
+ expect(retrieved).toEqual([]);
194
+ });
195
+
196
+ test("multiple sessions for same agent all returned, ordered by started_at DESC", () => {
197
+ store.recordSession(
198
+ makeSession({
199
+ agentName: "agent-x",
200
+ beadId: "task-1",
201
+ startedAt: "2026-01-01T10:00:00Z",
202
+ }),
203
+ );
204
+ store.recordSession(
205
+ makeSession({
206
+ agentName: "agent-x",
207
+ beadId: "task-2",
208
+ startedAt: "2026-01-01T12:00:00Z",
209
+ }),
210
+ );
211
+ store.recordSession(
212
+ makeSession({
213
+ agentName: "agent-x",
214
+ beadId: "task-3",
215
+ startedAt: "2026-01-01T11:00:00Z",
216
+ }),
217
+ );
218
+
219
+ const retrieved = store.getSessionsByAgent("agent-x");
220
+ expect(retrieved).toHaveLength(3);
221
+ expect(retrieved[0]?.beadId).toBe("task-2"); // most recent
222
+ expect(retrieved[1]?.beadId).toBe("task-3");
223
+ expect(retrieved[2]?.beadId).toBe("task-1"); // oldest
224
+ });
225
+ });
226
+
227
+ // === getSessionsByRun ===
228
+
229
+ describe("getSessionsByRun", () => {
230
+ test("returns sessions matching the given run_id", () => {
231
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1", runId: "run-001" }));
232
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2", runId: "run-001" }));
233
+ store.recordSession(makeSession({ agentName: "agent-c", beadId: "task-3", runId: "run-002" }));
234
+
235
+ const results = store.getSessionsByRun("run-001");
236
+ expect(results).toHaveLength(2);
237
+ const beadIds = results.map((s) => s.beadId);
238
+ expect(beadIds).toContain("task-1");
239
+ expect(beadIds).toContain("task-2");
240
+ });
241
+
242
+ test("returns empty array when no sessions match the run_id", () => {
243
+ store.recordSession(makeSession({ beadId: "task-1", runId: "run-001" }));
244
+
245
+ const results = store.getSessionsByRun("run-999");
246
+ expect(results).toEqual([]);
247
+ });
248
+
249
+ test("returns empty array for sessions with null run_id", () => {
250
+ store.recordSession(makeSession({ beadId: "task-1", runId: null }));
251
+
252
+ const results = store.getSessionsByRun("run-001");
253
+ expect(results).toEqual([]);
254
+ });
255
+
256
+ test("run_id roundtrips correctly through record/retrieve cycle", () => {
257
+ store.recordSession(makeSession({ beadId: "task-1", runId: "run-abc-123" }));
258
+
259
+ const results = store.getSessionsByRun("run-abc-123");
260
+ expect(results).toHaveLength(1);
261
+ expect(results[0]?.runId).toBe("run-abc-123");
262
+ });
263
+
264
+ test("null run_id roundtrips correctly", () => {
265
+ store.recordSession(makeSession({ beadId: "task-1", runId: null }));
266
+
267
+ const all = store.getRecentSessions(10);
268
+ expect(all).toHaveLength(1);
269
+ expect(all[0]?.runId).toBeNull();
270
+ });
271
+
272
+ test("results are ordered by started_at DESC", () => {
273
+ store.recordSession(
274
+ makeSession({
275
+ agentName: "agent-a",
276
+ beadId: "task-1",
277
+ runId: "run-001",
278
+ startedAt: "2026-01-01T10:00:00Z",
279
+ }),
280
+ );
281
+ store.recordSession(
282
+ makeSession({
283
+ agentName: "agent-b",
284
+ beadId: "task-2",
285
+ runId: "run-001",
286
+ startedAt: "2026-01-01T12:00:00Z",
287
+ }),
288
+ );
289
+ store.recordSession(
290
+ makeSession({
291
+ agentName: "agent-c",
292
+ beadId: "task-3",
293
+ runId: "run-001",
294
+ startedAt: "2026-01-01T11:00:00Z",
295
+ }),
296
+ );
297
+
298
+ const results = store.getSessionsByRun("run-001");
299
+ expect(results).toHaveLength(3);
300
+ expect(results[0]?.beadId).toBe("task-2"); // most recent
301
+ expect(results[1]?.beadId).toBe("task-3");
302
+ expect(results[2]?.beadId).toBe("task-1"); // oldest
303
+ });
304
+
305
+ test("migration: existing DB without run_id column can be opened and getSessionsByRun returns empty", () => {
306
+ store.close();
307
+
308
+ // Create a DB with old schema (no run_id column)
309
+ const Database = require("better-sqlite3");
310
+ const oldDb = new Database(dbPath);
311
+ oldDb.exec("DROP TABLE IF EXISTS sessions");
312
+ oldDb.exec(`
313
+ CREATE TABLE sessions (
314
+ agent_name TEXT NOT NULL,
315
+ bead_id TEXT NOT NULL,
316
+ capability TEXT NOT NULL,
317
+ started_at TEXT NOT NULL,
318
+ completed_at TEXT,
319
+ duration_ms INTEGER NOT NULL DEFAULT 0,
320
+ exit_code INTEGER,
321
+ merge_result TEXT,
322
+ parent_agent TEXT,
323
+ input_tokens INTEGER NOT NULL DEFAULT 0,
324
+ output_tokens INTEGER NOT NULL DEFAULT 0,
325
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
326
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
327
+ estimated_cost_usd REAL,
328
+ model_used TEXT,
329
+ PRIMARY KEY (agent_name, bead_id)
330
+ )
331
+ `);
332
+ oldDb.exec(`
333
+ INSERT INTO sessions (agent_name, bead_id, capability, started_at, duration_ms)
334
+ VALUES ('old-agent', 'old-task', 'builder', '2026-01-01T00:00:00Z', 100000)
335
+ `);
336
+ oldDb.close();
337
+
338
+ // Re-open with createMetricsStore — should migrate and add run_id column
339
+ store = createMetricsStore(dbPath);
340
+
341
+ // Old row should have null run_id after migration
342
+ const all = store.getRecentSessions(10);
343
+ expect(all).toHaveLength(1);
344
+ expect(all[0]?.runId).toBeNull();
345
+
346
+ // getSessionsByRun should return empty (old row has null run_id)
347
+ expect(store.getSessionsByRun("any-run")).toEqual([]);
348
+
349
+ // New rows with run_id should work
350
+ store.recordSession(
351
+ makeSession({ agentName: "new-agent", beadId: "new-task", runId: "run-new-001" }),
352
+ );
353
+ const runResults = store.getSessionsByRun("run-new-001");
354
+ expect(runResults).toHaveLength(1);
355
+ expect(runResults[0]?.agentName).toBe("new-agent");
356
+ });
357
+ });
358
+
359
+ // === getAverageDuration ===
360
+
361
+ describe("getAverageDuration", () => {
362
+ test("average across all completed sessions (completedAt IS NOT NULL)", () => {
363
+ store.recordSession(makeSession({ beadId: "task-1", durationMs: 100_000 }));
364
+ store.recordSession(makeSession({ beadId: "task-2", durationMs: 200_000 }));
365
+ store.recordSession(makeSession({ beadId: "task-3", durationMs: 300_000 }));
366
+
367
+ const avg = store.getAverageDuration();
368
+ expect(avg).toBe(200_000);
369
+ });
370
+
371
+ test("average filtered by capability", () => {
372
+ store.recordSession(
373
+ makeSession({ beadId: "task-1", capability: "builder", durationMs: 100_000 }),
374
+ );
375
+ store.recordSession(makeSession({ beadId: "task-2", capability: "scout", durationMs: 50_000 }));
376
+ store.recordSession(
377
+ makeSession({ beadId: "task-3", capability: "builder", durationMs: 200_000 }),
378
+ );
379
+
380
+ const avgBuilder = store.getAverageDuration("builder");
381
+ const avgScout = store.getAverageDuration("scout");
382
+
383
+ expect(avgBuilder).toBe(150_000);
384
+ expect(avgScout).toBe(50_000);
385
+ });
386
+
387
+ test("returns 0 when no completed sessions exist", () => {
388
+ const avg = store.getAverageDuration();
389
+ expect(avg).toBe(0);
390
+ });
391
+
392
+ test("sessions with completedAt=null are excluded from average", () => {
393
+ store.recordSession(makeSession({ beadId: "task-1", durationMs: 100_000, completedAt: null }));
394
+ store.recordSession(makeSession({ beadId: "task-2", durationMs: 200_000 }));
395
+ store.recordSession(makeSession({ beadId: "task-3", durationMs: 300_000 }));
396
+
397
+ const avg = store.getAverageDuration();
398
+ expect(avg).toBe(250_000); // (200_000 + 300_000) / 2
399
+ });
400
+
401
+ test("single session returns that session's duration", () => {
402
+ store.recordSession(makeSession({ durationMs: 123_456 }));
403
+
404
+ const avg = store.getAverageDuration();
405
+ expect(avg).toBe(123_456);
406
+ });
407
+ });
408
+
409
+ // === token fields ===
410
+
411
+ describe("token fields", () => {
412
+ test("token data roundtrips correctly", () => {
413
+ const session = makeSession({
414
+ inputTokens: 15_000,
415
+ outputTokens: 3_000,
416
+ cacheReadTokens: 100_000,
417
+ cacheCreationTokens: 10_000,
418
+ estimatedCostUsd: 1.23,
419
+ modelUsed: "claude-opus-4-6",
420
+ });
421
+
422
+ store.recordSession(session);
423
+ const retrieved = store.getRecentSessions(10);
424
+
425
+ expect(retrieved).toHaveLength(1);
426
+ expect(retrieved[0]?.inputTokens).toBe(15_000);
427
+ expect(retrieved[0]?.outputTokens).toBe(3_000);
428
+ expect(retrieved[0]?.cacheReadTokens).toBe(100_000);
429
+ expect(retrieved[0]?.cacheCreationTokens).toBe(10_000);
430
+ expect(retrieved[0]?.estimatedCostUsd).toBeCloseTo(1.23, 2);
431
+ expect(retrieved[0]?.modelUsed).toBe("claude-opus-4-6");
432
+ });
433
+
434
+ test("token fields default to 0 and cost/model default to null", () => {
435
+ const session = makeSession();
436
+
437
+ store.recordSession(session);
438
+ const retrieved = store.getRecentSessions(10);
439
+
440
+ expect(retrieved).toHaveLength(1);
441
+ expect(retrieved[0]?.inputTokens).toBe(0);
442
+ expect(retrieved[0]?.outputTokens).toBe(0);
443
+ expect(retrieved[0]?.cacheReadTokens).toBe(0);
444
+ expect(retrieved[0]?.cacheCreationTokens).toBe(0);
445
+ expect(retrieved[0]?.estimatedCostUsd).toBeNull();
446
+ expect(retrieved[0]?.modelUsed).toBeNull();
447
+ });
448
+
449
+ test("migration adds token columns to existing table without them", () => {
450
+ // Close the current store which has the new schema
451
+ store.close();
452
+
453
+ // Create a DB with the old schema (no token columns)
454
+ const Database = require("better-sqlite3");
455
+ const oldDb = new Database(dbPath);
456
+ oldDb.exec("DROP TABLE IF EXISTS sessions");
457
+ oldDb.exec(`
458
+ CREATE TABLE sessions (
459
+ agent_name TEXT NOT NULL,
460
+ bead_id TEXT NOT NULL,
461
+ capability TEXT NOT NULL,
462
+ started_at TEXT NOT NULL,
463
+ completed_at TEXT,
464
+ duration_ms INTEGER NOT NULL DEFAULT 0,
465
+ exit_code INTEGER,
466
+ merge_result TEXT,
467
+ parent_agent TEXT,
468
+ PRIMARY KEY (agent_name, bead_id)
469
+ )
470
+ `);
471
+ // Insert a row with old schema
472
+ oldDb.exec(`
473
+ INSERT INTO sessions (agent_name, bead_id, capability, started_at, duration_ms)
474
+ VALUES ('old-agent', 'old-task', 'builder', '2026-01-01T00:00:00Z', 100000)
475
+ `);
476
+ oldDb.close();
477
+
478
+ // Re-open with createMetricsStore which should migrate
479
+ store = createMetricsStore(dbPath);
480
+
481
+ // The old row should still be readable with token defaults
482
+ const sessions = store.getRecentSessions(10);
483
+ expect(sessions).toHaveLength(1);
484
+ expect(sessions[0]?.agentName).toBe("old-agent");
485
+ expect(sessions[0]?.inputTokens).toBe(0);
486
+ expect(sessions[0]?.outputTokens).toBe(0);
487
+ expect(sessions[0]?.estimatedCostUsd).toBeNull();
488
+ expect(sessions[0]?.modelUsed).toBeNull();
489
+
490
+ // New rows with token data should work
491
+ store.recordSession(
492
+ makeSession({
493
+ agentName: "new-agent",
494
+ beadId: "new-task",
495
+ inputTokens: 5000,
496
+ outputTokens: 1000,
497
+ estimatedCostUsd: 0.42,
498
+ modelUsed: "claude-sonnet-4-20250514",
499
+ }),
500
+ );
501
+
502
+ const newSessions = store.getSessionsByAgent("new-agent");
503
+ expect(newSessions).toHaveLength(1);
504
+ expect(newSessions[0]?.inputTokens).toBe(5000);
505
+ expect(newSessions[0]?.estimatedCostUsd).toBeCloseTo(0.42, 2);
506
+ });
507
+ });
508
+
509
+ // === purge ===
510
+
511
+ describe("purge", () => {
512
+ test("purge all deletes everything and returns count", () => {
513
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1" }));
514
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2" }));
515
+ store.recordSession(makeSession({ agentName: "agent-c", beadId: "task-3" }));
516
+
517
+ const count = store.purge({ all: true });
518
+ expect(count).toBe(3);
519
+ expect(store.getRecentSessions(10)).toEqual([]);
520
+ });
521
+
522
+ test("purge by agent deletes only that agent's records", () => {
523
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1" }));
524
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2" }));
525
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-3" }));
526
+
527
+ const count = store.purge({ agent: "agent-a" });
528
+ expect(count).toBe(2);
529
+
530
+ const remaining = store.getRecentSessions(10);
531
+ expect(remaining).toHaveLength(1);
532
+ expect(remaining[0]?.agentName).toBe("agent-b");
533
+ });
534
+
535
+ test("purge on empty DB returns 0", () => {
536
+ const count = store.purge({ all: true });
537
+ expect(count).toBe(0);
538
+ });
539
+
540
+ test("purge with no options returns 0 without deleting", () => {
541
+ store.recordSession(makeSession({ beadId: "task-1" }));
542
+
543
+ const count = store.purge({});
544
+ expect(count).toBe(0);
545
+ expect(store.getRecentSessions(10)).toHaveLength(1);
546
+ });
547
+ });
548
+
549
+ // === token snapshots ===
550
+
551
+ describe("token snapshots", () => {
552
+ test("recordSnapshot inserts and can be retrieved via getLatestSnapshots", () => {
553
+ const snapshot = {
554
+ agentName: "test-agent",
555
+ inputTokens: 1000,
556
+ outputTokens: 500,
557
+ cacheReadTokens: 200,
558
+ cacheCreationTokens: 100,
559
+ estimatedCostUsd: 0.15,
560
+ modelUsed: "claude-sonnet-4-5",
561
+ createdAt: new Date().toISOString(),
562
+ };
563
+
564
+ store.recordSnapshot(snapshot);
565
+
566
+ const snapshots = store.getLatestSnapshots();
567
+ expect(snapshots).toHaveLength(1);
568
+ expect(snapshots[0]?.agentName).toBe("test-agent");
569
+ expect(snapshots[0]?.inputTokens).toBe(1000);
570
+ expect(snapshots[0]?.outputTokens).toBe(500);
571
+ expect(snapshots[0]?.estimatedCostUsd).toBeCloseTo(0.15, 2);
572
+ });
573
+
574
+ test("getLatestSnapshots returns one row per agent (the most recent)", () => {
575
+ const now = Date.now();
576
+ store.recordSnapshot({
577
+ agentName: "agent-a",
578
+ inputTokens: 100,
579
+ outputTokens: 50,
580
+ cacheReadTokens: 0,
581
+ cacheCreationTokens: 0,
582
+ estimatedCostUsd: 0.01,
583
+ modelUsed: "claude-sonnet-4-5",
584
+ createdAt: new Date(now - 60_000).toISOString(), // 1 min ago
585
+ });
586
+
587
+ store.recordSnapshot({
588
+ agentName: "agent-a",
589
+ inputTokens: 200,
590
+ outputTokens: 100,
591
+ cacheReadTokens: 0,
592
+ cacheCreationTokens: 0,
593
+ estimatedCostUsd: 0.02,
594
+ modelUsed: "claude-sonnet-4-5",
595
+ createdAt: new Date(now).toISOString(), // now (most recent)
596
+ });
597
+
598
+ store.recordSnapshot({
599
+ agentName: "agent-b",
600
+ inputTokens: 300,
601
+ outputTokens: 150,
602
+ cacheReadTokens: 0,
603
+ cacheCreationTokens: 0,
604
+ estimatedCostUsd: 0.03,
605
+ modelUsed: "claude-sonnet-4-5",
606
+ createdAt: new Date(now - 30_000).toISOString(), // 30s ago
607
+ });
608
+
609
+ const snapshots = store.getLatestSnapshots();
610
+ expect(snapshots).toHaveLength(2); // one per agent
611
+
612
+ const agentASnapshot = snapshots.find((s) => s.agentName === "agent-a");
613
+ const agentBSnapshot = snapshots.find((s) => s.agentName === "agent-b");
614
+
615
+ expect(agentASnapshot?.inputTokens).toBe(200); // most recent for agent-a
616
+ expect(agentBSnapshot?.inputTokens).toBe(300);
617
+ });
618
+
619
+ test("getLatestSnapshotTime returns the most recent timestamp for an agent", () => {
620
+ const now = Date.now();
621
+ const time1 = new Date(now - 60_000).toISOString();
622
+ const time2 = new Date(now).toISOString();
623
+
624
+ store.recordSnapshot({
625
+ agentName: "test-agent",
626
+ inputTokens: 100,
627
+ outputTokens: 50,
628
+ cacheReadTokens: 0,
629
+ cacheCreationTokens: 0,
630
+ estimatedCostUsd: null,
631
+ modelUsed: null,
632
+ createdAt: time1,
633
+ });
634
+
635
+ store.recordSnapshot({
636
+ agentName: "test-agent",
637
+ inputTokens: 200,
638
+ outputTokens: 100,
639
+ cacheReadTokens: 0,
640
+ cacheCreationTokens: 0,
641
+ estimatedCostUsd: null,
642
+ modelUsed: null,
643
+ createdAt: time2,
644
+ });
645
+
646
+ const latestTime = store.getLatestSnapshotTime("test-agent");
647
+ expect(latestTime).toBe(time2);
648
+ });
649
+
650
+ test("getLatestSnapshotTime returns null for unknown agent", () => {
651
+ const latestTime = store.getLatestSnapshotTime("unknown-agent");
652
+ expect(latestTime).toBeNull();
653
+ });
654
+
655
+ test("purgeSnapshots with all=true deletes everything", () => {
656
+ store.recordSnapshot({
657
+ agentName: "agent-a",
658
+ inputTokens: 100,
659
+ outputTokens: 50,
660
+ cacheReadTokens: 0,
661
+ cacheCreationTokens: 0,
662
+ estimatedCostUsd: null,
663
+ modelUsed: null,
664
+ createdAt: new Date().toISOString(),
665
+ });
666
+
667
+ store.recordSnapshot({
668
+ agentName: "agent-b",
669
+ inputTokens: 200,
670
+ outputTokens: 100,
671
+ cacheReadTokens: 0,
672
+ cacheCreationTokens: 0,
673
+ estimatedCostUsd: null,
674
+ modelUsed: null,
675
+ createdAt: new Date().toISOString(),
676
+ });
677
+
678
+ const count = store.purgeSnapshots({ all: true });
679
+ expect(count).toBe(2);
680
+ expect(store.getLatestSnapshots()).toEqual([]);
681
+ });
682
+
683
+ test("purgeSnapshots with agent filter deletes only that agent", () => {
684
+ store.recordSnapshot({
685
+ agentName: "agent-a",
686
+ inputTokens: 100,
687
+ outputTokens: 50,
688
+ cacheReadTokens: 0,
689
+ cacheCreationTokens: 0,
690
+ estimatedCostUsd: null,
691
+ modelUsed: null,
692
+ createdAt: new Date().toISOString(),
693
+ });
694
+
695
+ store.recordSnapshot({
696
+ agentName: "agent-b",
697
+ inputTokens: 200,
698
+ outputTokens: 100,
699
+ cacheReadTokens: 0,
700
+ cacheCreationTokens: 0,
701
+ estimatedCostUsd: null,
702
+ modelUsed: null,
703
+ createdAt: new Date().toISOString(),
704
+ });
705
+
706
+ const count = store.purgeSnapshots({ agent: "agent-a" });
707
+ expect(count).toBe(1);
708
+
709
+ const remaining = store.getLatestSnapshots();
710
+ expect(remaining).toHaveLength(1);
711
+ expect(remaining[0]?.agentName).toBe("agent-b");
712
+ });
713
+
714
+ test("purgeSnapshots with olderThanMs deletes old snapshots", () => {
715
+ const now = Date.now();
716
+ store.recordSnapshot({
717
+ agentName: "agent-a",
718
+ inputTokens: 100,
719
+ outputTokens: 50,
720
+ cacheReadTokens: 0,
721
+ cacheCreationTokens: 0,
722
+ estimatedCostUsd: null,
723
+ modelUsed: null,
724
+ createdAt: new Date(now - 120_000).toISOString(), // 2 min ago
725
+ });
726
+
727
+ store.recordSnapshot({
728
+ agentName: "agent-b",
729
+ inputTokens: 200,
730
+ outputTokens: 100,
731
+ cacheReadTokens: 0,
732
+ cacheCreationTokens: 0,
733
+ estimatedCostUsd: null,
734
+ modelUsed: null,
735
+ createdAt: new Date(now - 10_000).toISOString(), // 10s ago (recent)
736
+ });
737
+
738
+ const count = store.purgeSnapshots({ olderThanMs: 60_000 }); // delete older than 1 min
739
+ expect(count).toBe(1); // only the 2-min-old one
740
+
741
+ const remaining = store.getLatestSnapshots();
742
+ expect(remaining).toHaveLength(1);
743
+ expect(remaining[0]?.agentName).toBe("agent-b");
744
+ });
745
+
746
+ test("table creation is idempotent (re-opening store does not fail)", () => {
747
+ store.recordSnapshot({
748
+ agentName: "test-agent",
749
+ inputTokens: 100,
750
+ outputTokens: 50,
751
+ cacheReadTokens: 0,
752
+ cacheCreationTokens: 0,
753
+ estimatedCostUsd: null,
754
+ modelUsed: null,
755
+ createdAt: new Date().toISOString(),
756
+ });
757
+
758
+ store.close();
759
+
760
+ // Re-open and verify data persists
761
+ store = createMetricsStore(dbPath);
762
+ const snapshots = store.getLatestSnapshots();
763
+ expect(snapshots).toHaveLength(1);
764
+ expect(snapshots[0]?.agentName).toBe("test-agent");
765
+ });
766
+ });
767
+
768
+ // === getSessionsFiltered ===
769
+
770
+ describe("getSessionsFiltered", () => {
771
+ test("returns all sessions when no since/until provided", () => {
772
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z" }));
773
+ store.recordSession(makeSession({ beadId: "task-2", startedAt: "2026-01-02T10:00:00Z" }));
774
+
775
+ const results = store.getSessionsFiltered({});
776
+ expect(results).toHaveLength(2);
777
+ });
778
+
779
+ test("filters by since (inclusive)", () => {
780
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z" }));
781
+ store.recordSession(makeSession({ beadId: "task-2", startedAt: "2026-01-02T10:00:00Z" }));
782
+ store.recordSession(makeSession({ beadId: "task-3", startedAt: "2026-01-03T10:00:00Z" }));
783
+
784
+ const results = store.getSessionsFiltered({ since: "2026-01-02T10:00:00Z" });
785
+ expect(results).toHaveLength(2);
786
+ const beadIds = results.map((s) => s.beadId);
787
+ expect(beadIds).toContain("task-2");
788
+ expect(beadIds).toContain("task-3");
789
+ });
790
+
791
+ test("filters by until (inclusive)", () => {
792
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z" }));
793
+ store.recordSession(makeSession({ beadId: "task-2", startedAt: "2026-01-02T10:00:00Z" }));
794
+ store.recordSession(makeSession({ beadId: "task-3", startedAt: "2026-01-03T10:00:00Z" }));
795
+
796
+ const results = store.getSessionsFiltered({ until: "2026-01-02T10:00:00Z" });
797
+ expect(results).toHaveLength(2);
798
+ const beadIds = results.map((s) => s.beadId);
799
+ expect(beadIds).toContain("task-1");
800
+ expect(beadIds).toContain("task-2");
801
+ });
802
+
803
+ test("filters by both since and until", () => {
804
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z" }));
805
+ store.recordSession(makeSession({ beadId: "task-2", startedAt: "2026-01-02T10:00:00Z" }));
806
+ store.recordSession(makeSession({ beadId: "task-3", startedAt: "2026-01-03T10:00:00Z" }));
807
+ store.recordSession(makeSession({ beadId: "task-4", startedAt: "2026-01-04T10:00:00Z" }));
808
+
809
+ const results = store.getSessionsFiltered({
810
+ since: "2026-01-02T10:00:00Z",
811
+ until: "2026-01-03T10:00:00Z",
812
+ });
813
+ expect(results).toHaveLength(2);
814
+ const beadIds = results.map((s) => s.beadId);
815
+ expect(beadIds).toContain("task-2");
816
+ expect(beadIds).toContain("task-3");
817
+ });
818
+
819
+ test("respects limit", () => {
820
+ for (let i = 0; i < 10; i++) {
821
+ store.recordSession(
822
+ makeSession({
823
+ beadId: `task-${i}`,
824
+ startedAt: new Date(Date.now() + i * 1000).toISOString(),
825
+ }),
826
+ );
827
+ }
828
+
829
+ const results = store.getSessionsFiltered({ limit: 3 });
830
+ expect(results).toHaveLength(3);
831
+ });
832
+
833
+ test("returns empty array when nothing matches time range", () => {
834
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z" }));
835
+
836
+ const results = store.getSessionsFiltered({ since: "2026-06-01T00:00:00Z" });
837
+ expect(results).toEqual([]);
838
+ });
839
+
840
+ test("session started before window but completed within it is returned", () => {
841
+ store.recordSession(
842
+ makeSession({
843
+ beadId: "task-early-start",
844
+ startedAt: "2026-01-01T10:00:00Z",
845
+ completedAt: "2026-01-03T10:00:00Z",
846
+ }),
847
+ );
848
+
849
+ const results = store.getSessionsFiltered({ since: "2026-01-02T00:00:00Z" });
850
+ expect(results).toHaveLength(1);
851
+ expect(results[0]?.beadId).toBe("task-early-start");
852
+ });
853
+
854
+ test("still-running session started before window is excluded by since filter", () => {
855
+ store.recordSession(
856
+ makeSession({
857
+ beadId: "task-running",
858
+ startedAt: "2026-01-01T10:00:00Z",
859
+ completedAt: null,
860
+ }),
861
+ );
862
+
863
+ const results = store.getSessionsFiltered({ since: "2026-01-02T00:00:00Z" });
864
+ expect(results).toHaveLength(0);
865
+ });
866
+
867
+ test("empty DB returns empty array", () => {
868
+ const results = store.getSessionsFiltered({});
869
+ expect(results).toEqual([]);
870
+ });
871
+ });
872
+
873
+ // === getSessionsByModel ===
874
+
875
+ describe("getSessionsByModel", () => {
876
+ test("returns empty array when no sessions", () => {
877
+ const results = store.getSessionsByModel();
878
+ expect(results).toEqual([]);
879
+ });
880
+
881
+ test("groups sessions by model and sums tokens", () => {
882
+ store.recordSession(
883
+ makeSession({
884
+ beadId: "task-1",
885
+ modelUsed: "claude-opus-4-6",
886
+ inputTokens: 1000,
887
+ outputTokens: 500,
888
+ cacheReadTokens: 100,
889
+ cacheCreationTokens: 50,
890
+ estimatedCostUsd: 1.0,
891
+ }),
892
+ );
893
+ store.recordSession(
894
+ makeSession({
895
+ beadId: "task-2",
896
+ modelUsed: "claude-opus-4-6",
897
+ inputTokens: 2000,
898
+ outputTokens: 1000,
899
+ cacheReadTokens: 200,
900
+ cacheCreationTokens: 100,
901
+ estimatedCostUsd: 2.0,
902
+ }),
903
+ );
904
+ store.recordSession(
905
+ makeSession({
906
+ beadId: "task-3",
907
+ modelUsed: "claude-sonnet-4-6",
908
+ inputTokens: 500,
909
+ outputTokens: 250,
910
+ cacheReadTokens: 50,
911
+ cacheCreationTokens: 25,
912
+ estimatedCostUsd: 0.5,
913
+ }),
914
+ );
915
+
916
+ const results = store.getSessionsByModel();
917
+ expect(results).toHaveLength(2);
918
+
919
+ const opus = results.find((r) => r.model === "claude-opus-4-6");
920
+ const sonnet = results.find((r) => r.model === "claude-sonnet-4-6");
921
+
922
+ expect(opus?.sessions).toBe(2);
923
+ expect(opus?.inputTokens).toBe(3000);
924
+ expect(opus?.outputTokens).toBe(1500);
925
+ expect(opus?.cacheReadTokens).toBe(300);
926
+ expect(opus?.cacheCreationTokens).toBe(150);
927
+ expect(opus?.estimatedCostUsd).toBeCloseTo(3.0, 5);
928
+
929
+ expect(sonnet?.sessions).toBe(1);
930
+ expect(sonnet?.inputTokens).toBe(500);
931
+ expect(sonnet?.estimatedCostUsd).toBeCloseTo(0.5, 5);
932
+ });
933
+
934
+ test("sessions with null model_used are grouped as 'unknown'", () => {
935
+ store.recordSession(makeSession({ beadId: "task-1", modelUsed: null, estimatedCostUsd: null }));
936
+ store.recordSession(makeSession({ beadId: "task-2", modelUsed: null, estimatedCostUsd: null }));
937
+
938
+ const results = store.getSessionsByModel();
939
+ expect(results).toHaveLength(1);
940
+ expect(results[0]?.model).toBe("unknown");
941
+ expect(results[0]?.sessions).toBe(2);
942
+ expect(results[0]?.estimatedCostUsd).toBe(0);
943
+ });
944
+
945
+ test("filters by since/until", () => {
946
+ store.recordSession(
947
+ makeSession({
948
+ beadId: "task-1",
949
+ startedAt: "2026-01-01T10:00:00Z",
950
+ modelUsed: "claude-opus-4-6",
951
+ estimatedCostUsd: 1.0,
952
+ }),
953
+ );
954
+ store.recordSession(
955
+ makeSession({
956
+ beadId: "task-2",
957
+ startedAt: "2026-01-03T10:00:00Z",
958
+ modelUsed: "claude-opus-4-6",
959
+ estimatedCostUsd: 2.0,
960
+ }),
961
+ );
962
+
963
+ const results = store.getSessionsByModel({ since: "2026-01-02T00:00:00Z" });
964
+ expect(results).toHaveLength(1);
965
+ expect(results[0]?.sessions).toBe(1);
966
+ expect(results[0]?.estimatedCostUsd).toBeCloseTo(2.0, 5);
967
+ });
968
+
969
+ test("session started before window but completed within it is included in model group", () => {
970
+ store.recordSession(
971
+ makeSession({
972
+ beadId: "task-early-start",
973
+ startedAt: "2026-01-01T10:00:00Z",
974
+ completedAt: "2026-01-03T10:00:00Z",
975
+ modelUsed: "claude-opus-4-6",
976
+ estimatedCostUsd: 1.5,
977
+ }),
978
+ );
979
+
980
+ const results = store.getSessionsByModel({ since: "2026-01-02T00:00:00Z" });
981
+ expect(results).toHaveLength(1);
982
+ expect(results[0]?.model).toBe("claude-opus-4-6");
983
+ expect(results[0]?.sessions).toBe(1);
984
+ });
985
+
986
+ test("still-running session started before window is excluded from model group", () => {
987
+ store.recordSession(
988
+ makeSession({
989
+ beadId: "task-running",
990
+ startedAt: "2026-01-01T10:00:00Z",
991
+ completedAt: null,
992
+ modelUsed: "claude-sonnet-4-6",
993
+ estimatedCostUsd: 0.5,
994
+ }),
995
+ );
996
+
997
+ const results = store.getSessionsByModel({ since: "2026-01-02T00:00:00Z" });
998
+ expect(results).toHaveLength(0);
999
+ });
1000
+
1001
+ test("ordered by estimated_cost_usd DESC", () => {
1002
+ store.recordSession(
1003
+ makeSession({
1004
+ beadId: "task-1",
1005
+ modelUsed: "model-cheap",
1006
+ estimatedCostUsd: 0.1,
1007
+ }),
1008
+ );
1009
+ store.recordSession(
1010
+ makeSession({
1011
+ beadId: "task-2",
1012
+ modelUsed: "model-expensive",
1013
+ estimatedCostUsd: 10.0,
1014
+ }),
1015
+ );
1016
+ store.recordSession(
1017
+ makeSession({
1018
+ beadId: "task-3",
1019
+ modelUsed: "model-mid",
1020
+ estimatedCostUsd: 1.0,
1021
+ }),
1022
+ );
1023
+
1024
+ const results = store.getSessionsByModel();
1025
+ expect(results[0]?.model).toBe("model-expensive");
1026
+ expect(results[1]?.model).toBe("model-mid");
1027
+ expect(results[2]?.model).toBe("model-cheap");
1028
+ });
1029
+ });
1030
+
1031
+ // === getSessionsByDate ===
1032
+
1033
+ describe("getSessionsByDate", () => {
1034
+ test("returns empty array when no sessions", () => {
1035
+ const results = store.getSessionsByDate();
1036
+ expect(results).toEqual([]);
1037
+ });
1038
+
1039
+ test("groups sessions by date and sums tokens", () => {
1040
+ store.recordSession(
1041
+ makeSession({
1042
+ beadId: "task-1",
1043
+ startedAt: "2026-01-01T10:00:00Z",
1044
+ inputTokens: 100,
1045
+ outputTokens: 50,
1046
+ cacheReadTokens: 10,
1047
+ cacheCreationTokens: 5,
1048
+ estimatedCostUsd: 0.1,
1049
+ }),
1050
+ );
1051
+ store.recordSession(
1052
+ makeSession({
1053
+ beadId: "task-2",
1054
+ startedAt: "2026-01-01T14:00:00Z",
1055
+ inputTokens: 200,
1056
+ outputTokens: 100,
1057
+ cacheReadTokens: 20,
1058
+ cacheCreationTokens: 10,
1059
+ estimatedCostUsd: 0.2,
1060
+ }),
1061
+ );
1062
+ store.recordSession(
1063
+ makeSession({
1064
+ beadId: "task-3",
1065
+ startedAt: "2026-01-02T08:00:00Z",
1066
+ inputTokens: 300,
1067
+ outputTokens: 150,
1068
+ cacheReadTokens: 30,
1069
+ cacheCreationTokens: 15,
1070
+ estimatedCostUsd: 0.3,
1071
+ }),
1072
+ );
1073
+
1074
+ const results = store.getSessionsByDate();
1075
+ expect(results).toHaveLength(2);
1076
+
1077
+ const day1 = results.find((r) => r.date === "2026-01-01");
1078
+ const day2 = results.find((r) => r.date === "2026-01-02");
1079
+
1080
+ expect(day1?.sessions).toBe(2);
1081
+ expect(day1?.inputTokens).toBe(300);
1082
+ expect(day1?.outputTokens).toBe(150);
1083
+ expect(day1?.cacheReadTokens).toBe(30);
1084
+ expect(day1?.cacheCreationTokens).toBe(15);
1085
+ expect(day1?.estimatedCostUsd).toBeCloseTo(0.3, 5);
1086
+
1087
+ expect(day2?.sessions).toBe(1);
1088
+ expect(day2?.inputTokens).toBe(300);
1089
+ expect(day2?.estimatedCostUsd).toBeCloseTo(0.3, 5);
1090
+ });
1091
+
1092
+ test("ordered by date ASC", () => {
1093
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-03T10:00:00Z" }));
1094
+ store.recordSession(makeSession({ beadId: "task-2", startedAt: "2026-01-01T10:00:00Z" }));
1095
+ store.recordSession(makeSession({ beadId: "task-3", startedAt: "2026-01-02T10:00:00Z" }));
1096
+
1097
+ const results = store.getSessionsByDate();
1098
+ expect(results[0]?.date).toBe("2026-01-01");
1099
+ expect(results[1]?.date).toBe("2026-01-02");
1100
+ expect(results[2]?.date).toBe("2026-01-03");
1101
+ });
1102
+
1103
+ test("filters by since/until", () => {
1104
+ store.recordSession(makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z" }));
1105
+ store.recordSession(makeSession({ beadId: "task-2", startedAt: "2026-01-02T10:00:00Z" }));
1106
+ store.recordSession(makeSession({ beadId: "task-3", startedAt: "2026-01-03T10:00:00Z" }));
1107
+
1108
+ const results = store.getSessionsByDate({
1109
+ since: "2026-01-02T00:00:00Z",
1110
+ until: "2026-01-02T23:59:59Z",
1111
+ });
1112
+ expect(results).toHaveLength(1);
1113
+ expect(results[0]?.date).toBe("2026-01-02");
1114
+ });
1115
+
1116
+ test("session started before window but completed within it is included in date group", () => {
1117
+ store.recordSession(
1118
+ makeSession({
1119
+ beadId: "task-early-start",
1120
+ startedAt: "2026-01-01T10:00:00Z",
1121
+ completedAt: "2026-01-03T10:00:00Z",
1122
+ }),
1123
+ );
1124
+
1125
+ const results = store.getSessionsByDate({ since: "2026-01-02T00:00:00Z" });
1126
+ expect(results).toHaveLength(1);
1127
+ expect(results[0]?.date).toBe("2026-01-01");
1128
+ });
1129
+
1130
+ test("still-running session started before window is excluded from date group", () => {
1131
+ store.recordSession(
1132
+ makeSession({
1133
+ beadId: "task-running",
1134
+ startedAt: "2026-01-01T10:00:00Z",
1135
+ completedAt: null,
1136
+ }),
1137
+ );
1138
+
1139
+ const results = store.getSessionsByDate({ since: "2026-01-02T00:00:00Z" });
1140
+ expect(results).toHaveLength(0);
1141
+ });
1142
+
1143
+ test("null estimated_cost_usd treated as 0 in sum", () => {
1144
+ store.recordSession(
1145
+ makeSession({ beadId: "task-1", startedAt: "2026-01-01T10:00:00Z", estimatedCostUsd: null }),
1146
+ );
1147
+ store.recordSession(
1148
+ makeSession({
1149
+ beadId: "task-2",
1150
+ startedAt: "2026-01-01T11:00:00Z",
1151
+ estimatedCostUsd: 0.5,
1152
+ }),
1153
+ );
1154
+
1155
+ const results = store.getSessionsByDate();
1156
+ expect(results).toHaveLength(1);
1157
+ expect(results[0]?.estimatedCostUsd).toBeCloseTo(0.5, 5);
1158
+ });
1159
+ });
1160
+
1161
+ // === close ===
1162
+
1163
+ describe("close", () => {
1164
+ test("calling close does not throw", () => {
1165
+ expect(() => store.close()).not.toThrow();
1166
+ });
1167
+ });