@pedrohnas/opencode-telegram 1.2.1 → 1.3.1
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/.claude/skills/playwright-cli/data/page-2026-02-09T02-40-45-070Z.png +0 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-15-698Z.yml +168 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-25-514Z.yml +219 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-40-888Z.yml +221 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-46-079Z.yml +230 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-53-985Z.yml +235 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-03-227Z.yml +235 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-08-587Z.yml +248 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-16-524Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-26-086Z.yml +196 -0
- package/docs/AUDIT.md +193 -0
- package/docs/PROGRESS.md +191 -0
- package/docs/plans/phase-5.md +410 -0
- package/docs/plans/phase-6.5.md +426 -0
- package/docs/plans/phase-6.md +349 -0
- package/e2e/helpers.ts +34 -0
- package/e2e/phase-5.test.ts +295 -0
- package/e2e/phase-6.5.test.ts +239 -0
- package/e2e/phase-6.test.ts +302 -0
- package/package.json +5 -2
- package/src/api-server.test.ts +309 -0
- package/src/api-server.ts +201 -0
- package/src/bot.test.ts +354 -0
- package/src/bot.ts +200 -2
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/event-bus.test.ts +337 -1
- package/src/event-bus.ts +83 -3
- package/src/handlers/agents.test.ts +122 -0
- package/src/handlers/agents.ts +93 -0
- package/src/handlers/media.test.ts +264 -0
- package/src/handlers/media.ts +168 -0
- package/src/handlers/models.test.ts +319 -0
- package/src/handlers/models.ts +191 -0
- package/src/index.ts +15 -0
- package/src/send/draft-stream.test.ts +76 -0
- package/src/send/draft-stream.ts +13 -1
- package/src/session-manager.test.ts +46 -0
- package/src/session-manager.ts +10 -1
package/src/event-bus.test.ts
CHANGED
|
@@ -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.
|
|
64
|
+
this.reconnectLoop().catch((err) => {
|
|
43
65
|
if (!this.stopped) {
|
|
44
|
-
console.error("EventBus
|
|
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
|
+
})
|