@pedrohnas/opencode-telegram 1.2.1 → 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.
Files changed (53) hide show
  1. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
  2. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
  3. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
  4. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
  5. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
  6. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
  7. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
  8. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
  9. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
  10. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
  11. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
  12. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
  13. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
  14. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
  15. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
  16. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
  17. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
  18. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
  19. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
  20. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
  21. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
  22. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
  23. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
  24. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
  25. package/docs/AUDIT.md +193 -0
  26. package/docs/PROGRESS.md +188 -0
  27. package/docs/plans/phase-5.md +410 -0
  28. package/docs/plans/phase-6.5.md +426 -0
  29. package/docs/plans/phase-6.md +349 -0
  30. package/e2e/helpers.ts +34 -0
  31. package/e2e/phase-5.test.ts +295 -0
  32. package/e2e/phase-6.5.test.ts +239 -0
  33. package/e2e/phase-6.test.ts +302 -0
  34. package/package.json +5 -2
  35. package/src/api-server.test.ts +309 -0
  36. package/src/api-server.ts +201 -0
  37. package/src/bot.test.ts +354 -0
  38. package/src/bot.ts +200 -2
  39. package/src/config.test.ts +16 -0
  40. package/src/config.ts +4 -0
  41. package/src/event-bus.test.ts +337 -1
  42. package/src/event-bus.ts +83 -3
  43. package/src/handlers/agents.test.ts +122 -0
  44. package/src/handlers/agents.ts +93 -0
  45. package/src/handlers/media.test.ts +264 -0
  46. package/src/handlers/media.ts +168 -0
  47. package/src/handlers/models.test.ts +319 -0
  48. package/src/handlers/models.ts +191 -0
  49. package/src/index.ts +15 -0
  50. package/src/send/draft-stream.test.ts +76 -0
  51. package/src/send/draft-stream.ts +13 -1
  52. package/src/session-manager.test.ts +46 -0
  53. package/src/session-manager.ts +10 -1
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeEach, mock } from "bun:test"
2
- import { EventBus, type EventHandler } from "./event-bus"
2
+ import { EventBus, computeDelay, DEFAULT_BACKOFF, type EventHandler } from "./event-bus"
3
3
  import { SessionManager } from "./session-manager"
4
4
 
5
5
  // Helper: create a mock SSE stream from an array of events
