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.
- package/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- 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
|
+
});
|