ctb 1.1.0 → 1.3.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 +50 -11
- package/docs/plans/2026-02-01-performance-reliability-improvements.md +1038 -0
- package/package.json +1 -1
- package/src/__tests__/cli.test.ts +12 -0
- package/src/__tests__/errors.test.ts +60 -0
- package/src/__tests__/events.test.ts +43 -0
- package/src/__tests__/session.test.ts +472 -0
- package/src/__tests__/telegram-api.test.ts +80 -0
- package/src/bot.ts +27 -3
- package/src/cli.ts +94 -0
- package/src/errors.ts +58 -0
- package/src/events.ts +57 -0
- package/src/handlers/commands.ts +383 -50
- package/src/handlers/index.ts +8 -1
- package/src/handlers/streaming.ts +23 -27
- package/src/handlers/text.ts +2 -1
- package/src/index.ts +53 -18
- package/src/session.ts +207 -7
- package/src/telegram-api.ts +195 -0
- package/src/types.ts +34 -33
- package/src/utils.ts +4 -21
package/package.json
CHANGED
|
@@ -180,6 +180,7 @@ describe("CLI argument parsing", () => {
|
|
|
180
180
|
dir?: string;
|
|
181
181
|
help?: boolean;
|
|
182
182
|
version?: boolean;
|
|
183
|
+
tut?: boolean;
|
|
183
184
|
} {
|
|
184
185
|
const options: {
|
|
185
186
|
token?: string;
|
|
@@ -187,6 +188,7 @@ describe("CLI argument parsing", () => {
|
|
|
187
188
|
dir?: string;
|
|
188
189
|
help?: boolean;
|
|
189
190
|
version?: boolean;
|
|
191
|
+
tut?: boolean;
|
|
190
192
|
} = {};
|
|
191
193
|
|
|
192
194
|
for (const arg of args) {
|
|
@@ -194,6 +196,8 @@ describe("CLI argument parsing", () => {
|
|
|
194
196
|
options.help = true;
|
|
195
197
|
} else if (arg === "--version" || arg === "-v") {
|
|
196
198
|
options.version = true;
|
|
199
|
+
} else if (arg === "tut" || arg === "tutorial") {
|
|
200
|
+
options.tut = true;
|
|
197
201
|
} else if (arg.startsWith("--token=")) {
|
|
198
202
|
options.token = arg.slice(8);
|
|
199
203
|
} else if (arg.startsWith("--users=")) {
|
|
@@ -222,6 +226,14 @@ describe("CLI argument parsing", () => {
|
|
|
222
226
|
expect(parseArgs(["-v"])).toEqual({ version: true });
|
|
223
227
|
});
|
|
224
228
|
|
|
229
|
+
test("parses tut command", () => {
|
|
230
|
+
expect(parseArgs(["tut"])).toEqual({ tut: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("parses tutorial command", () => {
|
|
234
|
+
expect(parseArgs(["tutorial"])).toEqual({ tut: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
225
237
|
test("parses --token option", () => {
|
|
226
238
|
const result = parseArgs(["--token=my-bot-token"]);
|
|
227
239
|
expect(result.token).toBe("my-bot-token");
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for error formatting.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { formatUserError } from "../errors";
|
|
7
|
+
|
|
8
|
+
describe("formatUserError", () => {
|
|
9
|
+
test("formats timeout error", () => {
|
|
10
|
+
const msg = formatUserError(new Error("Query timeout (180s > 180s limit)"));
|
|
11
|
+
expect(msg).toContain("took too long");
|
|
12
|
+
expect(msg).not.toContain("timeout");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("formats rate limit error", () => {
|
|
16
|
+
const msg = formatUserError(new Error("Too Many Requests: retry after 5"));
|
|
17
|
+
expect(msg).toContain("busy");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("formats network error", () => {
|
|
21
|
+
const msg = formatUserError(new Error("ETIMEDOUT"));
|
|
22
|
+
expect(msg.toLowerCase()).toContain("connection");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("formats generic error with truncation", () => {
|
|
26
|
+
const longError = "A".repeat(300);
|
|
27
|
+
const msg = formatUserError(new Error(longError));
|
|
28
|
+
expect(msg.length).toBeLessThan(250);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("formats cancelled/aborted error", () => {
|
|
32
|
+
const msg = formatUserError(new Error("Request was cancelled by user"));
|
|
33
|
+
expect(msg).toContain("cancelled");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("formats unsafe command error", () => {
|
|
37
|
+
const msg = formatUserError(new Error("unsafe command detected"));
|
|
38
|
+
expect(msg).toContain("safety");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("formats file access error", () => {
|
|
42
|
+
const msg = formatUserError(new Error("outside allowed paths"));
|
|
43
|
+
expect(msg).toContain("file location");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("formats authentication error", () => {
|
|
47
|
+
const msg = formatUserError(new Error("401 unauthorized"));
|
|
48
|
+
expect(msg).toContain("Authentication");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("formats ECONNRESET error", () => {
|
|
52
|
+
const msg = formatUserError(new Error("ECONNRESET"));
|
|
53
|
+
expect(msg).toContain("Connection");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("handles error with empty message", () => {
|
|
57
|
+
const msg = formatUserError(new Error(""));
|
|
58
|
+
expect(msg).toContain("Error");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for event emitter module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { botEvents } from "../events";
|
|
7
|
+
|
|
8
|
+
describe("BotEvents", () => {
|
|
9
|
+
test("emits and receives events", () => {
|
|
10
|
+
let received = false;
|
|
11
|
+
const unsubscribe = botEvents.on("sessionRunning", (running) => {
|
|
12
|
+
received = running;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
botEvents.emit("sessionRunning", true);
|
|
16
|
+
expect(received).toBe(true);
|
|
17
|
+
|
|
18
|
+
unsubscribe();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("unsubscribe stops receiving events", () => {
|
|
22
|
+
let count = 0;
|
|
23
|
+
const unsubscribe = botEvents.on("sessionRunning", () => {
|
|
24
|
+
count++;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
botEvents.emit("sessionRunning", true);
|
|
28
|
+
expect(count).toBe(1);
|
|
29
|
+
|
|
30
|
+
unsubscribe();
|
|
31
|
+
|
|
32
|
+
botEvents.emit("sessionRunning", true);
|
|
33
|
+
expect(count).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("getSessionState returns current state", () => {
|
|
37
|
+
botEvents.emit("sessionRunning", false);
|
|
38
|
+
expect(botEvents.getSessionState()).toBe(false);
|
|
39
|
+
|
|
40
|
+
botEvents.emit("sessionRunning", true);
|
|
41
|
+
expect(botEvents.getSessionState()).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for session module - model, cost, thinking, and plan mode features.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { ClaudeSession, getThinkingLevel } from "../session";
|
|
7
|
+
|
|
8
|
+
describe("getThinkingLevel", () => {
|
|
9
|
+
// Default keywords from config.ts:
|
|
10
|
+
// THINKING_KEYWORDS: "think,pensa,ragiona"
|
|
11
|
+
// THINKING_DEEP_KEYWORDS: "ultrathink,think hard,pensa bene"
|
|
12
|
+
|
|
13
|
+
describe("deep thinking triggers", () => {
|
|
14
|
+
test("triggers on 'think hard'", () => {
|
|
15
|
+
expect(getThinkingLevel("Please think hard about this")).toBe(50000);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("triggers on 'ultrathink'", () => {
|
|
19
|
+
expect(getThinkingLevel("ultrathink about the problem")).toBe(50000);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("triggers on 'pensa bene' (Italian)", () => {
|
|
23
|
+
expect(getThinkingLevel("pensa bene prima di rispondere")).toBe(50000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("is case insensitive", () => {
|
|
27
|
+
expect(getThinkingLevel("THINK HARD about this")).toBe(50000);
|
|
28
|
+
expect(getThinkingLevel("ULTRATHINK")).toBe(50000);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("normal thinking triggers", () => {
|
|
33
|
+
test("triggers on 'think'", () => {
|
|
34
|
+
expect(getThinkingLevel("Let me think about this")).toBe(10000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("triggers on 'pensa' (Italian)", () => {
|
|
38
|
+
expect(getThinkingLevel("pensa a questo problema")).toBe(10000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("triggers on 'ragiona' (Italian)", () => {
|
|
42
|
+
expect(getThinkingLevel("ragiona sul codice")).toBe(10000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("is case insensitive", () => {
|
|
46
|
+
expect(getThinkingLevel("THINK about it")).toBe(10000);
|
|
47
|
+
expect(getThinkingLevel("PENSA attentamente")).toBe(10000);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("no thinking triggers", () => {
|
|
52
|
+
test("returns 0 for simple messages", () => {
|
|
53
|
+
expect(getThinkingLevel("Hello")).toBe(0);
|
|
54
|
+
expect(getThinkingLevel("What time is it?")).toBe(0);
|
|
55
|
+
expect(getThinkingLevel("Show me the file")).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns 0 for empty string", () => {
|
|
59
|
+
expect(getThinkingLevel("")).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns 0 for unrelated words", () => {
|
|
63
|
+
expect(getThinkingLevel("analyze this code")).toBe(0);
|
|
64
|
+
expect(getThinkingLevel("consider the options")).toBe(0);
|
|
65
|
+
expect(getThinkingLevel("evaluate this approach")).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("priority", () => {
|
|
70
|
+
test("deep triggers take precedence over normal", () => {
|
|
71
|
+
// "think hard" should trigger deep (50000), not normal "think" (10000)
|
|
72
|
+
expect(getThinkingLevel("think hard")).toBe(50000);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("ClaudeSession", () => {
|
|
78
|
+
let session: ClaudeSession;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
session = new ClaudeSession();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("model selection", () => {
|
|
85
|
+
test("defaults to sonnet", () => {
|
|
86
|
+
expect(session.currentModel).toBe("sonnet");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("can be set to opus", () => {
|
|
90
|
+
session.currentModel = "opus";
|
|
91
|
+
expect(session.currentModel).toBe("opus");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("can be set to haiku", () => {
|
|
95
|
+
session.currentModel = "haiku";
|
|
96
|
+
expect(session.currentModel).toBe("haiku");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("can switch between models", () => {
|
|
100
|
+
session.currentModel = "opus";
|
|
101
|
+
expect(session.currentModel).toBe("opus");
|
|
102
|
+
|
|
103
|
+
session.currentModel = "haiku";
|
|
104
|
+
expect(session.currentModel).toBe("haiku");
|
|
105
|
+
|
|
106
|
+
session.currentModel = "sonnet";
|
|
107
|
+
expect(session.currentModel).toBe("sonnet");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("forceThinking", () => {
|
|
112
|
+
test("defaults to null", () => {
|
|
113
|
+
expect(session.forceThinking).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("can be set to specific token count", () => {
|
|
117
|
+
session.forceThinking = 10000;
|
|
118
|
+
expect(session.forceThinking).toBe(10000);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("can be set to 0 (off)", () => {
|
|
122
|
+
session.forceThinking = 0;
|
|
123
|
+
expect(session.forceThinking).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("can be set to deep (50000)", () => {
|
|
127
|
+
session.forceThinking = 50000;
|
|
128
|
+
expect(session.forceThinking).toBe(50000);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("planMode", () => {
|
|
133
|
+
test("defaults to false", () => {
|
|
134
|
+
expect(session.planMode).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("can be toggled on", () => {
|
|
138
|
+
session.planMode = true;
|
|
139
|
+
expect(session.planMode).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("can be toggled off", () => {
|
|
143
|
+
session.planMode = true;
|
|
144
|
+
session.planMode = false;
|
|
145
|
+
expect(session.planMode).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("token usage tracking", () => {
|
|
150
|
+
test("starts with zero totals", () => {
|
|
151
|
+
expect(session.totalInputTokens).toBe(0);
|
|
152
|
+
expect(session.totalOutputTokens).toBe(0);
|
|
153
|
+
expect(session.totalCacheReadTokens).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("can accumulate input tokens", () => {
|
|
157
|
+
session.totalInputTokens += 1000;
|
|
158
|
+
session.totalInputTokens += 500;
|
|
159
|
+
expect(session.totalInputTokens).toBe(1500);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("can accumulate output tokens", () => {
|
|
163
|
+
session.totalOutputTokens += 2000;
|
|
164
|
+
session.totalOutputTokens += 800;
|
|
165
|
+
expect(session.totalOutputTokens).toBe(2800);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("can accumulate cache read tokens", () => {
|
|
169
|
+
session.totalCacheReadTokens += 500;
|
|
170
|
+
session.totalCacheReadTokens += 300;
|
|
171
|
+
expect(session.totalCacheReadTokens).toBe(800);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("estimateCost", () => {
|
|
176
|
+
describe("sonnet pricing", () => {
|
|
177
|
+
test("calculates zero cost for zero tokens", () => {
|
|
178
|
+
const cost = session.estimateCost();
|
|
179
|
+
expect(cost.inputCost).toBe(0);
|
|
180
|
+
expect(cost.outputCost).toBe(0);
|
|
181
|
+
expect(cost.total).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("calculates cost for 1M input tokens", () => {
|
|
185
|
+
session.totalInputTokens = 1_000_000;
|
|
186
|
+
const cost = session.estimateCost();
|
|
187
|
+
expect(cost.inputCost).toBe(3); // $3 per 1M input
|
|
188
|
+
expect(cost.outputCost).toBe(0);
|
|
189
|
+
expect(cost.total).toBe(3);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("calculates cost for 1M output tokens", () => {
|
|
193
|
+
session.totalOutputTokens = 1_000_000;
|
|
194
|
+
const cost = session.estimateCost();
|
|
195
|
+
expect(cost.inputCost).toBe(0);
|
|
196
|
+
expect(cost.outputCost).toBe(15); // $15 per 1M output
|
|
197
|
+
expect(cost.total).toBe(15);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("calculates combined cost", () => {
|
|
201
|
+
session.totalInputTokens = 500_000;
|
|
202
|
+
session.totalOutputTokens = 100_000;
|
|
203
|
+
const cost = session.estimateCost();
|
|
204
|
+
expect(cost.inputCost).toBe(1.5); // $3 * 0.5
|
|
205
|
+
expect(cost.outputCost).toBe(1.5); // $15 * 0.1
|
|
206
|
+
expect(cost.total).toBe(3);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("opus pricing", () => {
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
session.currentModel = "opus";
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("calculates cost for 1M input tokens", () => {
|
|
216
|
+
session.totalInputTokens = 1_000_000;
|
|
217
|
+
const cost = session.estimateCost();
|
|
218
|
+
expect(cost.inputCost).toBe(15); // $15 per 1M input
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("calculates cost for 1M output tokens", () => {
|
|
222
|
+
session.totalOutputTokens = 1_000_000;
|
|
223
|
+
const cost = session.estimateCost();
|
|
224
|
+
expect(cost.outputCost).toBe(75); // $75 per 1M output
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("calculates combined cost", () => {
|
|
228
|
+
session.totalInputTokens = 100_000;
|
|
229
|
+
session.totalOutputTokens = 50_000;
|
|
230
|
+
const cost = session.estimateCost();
|
|
231
|
+
expect(cost.inputCost).toBe(1.5); // $15 * 0.1
|
|
232
|
+
expect(cost.outputCost).toBe(3.75); // $75 * 0.05
|
|
233
|
+
expect(cost.total).toBe(5.25);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("haiku pricing", () => {
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
session.currentModel = "haiku";
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("calculates cost for 1M input tokens", () => {
|
|
243
|
+
session.totalInputTokens = 1_000_000;
|
|
244
|
+
const cost = session.estimateCost();
|
|
245
|
+
expect(cost.inputCost).toBe(0.25); // $0.25 per 1M input
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("calculates cost for 1M output tokens", () => {
|
|
249
|
+
session.totalOutputTokens = 1_000_000;
|
|
250
|
+
const cost = session.estimateCost();
|
|
251
|
+
expect(cost.outputCost).toBe(1.25); // $1.25 per 1M output
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("calculates combined cost", () => {
|
|
255
|
+
session.totalInputTokens = 4_000_000;
|
|
256
|
+
session.totalOutputTokens = 800_000;
|
|
257
|
+
const cost = session.estimateCost();
|
|
258
|
+
expect(cost.inputCost).toBe(1); // $0.25 * 4
|
|
259
|
+
expect(cost.outputCost).toBe(1); // $1.25 * 0.8
|
|
260
|
+
expect(cost.total).toBe(2);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("small token counts", () => {
|
|
265
|
+
test("calculates fractional costs", () => {
|
|
266
|
+
session.totalInputTokens = 1000;
|
|
267
|
+
session.totalOutputTokens = 500;
|
|
268
|
+
const cost = session.estimateCost();
|
|
269
|
+
expect(cost.inputCost).toBeCloseTo(0.003, 5); // $3 * 0.001
|
|
270
|
+
expect(cost.outputCost).toBeCloseTo(0.0075, 5); // $15 * 0.0005
|
|
271
|
+
expect(cost.total).toBeCloseTo(0.0105, 5);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("kill", () => {
|
|
277
|
+
test("resets sessionId", async () => {
|
|
278
|
+
session.sessionId = "test-session-123";
|
|
279
|
+
await session.kill();
|
|
280
|
+
expect(session.sessionId).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("resets lastActivity", async () => {
|
|
284
|
+
session.lastActivity = new Date();
|
|
285
|
+
await session.kill();
|
|
286
|
+
expect(session.lastActivity).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("resets token totals", async () => {
|
|
290
|
+
session.totalInputTokens = 10000;
|
|
291
|
+
session.totalOutputTokens = 5000;
|
|
292
|
+
session.totalCacheReadTokens = 2000;
|
|
293
|
+
await session.kill();
|
|
294
|
+
expect(session.totalInputTokens).toBe(0);
|
|
295
|
+
expect(session.totalOutputTokens).toBe(0);
|
|
296
|
+
expect(session.totalCacheReadTokens).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("resets planMode", async () => {
|
|
300
|
+
session.planMode = true;
|
|
301
|
+
await session.kill();
|
|
302
|
+
expect(session.planMode).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("resets forceThinking", async () => {
|
|
306
|
+
session.forceThinking = 50000;
|
|
307
|
+
await session.kill();
|
|
308
|
+
expect(session.forceThinking).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("preserves currentModel", async () => {
|
|
312
|
+
session.currentModel = "opus";
|
|
313
|
+
await session.kill();
|
|
314
|
+
expect(session.currentModel).toBe("opus");
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("isActive", () => {
|
|
319
|
+
test("returns false when no sessionId", () => {
|
|
320
|
+
expect(session.isActive).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("returns true when sessionId exists", () => {
|
|
324
|
+
session.sessionId = "test-session-123";
|
|
325
|
+
expect(session.isActive).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("workingDir", () => {
|
|
330
|
+
test("can get working directory", () => {
|
|
331
|
+
expect(typeof session.workingDir).toBe("string");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("setWorkingDir changes directory", () => {
|
|
335
|
+
const newDir = "/tmp/test-dir";
|
|
336
|
+
session.setWorkingDir(newDir);
|
|
337
|
+
expect(session.workingDir).toBe(newDir);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("setWorkingDir clears sessionId", () => {
|
|
341
|
+
session.sessionId = "test-session-123";
|
|
342
|
+
session.setWorkingDir("/tmp/new-dir");
|
|
343
|
+
expect(session.sessionId).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("stop", () => {
|
|
348
|
+
test("returns false when nothing is running", async () => {
|
|
349
|
+
const result = await session.stop();
|
|
350
|
+
expect(result).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("interrupt flags", () => {
|
|
355
|
+
test("markInterrupt and consumeInterruptFlag work together", () => {
|
|
356
|
+
expect(session.consumeInterruptFlag()).toBe(false);
|
|
357
|
+
session.markInterrupt();
|
|
358
|
+
expect(session.consumeInterruptFlag()).toBe(true);
|
|
359
|
+
expect(session.consumeInterruptFlag()).toBe(false); // Consumed
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("processing state", () => {
|
|
364
|
+
test("startProcessing returns cleanup function", () => {
|
|
365
|
+
expect(session.isRunning).toBe(false);
|
|
366
|
+
const cleanup = session.startProcessing();
|
|
367
|
+
expect(session.isRunning).toBe(true);
|
|
368
|
+
cleanup();
|
|
369
|
+
expect(session.isRunning).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("undo/checkpointing", () => {
|
|
374
|
+
test("canUndo is false by default", () => {
|
|
375
|
+
expect(session.canUndo).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("undoCheckpoints is 0 by default", () => {
|
|
379
|
+
expect(session.undoCheckpoints).toBe(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("undo fails when no session active", async () => {
|
|
383
|
+
const [success, message] = await session.undo();
|
|
384
|
+
expect(success).toBe(false);
|
|
385
|
+
expect(message).toContain("No active session");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("kill resets checkpoints", async () => {
|
|
389
|
+
// Simulate having checkpoints (by accessing private property for testing)
|
|
390
|
+
// @ts-expect-error - accessing private for test
|
|
391
|
+
session._userMessageUuids = ["uuid1", "uuid2"];
|
|
392
|
+
expect(session.undoCheckpoints).toBe(2);
|
|
393
|
+
|
|
394
|
+
await session.kill();
|
|
395
|
+
expect(session.undoCheckpoints).toBe(0);
|
|
396
|
+
expect(session.canUndo).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("session debouncing", () => {
|
|
401
|
+
test("multiple saveSession calls within debounce window only write once", async () => {
|
|
402
|
+
let writeCount = 0;
|
|
403
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
404
|
+
const originalWrite = (Bun as any).write;
|
|
405
|
+
|
|
406
|
+
// Mock Bun.write to count session.json writes
|
|
407
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
408
|
+
(Bun as any).write = async (...args: unknown[]) => {
|
|
409
|
+
if (String(args[0]).includes("session.json")) {
|
|
410
|
+
writeCount++;
|
|
411
|
+
}
|
|
412
|
+
return originalWrite.apply(Bun, args);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
// Set up a session ID to trigger saves
|
|
417
|
+
session.sessionId = "test-session-debounce-1";
|
|
418
|
+
|
|
419
|
+
// Call saveSession multiple times rapidly
|
|
420
|
+
// @ts-expect-error - accessing private for test
|
|
421
|
+
session.saveSession();
|
|
422
|
+
// @ts-expect-error - accessing private for test
|
|
423
|
+
session.saveSession();
|
|
424
|
+
// @ts-expect-error - accessing private for test
|
|
425
|
+
session.saveSession();
|
|
426
|
+
|
|
427
|
+
// Immediately after calls, write should not have happened yet (debounced)
|
|
428
|
+
expect(writeCount).toBe(0);
|
|
429
|
+
|
|
430
|
+
// Wait for debounce timeout (500ms + buffer)
|
|
431
|
+
await Bun.sleep(600);
|
|
432
|
+
|
|
433
|
+
// Now exactly one write should have occurred
|
|
434
|
+
expect(writeCount).toBe(1);
|
|
435
|
+
} finally {
|
|
436
|
+
// Restore original Bun.write
|
|
437
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
438
|
+
(Bun as any).write = originalWrite;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("flushSession writes immediately", async () => {
|
|
443
|
+
let writeCount = 0;
|
|
444
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
445
|
+
const originalWrite = (Bun as any).write;
|
|
446
|
+
|
|
447
|
+
// Mock Bun.write to count session.json writes
|
|
448
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
449
|
+
(Bun as any).write = async (...args: unknown[]) => {
|
|
450
|
+
if (String(args[0]).includes("session.json")) {
|
|
451
|
+
writeCount++;
|
|
452
|
+
}
|
|
453
|
+
return originalWrite.apply(Bun, args);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// Set up a session ID to trigger saves
|
|
458
|
+
session.sessionId = "test-session-flush-1";
|
|
459
|
+
|
|
460
|
+
// Call flushSession for immediate write
|
|
461
|
+
session.flushSession();
|
|
462
|
+
|
|
463
|
+
// Write should happen immediately (synchronously)
|
|
464
|
+
expect(writeCount).toBe(1);
|
|
465
|
+
} finally {
|
|
466
|
+
// Restore original Bun.write
|
|
467
|
+
// biome-ignore lint/suspicious/noExplicitAny: test mock requires any
|
|
468
|
+
(Bun as any).write = originalWrite;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Telegram API utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { TelegramApiError, withRetry } from "../telegram-api";
|
|
7
|
+
|
|
8
|
+
describe("withRetry", () => {
|
|
9
|
+
test("succeeds on first attempt", async () => {
|
|
10
|
+
let attempts = 0;
|
|
11
|
+
const result = await withRetry(async () => {
|
|
12
|
+
attempts++;
|
|
13
|
+
return "success";
|
|
14
|
+
});
|
|
15
|
+
expect(result).toBe("success");
|
|
16
|
+
expect(attempts).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("retries on transient failure then succeeds", async () => {
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
const result = await withRetry(
|
|
22
|
+
async () => {
|
|
23
|
+
attempts++;
|
|
24
|
+
if (attempts < 3) {
|
|
25
|
+
throw new Error("Too Many Requests: retry after 1");
|
|
26
|
+
}
|
|
27
|
+
return "success";
|
|
28
|
+
},
|
|
29
|
+
{ maxRetries: 3, baseDelay: 10 },
|
|
30
|
+
);
|
|
31
|
+
expect(result).toBe("success");
|
|
32
|
+
expect(attempts).toBe(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("throws after max retries", async () => {
|
|
36
|
+
let attempts = 0;
|
|
37
|
+
await expect(
|
|
38
|
+
withRetry(
|
|
39
|
+
async () => {
|
|
40
|
+
attempts++;
|
|
41
|
+
throw new Error("Too Many Requests: retry after 1");
|
|
42
|
+
},
|
|
43
|
+
{ maxRetries: 2, baseDelay: 10 },
|
|
44
|
+
),
|
|
45
|
+
).rejects.toThrow();
|
|
46
|
+
expect(attempts).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not retry non-transient errors", async () => {
|
|
50
|
+
let attempts = 0;
|
|
51
|
+
await expect(
|
|
52
|
+
withRetry(
|
|
53
|
+
async () => {
|
|
54
|
+
attempts++;
|
|
55
|
+
throw new Error("Bad Request: message not found");
|
|
56
|
+
},
|
|
57
|
+
{ maxRetries: 3, baseDelay: 10 },
|
|
58
|
+
),
|
|
59
|
+
).rejects.toThrow("Bad Request");
|
|
60
|
+
expect(attempts).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("TelegramApiError", () => {
|
|
65
|
+
test("isTransient returns true for rate limit errors", () => {
|
|
66
|
+
const error = new TelegramApiError("Too Many Requests: retry after 5", 429);
|
|
67
|
+
expect(error.isTransient).toBe(true);
|
|
68
|
+
expect(error.retryAfter).toBe(5);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("isTransient returns true for network errors", () => {
|
|
72
|
+
const error = new TelegramApiError("ETIMEDOUT", 0);
|
|
73
|
+
expect(error.isTransient).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("isTransient returns false for bad request", () => {
|
|
77
|
+
const error = new TelegramApiError("Bad Request: message not found", 400);
|
|
78
|
+
expect(error.isTransient).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|