@@ -73,6 +73,7 @@ describe("EventBus", () => {
73
73
 
74
74
  // Wait for stream to be consumed
75
75
  await new Promise((r) => setTimeout(r, 50))
76
+ bus.stop()
76
77
 
77
78
  expect(received.length).toBe(1)
78
79
  expect(received[0].sessionId).toBe("s1")
@@ -91,6 +92,7 @@ describe("EventBus", () => {
91
92
  await bus.start()
92
93
 
93
94
  await new Promise((r) => setTimeout(r, 50))
95
+ bus.stop()
94
96
 
95
97
  expect(received.length).toBe(0)
96
98
  })
@@ -104,6 +106,7 @@ describe("EventBus", () => {
104
106
  await bus.start()
105
107
 
106
108
  await new Promise((r) => setTimeout(r, 50))
109
+ bus.stop()
107
110
 
108
111
  expect(onEvent).toHaveBeenCalledTimes(1)
109
112
  const [sid, ck, ev] = onEvent.mock.calls[0]
@@ -130,6 +133,7 @@ describe("EventBus", () => {
130
133
  await bus.start()
131
134
 
132
135
  await new Promise((r) => setTimeout(r, 50))
136
+ bus.stop()
133
137
 
134
138
  expect(received).toEqual([
135
139
  "chat:1:message.part.updated",
@@ -173,3 +177,335 @@ describe("EventBus", () => {
173
177
  expect(received.length).toBe(1)
174
178
  })
175
179
  })
180
+
181
+ describe("EventBus reconnect", () => {
182
+ let sm: SessionManager
183
+
184
+ beforeEach(() => {
185
+ sm = new SessionManager({ maxEntries: 100, ttlMs: 60000 })
186
+ })
187
+
188
+ test("reconnects after stream ends", async () => {
189
+ sm.set("chat:1", { sessionId: "s1", directory: "/tmp" })
190
+
191
+ const received: string[] = []
192
+ const handler: EventHandler = (_sid, _ck, event) => {
193
+ received.push(event.type)
194
+ }
195
+
196
+ let callCount = 0
197
+ const sdk = {
198
+ event: {
199
+ subscribe: mock(async () => {
200
+ callCount++
201
+ if (callCount === 1) {
202
+ return createMockStream([makePartEvent("s1", "first")])
203
+ }
204
+ if (callCount === 2) {
205
+ return createMockStream([makeIdleEvent("s1")])
206
+ }
207
+ // Third call: block forever (simulates stable connection)
208
+ return {
209
+ stream: (async function* () {
210
+ await new Promise(() => {}) // never resolves
211
+ })(),
212
+ }
213
+ }),
214
+ },
215
+ }
216
+
217
+ const bus = new EventBus({
218
+ sdk: sdk as any,
219
+ sessionManager: sm,
220
+ onEvent: handler,
221
+ backoff: { initialDelayMs: 10, maxDelayMs: 100, jitter: 0 },
222
+ _sleep: async () => {
223
+ await new Promise((r) => setTimeout(r, 5))
224
+ },
225
+ })
226
+ await bus.start()
227
+
228
+ // Wait for reconnect cycle to reach third subscribe
229
+ await new Promise((r) => setTimeout(r, 200))
230
+ bus.stop()
231
+
232
+ expect(received).toContain("message.part.updated")
233
+ expect(received).toContain("session.idle")
234
+ expect(callCount).toBe(3)
235
+ })
236
+
237
+ test("does NOT reconnect after stop() is called", async () => {
238
+ let subscribeCalls = 0
239
+ const sdk = {
240
+ event: {
241
+ subscribe: mock(async () => {
242
+ subscribeCalls++
243
+ return createMockStream([])
244
+ }),
245
+ },
246
+ }
247
+
248
+ let sleepCalled = false
249
+ const bus = new EventBus({
250
+ sdk: sdk as any,
251
+ sessionManager: sm,
252
+ onEvent: () => {},
253
+ backoff: { initialDelayMs: 50, maxDelayMs: 100, jitter: 0 },
254
+ _sleep: async (ms) => {
255
+ sleepCalled = true
256
+ // Stop during sleep — should cancel reconnect
257
+ bus.stop()
258
+ await new Promise((r) => setTimeout(r, ms))
259
+ },
260
+ })
261
+ await bus.start()
262
+
263
+ // Wait for stream to end, sleep to start, and stop to take effect
264
+ await new Promise((r) => setTimeout(r, 200))
265
+
266
+ expect(sleepCalled).toBe(true)
267
+ // Only the initial subscribe should have been called (no reconnect after stop)
268
+ expect(subscribeCalls).toBe(1)
269
+ })
270
+
271
+ test("backoff delay increases on consecutive failures", async () => {
272
+ const sdk = {
273
+ event: {
274
+ subscribe: mock(async () => {
275
+ throw new Error("connection refused")
276
+ }),
277
+ },
278
+ }
279
+
280
+ const sleepCalls: number[] = []
281
+ const bus = new EventBus({
282
+ sdk: sdk as any,
283
+ sessionManager: sm,
284
+ onEvent: () => {},
285
+ backoff: { initialDelayMs: 100, maxDelayMs: 10000, backoffFactor: 2, jitter: 0 },
286
+ _sleep: async (ms) => {
287
+ sleepCalls.push(ms)
288
+ if (sleepCalls.length >= 4) bus.stop()
289
+ await new Promise((r) => setTimeout(r, 5))
290
+ },
291
+ })
292
+ await bus.start()
293
+
294
+ await new Promise((r) => setTimeout(r, 200))
295
+
296
+ // Each delay should be larger than the previous
297
+ expect(sleepCalls.length).toBeGreaterThanOrEqual(3)
298
+ for (let i = 1; i < sleepCalls.length; i++) {
299
+ expect(sleepCalls[i]).toBeGreaterThan(sleepCalls[i - 1])
300
+ }
301
+ })
302
+
303
+ test("jitter adds randomness to delay", () => {
304
+ const config = { initialDelayMs: 1000, maxDelayMs: 30000, backoffFactor: 1.8, jitter: 0.25 }
305
+ const delays = new Set<number>()
306
+ for (let i = 0; i < 20; i++) {
307
+ delays.add(Math.round(computeDelay(config, 0)))
308
+ }
309
+ // With jitter, we should get multiple different values
310
+ expect(delays.size).toBeGreaterThan(1)
311
+ })
312
+
313
+ test("max delay is capped at maxDelayMs", () => {
314
+ const config = { initialDelayMs: 1000, maxDelayMs: 5000, backoffFactor: 10, jitter: 0 }
315
+ // attempt 5 → base would be 1000 * 10^5 = 100_000_000, but capped at 5000
316
+ const delay = computeDelay(config, 5)
317
+ expect(delay).toBe(5000)
318
+ })
319
+
320
+ test("reconnects after subscribe() throws an error", async () => {
321
+ let callCount = 0
322
+ const sdk = {
323
+ event: {
324
+ subscribe: mock(async () => {
325
+ callCount++
326
+ if (callCount === 1) throw new Error("network error")
327
+ // Block forever on success (simulates stable connection)
328
+ return {
329
+ stream: (async function* () {
330
+ await new Promise(() => {})
331
+ })(),
332
+ }
333
+ }),
334
+ },
335
+ }
336
+
337
+ const bus = new EventBus({
338
+ sdk: sdk as any,
339
+ sessionManager: sm,
340
+ onEvent: () => {},
341
+ backoff: { initialDelayMs: 10, maxDelayMs: 100, jitter: 0 },
342
+ _sleep: async () => {
343
+ await new Promise((r) => setTimeout(r, 5))
344
+ },
345
+ })
346
+ await bus.start()
347
+
348
+ await new Promise((r) => setTimeout(r, 200))
349
+ bus.stop()
350
+
351
+ // Should have retried after the error
352
+ expect(callCount).toBe(2)
353
+ })
354
+
355
+ test("reconnects after stream throws mid-iteration", async () => {
356
+ sm.set("chat:1", { sessionId: "s1", directory: "/tmp" })
357
+
358
+ const received: string[] = []
359
+ let callCount = 0
360
+ const sdk = {
361
+ event: {
362
+ subscribe: mock(async () => {
363
+ callCount++
364
+ if (callCount === 1) {
365
+ // Stream that throws after yielding one event
366
+ async function* brokenStream() {
367
+ yield makePartEvent("s1", "before-error")
368
+ throw new Error("stream broke")
369
+ }
370
+ return { stream: brokenStream() }
371
+ }
372
+ // Yields event then blocks forever
373
+ async function* stableStream() {
374
+ yield makeIdleEvent("s1")
375
+ await new Promise(() => {}) // never resolves
376
+ }
377
+ return { stream: stableStream() }
378
+ }),
379
+ },
380
+ }
381
+
382
+ const bus = new EventBus({
383
+ sdk: sdk as any,
384
+ sessionManager: sm,
385
+ onEvent: (_sid, _ck, ev) => received.push(ev.type),
386
+ backoff: { initialDelayMs: 10, maxDelayMs: 100, jitter: 0 },
387
+ _sleep: async () => {
388
+ await new Promise((r) => setTimeout(r, 5))
389
+ },
390
+ })
391
+ await bus.start()
392
+
393
+ await new Promise((r) => setTimeout(r, 200))
394
+ bus.stop()
395
+
396
+ expect(received).toContain("message.part.updated")
397
+ expect(received).toContain("session.idle")
398
+ expect(callCount).toBe(2)
399
+ })
400
+
401
+ test("continues processing events after reconnecting", async () => {
402
+ sm.set("chat:1", { sessionId: "s1", directory: "/tmp" })
403
+
404
+ const received: string[] = []
405
+ let callCount = 0
406
+ const sdk = {
407
+ event: {
408
+ subscribe: mock(async () => {
409
+ callCount++
410
+ if (callCount === 1) {
411
+ return createMockStream([makePartEvent("s1", "stream1")])
412
+ }
413
+ if (callCount === 2) {
414
+ return createMockStream([
415
+ makePartEvent("s1", "stream2-a"),
416
+ makeIdleEvent("s1"),
417
+ ])
418
+ }
419
+ // Block forever on third call
420
+ return {
421
+ stream: (async function* () {
422
+ await new Promise(() => {})
423
+ })(),
424
+ }
425
+ }),
426
+ },
427
+ }
428
+
429
+ const bus = new EventBus({
430
+ sdk: sdk as any,
431
+ sessionManager: sm,
432
+ onEvent: (_sid, _ck, ev) => received.push(ev.type),
433
+ backoff: { initialDelayMs: 10, maxDelayMs: 100, jitter: 0 },
434
+ _sleep: async () => {
435
+ await new Promise((r) => setTimeout(r, 5))
436
+ },
437
+ })
438
+ await bus.start()
439
+
440
+ await new Promise((r) => setTimeout(r, 200))
441
+ bus.stop()
442
+
443
+ // Events from both streams should be processed
444
+ expect(received.filter((t) => t === "message.part.updated").length).toBe(2)
445
+ expect(received).toContain("session.idle")
446
+ })
447
+
448
+ test("stop() during reconnect delay cancels the reconnect", async () => {
449
+ const sdk = {
450
+ event: {
451
+ subscribe: mock(async () => createMockStream([])),
452
+ },
453
+ }
454
+
455
+ let sleepStarted = false
456
+ const bus = new EventBus({
457
+ sdk: sdk as any,
458
+ sessionManager: sm,
459
+ onEvent: () => {},
460
+ backoff: { initialDelayMs: 5000, maxDelayMs: 5000, jitter: 0 },
461
+ _sleep: async (ms) => {
462
+ sleepStarted = true
463
+ await new Promise((r) => setTimeout(r, ms))
464
+ },
465
+ })
466
+ await bus.start()
467
+
468
+ // Wait for first stream to end and sleep to begin
469
+ await new Promise((r) => setTimeout(r, 50))
470
+ expect(sleepStarted).toBe(true)
471
+
472
+ const callsBefore = (sdk.event.subscribe as any).mock.calls.length
473
+ bus.stop()
474
+
475
+ // Wait — should NOT reconnect
476
+ await new Promise((r) => setTimeout(r, 200))
477
+ expect((sdk.event.subscribe as any).mock.calls.length).toBe(callsBefore)
478
+ })
479
+ })
480
+
481
+ describe("computeDelay", () => {
482
+ test("returns initialDelayMs for attempt 0 with no jitter", () => {
483
+ const delay = computeDelay({ ...DEFAULT_BACKOFF, jitter: 0 }, 0)
484
+ expect(delay).toBe(DEFAULT_BACKOFF.initialDelayMs)
485
+ })
486
+
487
+ test("increases delay with each attempt", () => {
488
+ const config = { initialDelayMs: 100, maxDelayMs: 10000, backoffFactor: 2, jitter: 0 }
489
+ expect(computeDelay(config, 0)).toBe(100)
490
+ expect(computeDelay(config, 1)).toBe(200)
491
+ expect(computeDelay(config, 2)).toBe(400)
492
+ expect(computeDelay(config, 3)).toBe(800)
493
+ })
494
+
495
+ test("caps at maxDelayMs", () => {
496
+ const config = { initialDelayMs: 100, maxDelayMs: 300, backoffFactor: 2, jitter: 0 }
497
+ expect(computeDelay(config, 0)).toBe(100)
498
+ expect(computeDelay(config, 1)).toBe(200)
499
+ expect(computeDelay(config, 2)).toBe(300) // would be 400, capped
500
+ expect(computeDelay(config, 10)).toBe(300)
501
+ })
502
+
503
+ test("jitter keeps delay within expected range", () => {
504
+ const config = { initialDelayMs: 1000, maxDelayMs: 30000, backoffFactor: 1, jitter: 0.25 }
505
+ for (let i = 0; i < 50; i++) {
506
+ const delay = computeDelay(config, 0)
507
+ expect(delay).toBeGreaterThanOrEqual(750) // 1000 - 25%
508
+ expect(delay).toBeLessThanOrEqual(1250) // 1000 + 25%
509
+ }
510
+ })
511
+ })
package/src/event-bus.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * Anti-leak design:
8
8
  * - One connection for ALL sessions (not one per session)
9
9
  * - AbortController for clean shutdown
10
- * - Auto-reconnect with backoff on stream end
10
+ * - Auto-reconnect with exponential backoff on stream end/error
11
11
  */
12
12
 
13
13
  import type { OpencodeClient } from "@opencode-ai/sdk/v2"
@@ -19,35 +19,101 @@ export type EventHandler = (
19
19
  event: any,
20
20
  ) => void
21
21
 
22
+ export type BackoffConfig = {
23
+ initialDelayMs: number
24
+ maxDelayMs: number
25
+ backoffFactor: number
26
+ jitter: number // 0-1, e.g. 0.25 for ±25%
27
+ }
28
+
29
+ export const DEFAULT_BACKOFF: BackoffConfig = {
30
+ initialDelayMs: 2000,
31
+ maxDelayMs: 30000,
32
+ backoffFactor: 1.8,
33
+ jitter: 0.25,
34
+ }
35
+
22
36
  export type EventBusOptions = {
23
37
  sdk: OpencodeClient
24
38
  sessionManager: SessionManager
25
39
  onEvent: EventHandler
40
+ backoff?: Partial<BackoffConfig>
41
+ /** Exposed for testing — override to track delay calls */
42
+ _sleep?: (ms: number) => Promise<void>
26
43
  }
27
44
 
28
45
  export class EventBus {
29
46
  private readonly sdk: OpencodeClient
30
47
  private readonly sessionManager: SessionManager
31
48
  private readonly onEvent: EventHandler
49
+ private readonly backoff: BackoffConfig
50
+ private readonly sleep: (ms: number) => Promise<void>
32
51
  private stopped = false
52
+ private sleepReject: ((err: Error) => void) | null = null
33
53
 
34
54
  constructor(opts: EventBusOptions) {
35
55
  this.sdk = opts.sdk
36
56
  this.sessionManager = opts.sessionManager
37
57
  this.onEvent = opts.onEvent
58
+ this.backoff = { ...DEFAULT_BACKOFF, ...opts.backoff }
59
+ this.sleep = opts._sleep ?? defaultSleep
38
60
  }
39
61
 
40
62
  async start(): Promise<void> {
41
63
  this.stopped = false
42
- this.listen().catch((err) => {
64
+ this.reconnectLoop().catch((err) => {
43
65
  if (!this.stopped) {
44
- console.error("EventBus stream error:", err)
66
+ console.error("EventBus reconnect loop error:", err)
45
67
  }
46
68
  })
47
69
  }
48
70
 
49
71
  stop(): void {
50
72
  this.stopped = true
73
+ // Break out of sleep if currently waiting
74
+ if (this.sleepReject) {
75
+ this.sleepReject(new Error("stopped"))
76
+ this.sleepReject = null
77
+ }
78
+ }
79
+
80
+ private async reconnectLoop(): Promise<void> {
81
+ let attempt = 0
82
+
83
+ while (!this.stopped) {
84
+ try {
85
+ await this.listen()
86
+ // Stream ended normally — reset attempt counter on successful connection
87
+ // (we got at least to the point of iterating)
88
+ } catch (err) {
89
+ if (this.stopped) return
90
+ console.error(`EventBus connection error (attempt ${attempt + 1}):`, err)
91
+ }
92
+
93
+ if (this.stopped) return
94
+
95
+ const delay = computeDelay(this.backoff, attempt)
96
+ console.log(`EventBus reconnecting in ${Math.round(delay)}ms (attempt ${attempt + 1})`)
97
+
98
+ try {
99
+ await this.cancellableSleep(delay)
100
+ } catch {
101
+ // Cancelled by stop()
102
+ return
103
+ }
104
+
105
+ attempt++
106
+ }
107
+ }
108
+
109
+ private async cancellableSleep(ms: number): Promise<void> {
110
+ return new Promise<void>((resolve, reject) => {
111
+ this.sleepReject = reject
112
+ this.sleep(ms).then(() => {
113
+ this.sleepReject = null
114
+ resolve()
115
+ })
116
+ })
51
117
  }
52
118
 
53
119
  private async listen(): Promise<void> {
@@ -70,6 +136,20 @@ export class EventBus {
70
136
  }
71
137
  }
72
138
 
139
+ export function computeDelay(config: BackoffConfig, attempt: number): number {
140
+ const base = Math.min(
141
+ config.maxDelayMs,
142
+ config.initialDelayMs * Math.pow(config.backoffFactor, attempt),
143
+ )
144
+ const jitterRange = base * config.jitter
145
+ const jitterOffset = (Math.random() * 2 - 1) * jitterRange
146
+ return Math.max(0, base + jitterOffset)
147
+ }
148
+
149
+ function defaultSleep(ms: number): Promise<void> {
150
+ return new Promise((resolve) => setTimeout(resolve, ms))
151
+ }
152
+
73
153
  /**
74
154
  * Extract sessionID from various event shapes.
75
155
  * Events may have it at:
@@ -0,0 +1,122 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test"
2
+ import { SessionManager } from "../session-manager"
3
+ import {
4
+ parseAgentCallback,
5
+ formatAgentList,
6
+ handleAgent,
7
+ handleAgentSelect,
8
+ } from "./agents"
9
+
10
+ // --- parseAgentCallback ---
11
+
12
+ describe("parseAgentCallback", () => {
13
+ test("parses agent name", () => {
14
+ expect(parseAgentCallback("agt:code")).toEqual({ name: "code" })
15
+ })
16
+
17
+ test("parses reset action", () => {
18
+ expect(parseAgentCallback("agt:reset")).toEqual({ action: "reset" })
19
+ })
20
+
21
+ test("returns null for non-agt prefix", () => {
22
+ expect(parseAgentCallback("invalid")).toBeNull()
23
+ })
24
+
25
+ test("returns null for empty after prefix", () => {
26
+ expect(parseAgentCallback("agt:")).toBeNull()
27
+ })
28
+ })
29
+
30
+ // --- formatAgentList ---
31
+
32
+ describe("formatAgentList", () => {
33
+ test("formats 2 agents as rows + reset row", () => {
34
+ const agents = [
35
+ { name: "code", description: "Code agent" },
36
+ { name: "build", description: "Build agent" },
37
+ ]
38
+ const result = formatAgentList(agents)
39
+ // 2 agent rows + 1 reset row
40
+ expect(result.reply_markup.inline_keyboard.length).toBe(3)
41
+ expect(result.reply_markup.inline_keyboard[0][0].text).toBe("code")
42
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("agt:code")
43
+ const lastRow = result.reply_markup.inline_keyboard[2]
44
+ expect(lastRow[0].callback_data).toBe("agt:reset")
45
+ })
46
+
47
+ test("filters hidden agents", () => {
48
+ const agents = [
49
+ { name: "code", hidden: false },
50
+ { name: "secret", hidden: true },
51
+ ]
52
+ const result = formatAgentList(agents)
53
+ // 1 agent row + 1 reset row
54
+ expect(result.reply_markup.inline_keyboard.length).toBe(2)
55
+ expect(result.reply_markup.inline_keyboard[0][0].text).toBe("code")
56
+ })
57
+
58
+ test("returns 'No agents' when all hidden or empty", () => {
59
+ const result = formatAgentList([])
60
+ expect(result.text).toContain("No agents")
61
+ // Still has reset row
62
+ expect(result.reply_markup.inline_keyboard.length).toBe(1)
63
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("agt:reset")
64
+ })
65
+ })
66
+
67
+ // --- handleAgent ---
68
+
69
+ describe("handleAgent", () => {
70
+ let sm: SessionManager
71
+
72
+ beforeEach(() => {
73
+ sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
74
+ })
75
+
76
+ test("fetches agents and returns keyboard", async () => {
77
+ const sdk = {
78
+ app: {
79
+ agents: mock(async () => ({
80
+ data: [
81
+ { name: "code", description: "Code agent" },
82
+ { name: "build", description: "Build agent" },
83
+ ],
84
+ })),
85
+ },
86
+ }
87
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
88
+ const result = await handleAgent({ sdk: sdk as any, sessionManager: sm, chatKey: "123" })
89
+ expect(result.reply_markup.inline_keyboard.length).toBeGreaterThan(0)
90
+ expect(sdk.app.agents).toHaveBeenCalledTimes(1)
91
+ })
92
+ })
93
+
94
+ // --- handleAgentSelect ---
95
+
96
+ describe("handleAgentSelect", () => {
97
+ let sm: SessionManager
98
+
99
+ beforeEach(() => {
100
+ sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
101
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
102
+ })
103
+
104
+ test("stores override in sessionManager", async () => {
105
+ const result = await handleAgentSelect({
106
+ chatKey: "123",
107
+ agentName: "code",
108
+ sessionManager: sm,
109
+ })
110
+ expect(sm.get("123")?.agentOverride).toBe("code")
111
+ expect(result).toContain("code")
112
+ })
113
+
114
+ test("returns error when no active session", async () => {
115
+ const result = await handleAgentSelect({
116
+ chatKey: "999",
117
+ agentName: "code",
118
+ sessionManager: sm,
119
+ })
120
+ expect(result).toContain("No active session")
121
+ })
122
+ })