talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,594 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+
3
+ // Mock the log module before importing sessions
4
+ vi.mock("../util/log.js", () => ({
5
+ log: vi.fn(),
6
+ logError: vi.fn(),
7
+ logWarn: vi.fn(),
8
+ }));
9
+
10
+ // Mock fs to avoid real filesystem side effects
11
+ vi.mock("node:fs", () => ({
12
+ existsSync: vi.fn(() => false),
13
+ readFileSync: vi.fn(() => "{}"),
14
+ writeFileSync: vi.fn(),
15
+ mkdirSync: vi.fn(),
16
+ }));
17
+
18
+ const writeFileAtomicSync = vi.fn();
19
+ vi.mock("write-file-atomic", () => ({
20
+ default: { sync: writeFileAtomicSync },
21
+ }));
22
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+
25
+ // We need to import these functions after mocks are set up
26
+ const {
27
+ getSession,
28
+ incrementTurns,
29
+ recordUsage,
30
+ resetSession,
31
+ getSessionInfo,
32
+ setSessionId,
33
+ setLastBotMessageId,
34
+ getLastBotMessageId,
35
+ getActiveSessionCount,
36
+ setSessionName,
37
+ getAllSessions,
38
+ loadSessions,
39
+ flushSessions,
40
+ } = await import("../storage/sessions.js");
41
+
42
+ describe("sessions", () => {
43
+ beforeEach(() => {
44
+ // Reset sessions between tests by resetting all known chat IDs
45
+ // We use unique chat IDs per test to avoid cross-contamination
46
+ });
47
+
48
+ describe("getSession", () => {
49
+ it("creates a new session with defaults for unknown chat", () => {
50
+ const session = getSession("test-new-chat");
51
+ expect(session.sessionId).toBeUndefined();
52
+ expect(session.turns).toBe(0);
53
+ expect(session.lastActive).toBeGreaterThan(0);
54
+ expect(session.createdAt).toBeGreaterThan(0);
55
+ expect(session.usage).toBeDefined();
56
+ expect(session.usage.totalInputTokens).toBe(0);
57
+ expect(session.usage.totalOutputTokens).toBe(0);
58
+ });
59
+
60
+ it("returns the same session on subsequent calls", () => {
61
+ const chatId = "test-same-session";
62
+ const first = getSession(chatId);
63
+ first.turns = 5;
64
+ const second = getSession(chatId);
65
+ expect(second.turns).toBe(5);
66
+ });
67
+
68
+ it("initializes usage with all zero fields", () => {
69
+ const session = getSession("test-usage-init");
70
+ expect(session.usage.totalInputTokens).toBe(0);
71
+ expect(session.usage.totalOutputTokens).toBe(0);
72
+ expect(session.usage.totalCacheRead).toBe(0);
73
+ expect(session.usage.totalCacheWrite).toBe(0);
74
+ expect(session.usage.lastPromptTokens).toBe(0);
75
+ expect(session.usage.estimatedCostUsd).toBe(0);
76
+ expect(session.usage.totalResponseMs).toBe(0);
77
+ expect(session.usage.lastResponseMs).toBe(0);
78
+ expect(session.usage.fastestResponseMs).toBe(Infinity);
79
+ });
80
+ });
81
+
82
+ describe("incrementTurns", () => {
83
+ it("increments turn count by 1", () => {
84
+ const chatId = "test-inc-turns";
85
+ getSession(chatId); // initialize
86
+ incrementTurns(chatId);
87
+ expect(getSession(chatId).turns).toBe(1);
88
+ incrementTurns(chatId);
89
+ expect(getSession(chatId).turns).toBe(2);
90
+ });
91
+
92
+ it("updates lastActive timestamp", () => {
93
+ const chatId = "test-inc-active";
94
+ const before = getSession(chatId).lastActive;
95
+ // Small delay to ensure timestamp changes
96
+ incrementTurns(chatId);
97
+ expect(getSession(chatId).lastActive).toBeGreaterThanOrEqual(before);
98
+ });
99
+ });
100
+
101
+ describe("recordUsage", () => {
102
+ it("accumulates token usage correctly", () => {
103
+ const chatId = "test-record-usage";
104
+ getSession(chatId);
105
+
106
+ recordUsage(chatId, {
107
+ inputTokens: 100,
108
+ outputTokens: 50,
109
+ cacheRead: 10,
110
+ cacheWrite: 5,
111
+ });
112
+
113
+ const session = getSession(chatId);
114
+ expect(session.usage.totalInputTokens).toBe(100);
115
+ expect(session.usage.totalOutputTokens).toBe(50);
116
+ expect(session.usage.totalCacheRead).toBe(10);
117
+ expect(session.usage.totalCacheWrite).toBe(5);
118
+
119
+ recordUsage(chatId, {
120
+ inputTokens: 200,
121
+ outputTokens: 100,
122
+ cacheRead: 20,
123
+ cacheWrite: 10,
124
+ });
125
+
126
+ expect(session.usage.totalInputTokens).toBe(300);
127
+ expect(session.usage.totalOutputTokens).toBe(150);
128
+ expect(session.usage.totalCacheRead).toBe(30);
129
+ expect(session.usage.totalCacheWrite).toBe(15);
130
+ });
131
+
132
+ it("updates lastPromptTokens to latest turn snapshot", () => {
133
+ const chatId = "test-prompt-tokens";
134
+ getSession(chatId);
135
+
136
+ recordUsage(chatId, {
137
+ inputTokens: 100,
138
+ outputTokens: 50,
139
+ cacheRead: 10,
140
+ cacheWrite: 5,
141
+ });
142
+ // lastPromptTokens = inputTokens + cacheRead + cacheWrite
143
+ expect(getSession(chatId).usage.lastPromptTokens).toBe(115);
144
+
145
+ recordUsage(chatId, {
146
+ inputTokens: 200,
147
+ outputTokens: 75,
148
+ cacheRead: 30,
149
+ cacheWrite: 20,
150
+ });
151
+ expect(getSession(chatId).usage.lastPromptTokens).toBe(250);
152
+ });
153
+
154
+ it("calculates estimated cost", () => {
155
+ const chatId = "test-cost";
156
+ getSession(chatId);
157
+
158
+ recordUsage(chatId, {
159
+ inputTokens: 1_000_000,
160
+ outputTokens: 0,
161
+ cacheRead: 0,
162
+ cacheWrite: 0,
163
+ });
164
+ // Cost for 1M input tokens at $3/M = $3
165
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(3, 1);
166
+ });
167
+
168
+ it("tracks response time duration", () => {
169
+ const chatId = "test-duration";
170
+ getSession(chatId);
171
+
172
+ recordUsage(chatId, {
173
+ inputTokens: 100,
174
+ outputTokens: 50,
175
+ cacheRead: 0,
176
+ cacheWrite: 0,
177
+ durationMs: 1500,
178
+ });
179
+
180
+ const usage = getSession(chatId).usage;
181
+ expect(usage.lastResponseMs).toBe(1500);
182
+ expect(usage.totalResponseMs).toBe(1500);
183
+ expect(usage.fastestResponseMs).toBe(1500);
184
+
185
+ recordUsage(chatId, {
186
+ inputTokens: 100,
187
+ outputTokens: 50,
188
+ cacheRead: 0,
189
+ cacheWrite: 0,
190
+ durationMs: 800,
191
+ });
192
+
193
+ expect(usage.lastResponseMs).toBe(800);
194
+ expect(usage.totalResponseMs).toBe(2300);
195
+ expect(usage.fastestResponseMs).toBe(800);
196
+ });
197
+ });
198
+
199
+ describe("resetSession", () => {
200
+ it("removes the session so a fresh one is created next time", () => {
201
+ const chatId = "test-reset";
202
+ const session = getSession(chatId);
203
+ session.turns = 10;
204
+ setSessionId(chatId, "some-session-id");
205
+
206
+ resetSession(chatId);
207
+
208
+ const fresh = getSession(chatId);
209
+ expect(fresh.turns).toBe(0);
210
+ expect(fresh.sessionId).toBeUndefined();
211
+ });
212
+ });
213
+
214
+ describe("getSessionInfo", () => {
215
+ it("returns correct data for existing session", () => {
216
+ const chatId = "test-info-existing";
217
+ setSessionId(chatId, "sid-123");
218
+ incrementTurns(chatId);
219
+
220
+ const info = getSessionInfo(chatId);
221
+ expect(info.sessionId).toBe("sid-123");
222
+ expect(info.turns).toBe(1);
223
+ expect(info.lastActive).toBeGreaterThan(0);
224
+ });
225
+
226
+ it("returns defaults for missing session", () => {
227
+ const info = getSessionInfo("nonexistent-chat-id-xyz");
228
+ expect(info.sessionId).toBeUndefined();
229
+ expect(info.turns).toBe(0);
230
+ expect(info.lastActive).toBe(0);
231
+ expect(info.createdAt).toBe(0);
232
+ expect(info.usage.totalInputTokens).toBe(0);
233
+ });
234
+ });
235
+
236
+ describe("lastBotMessageId", () => {
237
+ it("stores and retrieves bot message ID", () => {
238
+ const chatId = "test-bot-msg";
239
+ expect(getLastBotMessageId(chatId)).toBeUndefined();
240
+
241
+ setLastBotMessageId(chatId, 42);
242
+ expect(getLastBotMessageId(chatId)).toBe(42);
243
+ });
244
+ });
245
+
246
+ describe("setSessionId", () => {
247
+ it("persists session ID", () => {
248
+ const chatId = "test-set-sid";
249
+ setSessionId(chatId, "abc-123");
250
+ expect(getSession(chatId).sessionId).toBe("abc-123");
251
+ });
252
+ });
253
+
254
+ describe("recordUsage with model pricing", () => {
255
+ it("applies haiku pricing for haiku model", () => {
256
+ const chatId = "test-haiku-pricing";
257
+ getSession(chatId);
258
+
259
+ recordUsage(chatId, {
260
+ inputTokens: 1_000_000,
261
+ outputTokens: 0,
262
+ cacheRead: 0,
263
+ cacheWrite: 0,
264
+ model: "claude-haiku-4-5",
265
+ });
266
+ // Haiku input: $0.8/M
267
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(0.8, 1);
268
+ });
269
+
270
+ it("applies opus pricing for opus model", () => {
271
+ const chatId = "test-opus-pricing";
272
+ getSession(chatId);
273
+
274
+ recordUsage(chatId, {
275
+ inputTokens: 1_000_000,
276
+ outputTokens: 0,
277
+ cacheRead: 0,
278
+ cacheWrite: 0,
279
+ model: "claude-opus-4-6",
280
+ });
281
+ // Opus input: $15/M
282
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(15, 1);
283
+ });
284
+
285
+ it("applies sonnet pricing by default (no model)", () => {
286
+ const chatId = "test-sonnet-pricing-default";
287
+ getSession(chatId);
288
+
289
+ recordUsage(chatId, {
290
+ inputTokens: 1_000_000,
291
+ outputTokens: 0,
292
+ cacheRead: 0,
293
+ cacheWrite: 0,
294
+ });
295
+ // Sonnet input: $3/M
296
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(3, 1);
297
+ });
298
+
299
+ it("calculates output cost correctly", () => {
300
+ const chatId = "test-output-cost";
301
+ getSession(chatId);
302
+
303
+ recordUsage(chatId, {
304
+ inputTokens: 0,
305
+ outputTokens: 1_000_000,
306
+ cacheRead: 0,
307
+ cacheWrite: 0,
308
+ model: "claude-sonnet-4-6",
309
+ });
310
+ // Sonnet output: $15/M
311
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(15, 1);
312
+ });
313
+
314
+ it("calculates cache read cost correctly", () => {
315
+ const chatId = "test-cache-read-cost";
316
+ getSession(chatId);
317
+
318
+ recordUsage(chatId, {
319
+ inputTokens: 0,
320
+ outputTokens: 0,
321
+ cacheRead: 1_000_000,
322
+ cacheWrite: 0,
323
+ model: "claude-sonnet-4-6",
324
+ });
325
+ // Sonnet cacheRead: $0.3/M
326
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(0.3, 2);
327
+ });
328
+
329
+ it("calculates cache write cost correctly", () => {
330
+ const chatId = "test-cache-write-cost";
331
+ getSession(chatId);
332
+
333
+ recordUsage(chatId, {
334
+ inputTokens: 0,
335
+ outputTokens: 0,
336
+ cacheRead: 0,
337
+ cacheWrite: 1_000_000,
338
+ model: "claude-sonnet-4-6",
339
+ });
340
+ // Sonnet cacheWrite: $3.75/M
341
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(3.75, 2);
342
+ });
343
+
344
+ it("tracks lastModel", () => {
345
+ const chatId = "test-last-model";
346
+ getSession(chatId);
347
+
348
+ recordUsage(chatId, {
349
+ inputTokens: 100,
350
+ outputTokens: 50,
351
+ cacheRead: 0,
352
+ cacheWrite: 0,
353
+ model: "claude-opus-4-6",
354
+ });
355
+
356
+ expect(getSession(chatId).lastModel).toBe("claude-opus-4-6");
357
+ });
358
+
359
+ it("updates fastestResponseMs correctly across turns", () => {
360
+ const chatId = "test-fastest-response";
361
+ getSession(chatId);
362
+
363
+ recordUsage(chatId, {
364
+ inputTokens: 100,
365
+ outputTokens: 50,
366
+ cacheRead: 0,
367
+ cacheWrite: 0,
368
+ durationMs: 2000,
369
+ });
370
+
371
+ recordUsage(chatId, {
372
+ inputTokens: 100,
373
+ outputTokens: 50,
374
+ cacheRead: 0,
375
+ cacheWrite: 0,
376
+ durationMs: 500,
377
+ });
378
+
379
+ recordUsage(chatId, {
380
+ inputTokens: 100,
381
+ outputTokens: 50,
382
+ cacheRead: 0,
383
+ cacheWrite: 0,
384
+ durationMs: 1000,
385
+ });
386
+
387
+ const usage = getSession(chatId).usage;
388
+ expect(usage.fastestResponseMs).toBe(500);
389
+ expect(usage.lastResponseMs).toBe(1000);
390
+ expect(usage.totalResponseMs).toBe(3500);
391
+ });
392
+ });
393
+
394
+ describe("setSessionName", () => {
395
+ it("persists session name", () => {
396
+ const chatId = "test-set-name";
397
+ getSession(chatId);
398
+ setSessionName(chatId, "My Test Session");
399
+ expect(getSession(chatId).sessionName).toBe("My Test Session");
400
+ });
401
+
402
+ it("updates existing name", () => {
403
+ const chatId = "test-update-name";
404
+ getSession(chatId);
405
+ setSessionName(chatId, "First Name");
406
+ setSessionName(chatId, "Second Name");
407
+ expect(getSession(chatId).sessionName).toBe("Second Name");
408
+ });
409
+
410
+ it("is reflected in getSessionInfo", () => {
411
+ const chatId = "test-name-in-info";
412
+ setSessionId(chatId, "sid-name");
413
+ setSessionName(chatId, "Named Session");
414
+ const info = getSessionInfo(chatId);
415
+ expect(info.sessionName).toBe("Named Session");
416
+ });
417
+ });
418
+
419
+ describe("setLastBotMessageId", () => {
420
+ it("persists bot message ID", () => {
421
+ const chatId = "test-set-bot-msg-id";
422
+ getSession(chatId);
423
+ setLastBotMessageId(chatId, 999);
424
+ expect(getLastBotMessageId(chatId)).toBe(999);
425
+ });
426
+
427
+ it("updates existing bot message ID", () => {
428
+ const chatId = "test-update-bot-msg";
429
+ getSession(chatId);
430
+ setLastBotMessageId(chatId, 100);
431
+ setLastBotMessageId(chatId, 200);
432
+ expect(getLastBotMessageId(chatId)).toBe(200);
433
+ });
434
+ });
435
+
436
+ describe("getAllSessions", () => {
437
+ it("returns all active sessions", () => {
438
+ const id1 = "test-all-sessions-1";
439
+ const id2 = "test-all-sessions-2";
440
+ getSession(id1);
441
+ getSession(id2);
442
+ setSessionId(id1, "sid-1");
443
+ setSessionId(id2, "sid-2");
444
+
445
+ const all = getAllSessions();
446
+ const chatIds = all.map((s) => s.chatId);
447
+ expect(chatIds).toContain(id1);
448
+ expect(chatIds).toContain(id2);
449
+ });
450
+
451
+ it("returns correct info structure for each session", () => {
452
+ const id = "test-all-sessions-info";
453
+ setSessionId(id, "sid-info");
454
+ incrementTurns(id);
455
+
456
+ const all = getAllSessions();
457
+ const entry = all.find((s) => s.chatId === id);
458
+ expect(entry).toBeDefined();
459
+ expect(entry!.info.sessionId).toBe("sid-info");
460
+ expect(entry!.info.turns).toBe(1);
461
+ expect(entry!.info.usage).toBeDefined();
462
+ expect(entry!.info.usage.totalInputTokens).toBe(0);
463
+ });
464
+ });
465
+
466
+ describe("loadSessions", () => {
467
+ it("handles missing file gracefully", () => {
468
+ vi.mocked(existsSync).mockReturnValueOnce(false);
469
+ expect(() => loadSessions()).not.toThrow();
470
+ });
471
+
472
+ it("handles corrupt JSON gracefully", () => {
473
+ vi.mocked(existsSync).mockReturnValueOnce(true);
474
+ vi.mocked(readFileSync).mockReturnValueOnce("not valid json");
475
+ expect(() => loadSessions()).not.toThrow();
476
+ });
477
+ });
478
+
479
+ describe("flushSessions", () => {
480
+ it("triggers an atomic write", () => {
481
+ writeFileAtomicSync.mockClear();
482
+ flushSessions();
483
+ expect(writeFileAtomicSync).toHaveBeenCalled();
484
+ });
485
+ });
486
+
487
+ describe("cost calculation math", () => {
488
+ it("calculates multi-component cost correctly (input + output + cache)", () => {
489
+ const chatId = "test-cost-math";
490
+ getSession(chatId);
491
+
492
+ // Use exact token counts to verify the formula:
493
+ // cost = (input * pricing.input + cacheWrite * pricing.cacheWrite +
494
+ // cacheRead * pricing.cacheRead + output * pricing.output) / 1_000_000
495
+ // Sonnet: input=$3/M, output=$15/M, cacheRead=$0.3/M, cacheWrite=$3.75/M
496
+ recordUsage(chatId, {
497
+ inputTokens: 500_000, // 500k * 3 / 1M = $1.50
498
+ outputTokens: 100_000, // 100k * 15 / 1M = $1.50
499
+ cacheRead: 200_000, // 200k * 0.3 / 1M = $0.06
500
+ cacheWrite: 100_000, // 100k * 3.75 / 1M = $0.375
501
+ model: "claude-sonnet-4-6",
502
+ });
503
+
504
+ const usage = getSession(chatId).usage;
505
+ // Total: 1.50 + 1.50 + 0.06 + 0.375 = $3.435
506
+ expect(usage.estimatedCostUsd).toBeCloseTo(3.435, 3);
507
+ });
508
+
509
+ it("accumulates cost across multiple recordUsage calls", () => {
510
+ const chatId = "test-cost-accum";
511
+ getSession(chatId);
512
+
513
+ recordUsage(chatId, {
514
+ inputTokens: 1_000_000,
515
+ outputTokens: 0,
516
+ cacheRead: 0,
517
+ cacheWrite: 0,
518
+ });
519
+ // Sonnet input: $3
520
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(3, 2);
521
+
522
+ recordUsage(chatId, {
523
+ inputTokens: 0,
524
+ outputTokens: 1_000_000,
525
+ cacheRead: 0,
526
+ cacheWrite: 0,
527
+ });
528
+ // + Sonnet output: $15. Total: $18
529
+ expect(getSession(chatId).usage.estimatedCostUsd).toBeCloseTo(18, 2);
530
+ });
531
+ });
532
+
533
+ describe("cache hit rate tracking", () => {
534
+ it("tracks cache read tokens across multiple turns", () => {
535
+ const chatId = "test-cache-track-read";
536
+ getSession(chatId);
537
+
538
+ recordUsage(chatId, {
539
+ inputTokens: 100,
540
+ outputTokens: 50,
541
+ cacheRead: 500,
542
+ cacheWrite: 200,
543
+ });
544
+ recordUsage(chatId, {
545
+ inputTokens: 150,
546
+ outputTokens: 75,
547
+ cacheRead: 800,
548
+ cacheWrite: 100,
549
+ });
550
+
551
+ const usage = getSession(chatId).usage;
552
+ expect(usage.totalCacheRead).toBe(1300);
553
+ expect(usage.totalCacheWrite).toBe(300);
554
+ });
555
+ });
556
+
557
+ describe("resetSession clears state", () => {
558
+ it("after reset, sessionId is undefined and turns is 0", () => {
559
+ const chatId = "test-reset-clear";
560
+ setSessionId(chatId, "some-session");
561
+ incrementTurns(chatId);
562
+ incrementTurns(chatId);
563
+ incrementTurns(chatId);
564
+
565
+ expect(getSession(chatId).sessionId).toBe("some-session");
566
+ expect(getSession(chatId).turns).toBe(3);
567
+
568
+ resetSession(chatId);
569
+
570
+ // getSession creates a fresh session, so check defaults
571
+ const fresh = getSession(chatId);
572
+ expect(fresh.sessionId).toBeUndefined();
573
+ expect(fresh.turns).toBe(0);
574
+ expect(fresh.usage.estimatedCostUsd).toBe(0);
575
+ expect(fresh.usage.totalInputTokens).toBe(0);
576
+ });
577
+ });
578
+
579
+ describe("getActiveSessionCount", () => {
580
+ it("returns the number of tracked sessions", () => {
581
+ // Create a session so count is at least 1
582
+ getSession("test-count-session");
583
+ const count = getActiveSessionCount();
584
+ expect(count).toBeGreaterThan(0);
585
+ expect(typeof count).toBe("number");
586
+ });
587
+
588
+ it("increases when new sessions are created", () => {
589
+ const before = getActiveSessionCount();
590
+ getSession("test-count-new-" + Math.random());
591
+ expect(getActiveSessionCount()).toBe(before + 1);
592
+ });
593
+ });
594
+ });