@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/docs/PROGRESS.md
CHANGED
|
@@ -325,3 +325,194 @@ e2e/
|
|
|
325
325
|
- `sdk.session.abort()` is fire-and-forget — errors logged but don't block new turn
|
|
326
326
|
- Phase 3 streaming E2E was flaky: finalizeResponse can shorten text (strips tool suffix), so assert "same message ID + has content" instead of "text grew"
|
|
327
327
|
- `sdk.session.summarize()` requires providerID + modelID — simpler to guide users to ask the AI directly
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Phase 5 — Model & Agent Selection ✅
|
|
332
|
+
|
|
333
|
+
**Status:** Complete
|
|
334
|
+
**Date:** 2026-02-08
|
|
335
|
+
**Plan:** See `docs/plans/phase-5.md`
|
|
336
|
+
|
|
337
|
+
### Delivered
|
|
338
|
+
- **`/model` command** — Shows providers as inline keyboard. Clicking a provider shows its models. Selecting a model stores a `modelOverride` in SessionEntry, passed to every `session.prompt()`.
|
|
339
|
+
- **`/agent` command** — Shows available agents (filtered: non-hidden) as inline keyboard. Selecting stores `agentOverride` in SessionEntry.
|
|
340
|
+
- **Model reset** — "Reset to default" button clears override.
|
|
341
|
+
- **Agent reset** — Same pattern.
|
|
342
|
+
- **Back navigation** — Model selection supports back→provider list.
|
|
343
|
+
- **Override persistence** — Model/agent overrides survive `/new` and session switches (`/list`).
|
|
344
|
+
- **Callback data encoding** — Uses `mdl:{providerID}:{modelID}` directly (fits 64-byte limit for most providers). Falls back to truncation.
|
|
345
|
+
|
|
346
|
+
### Tests
|
|
347
|
+
| Type | Count | Status |
|
|
348
|
+
|------|-------|--------|
|
|
349
|
+
| Unit (bun test src/) | 222 | ✅ all pass |
|
|
350
|
+
| E2E Phase 0-4 (regression) | 17 | ✅ all pass |
|
|
351
|
+
| E2E Phase 5 | 3 | ✅ all pass |
|
|
352
|
+
|
|
353
|
+
### Files Created
|
|
354
|
+
```
|
|
355
|
+
src/
|
|
356
|
+
handlers/
|
|
357
|
+
models.ts ← formatProviderList, formatModelList, parseModelCallback, handleModel, handleModelSelect
|
|
358
|
+
models.test.ts ← 18 tests
|
|
359
|
+
agents.ts ← formatAgentList, parseAgentCallback, handleAgent, handleAgentSelect
|
|
360
|
+
agents.test.ts ← 10 tests
|
|
361
|
+
e2e/
|
|
362
|
+
phase-5.test.ts ← 3 E2E tests
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Files Modified
|
|
366
|
+
```
|
|
367
|
+
src/
|
|
368
|
+
session-manager.ts ← Added modelOverride? and agentOverride? to SessionEntry
|
|
369
|
+
session-manager.test.ts ← 3 new tests for override fields
|
|
370
|
+
bot.ts ← /model, /agent commands; mdl: + agt: callback routing;
|
|
371
|
+
pass overrides in handleMessage prompt call
|
|
372
|
+
bot.test.ts ← 6 new tests (override passing, callbacks)
|
|
373
|
+
index.ts ← setMyCommands includes /model and /agent
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Key Design Decisions
|
|
377
|
+
- **Direct ID encoding in callback data** — `mdl:anthropic:claude-sonnet-4-5-20250929` fits 64 bytes. No need for index-based lookup maps.
|
|
378
|
+
- **Override persistence across `/new`** — overrides are per-user preferences, not per-session. Preserved by saving before remove and restoring after create.
|
|
379
|
+
- **Hidden agents filtered** — `sdk.app.agents()` may return hidden agents; we filter them out.
|
|
380
|
+
- **No pagination** — Most providers have <10 models. Deferred for later.
|
|
381
|
+
|
|
382
|
+
### Lessons Learned
|
|
383
|
+
- SDK v2 uses flat params: `sdk.session.prompt({ sessionID, parts, model: { providerID, modelID } })`, not nested path/body.
|
|
384
|
+
- `sdk.provider.list()` returns `{ data: { all: Provider[] } }` where each Provider has `models: { [modelID]: Model }`.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Phase 6 — Media & Files ✅
|
|
389
|
+
|
|
390
|
+
**Status:** Complete
|
|
391
|
+
**Date:** 2026-02-08
|
|
392
|
+
**Plan:** See `docs/plans/phase-6.md`
|
|
393
|
+
|
|
394
|
+
### Delivered
|
|
395
|
+
- **Photo handling** — Highest resolution photo downloaded, converted to base64 data URL, sent as `FilePartInput`.
|
|
396
|
+
- **Document handling** — Any document type (PDF, code, text) downloaded and sent as file part. Original filename preserved.
|
|
397
|
+
- **Voice/Audio handling** — Voice messages (OGG) and audio files downloaded and sent as file parts.
|
|
398
|
+
- **Video handling** — Video files downloaded and sent as file parts.
|
|
399
|
+
- **Caption support** — Caption becomes `TextPartInput` alongside `FilePartInput`.
|
|
400
|
+
- **File size limit** — 20MB check before download (Telegram Bot API limit).
|
|
401
|
+
- **MIME detection** — Extension-based fallback when Telegram doesn't provide MIME type (43 extensions mapped).
|
|
402
|
+
- **DraftStream race condition fix** — Added `sending` flag to prevent concurrent `sendMessage` calls when multiple SSE events arrive before first message is created. Fixes fragmentation with fast models (Gemini Flash).
|
|
403
|
+
- **Override persistence fix** — `handleNew` and `handleSessionCallback` now preserve model/agent overrides across session changes.
|
|
404
|
+
|
|
405
|
+
### Tests
|
|
406
|
+
| Type | Count | Status |
|
|
407
|
+
|------|-------|--------|
|
|
408
|
+
| Unit (bun test src/) | 252 | ✅ all pass |
|
|
409
|
+
| E2E Phase 0-5 (regression) | 26 | ✅ all pass |
|
|
410
|
+
| E2E Phase 6 | 6 | ✅ all pass |
|
|
411
|
+
|
|
412
|
+
### Files Created
|
|
413
|
+
```
|
|
414
|
+
src/
|
|
415
|
+
handlers/
|
|
416
|
+
media.ts ← extractFileRef, downloadTelegramFile, bufferToDataUrl,
|
|
417
|
+
buildFilePart, buildMediaParts, getMimeFromFileName
|
|
418
|
+
media.test.ts ← 21 tests
|
|
419
|
+
e2e/
|
|
420
|
+
phase-6.test.ts ← 6 E2E tests (photo, document, caption, regression text,
|
|
421
|
+
fragmentation, interruption)
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Files Modified
|
|
425
|
+
```
|
|
426
|
+
src/
|
|
427
|
+
bot.ts ← handleMedia function, 5 Grammy media handlers (photo,
|
|
428
|
+
document, voice, audio, video) BEFORE message:text;
|
|
429
|
+
handleMessage accepts optional parts param;
|
|
430
|
+
handleNew/handleSessionCallback preserve overrides
|
|
431
|
+
bot.test.ts ← 6 new tests (media parts, override persistence)
|
|
432
|
+
send/draft-stream.ts ← Added `sending` flag to prevent concurrent sendMessage race
|
|
433
|
+
send/draft-stream.test.ts ← 3 new tests for race condition scenarios
|
|
434
|
+
e2e/
|
|
435
|
+
phase-5.test.ts ← 6 new tests (model override + persistence + E2E)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Key Design Decisions
|
|
439
|
+
- **Grammy handler ordering** — Media handlers (`message:photo`, etc.) must come BEFORE `message:text` because photos with captions also match `message:text`.
|
|
440
|
+
- **Shared `handleMedia` function** — All 5 media types share the same handler logic.
|
|
441
|
+
- **Data URL approach** — Download → Buffer → base64 data URL. Simple, no temp files, works within HTTP body limits even for 20MB files (~27MB base64).
|
|
442
|
+
- **`sending` flag pattern** — Based on OpenClaw's `inFlight` pattern: first `update()` sets flag, concurrent calls just store `pending`, flag cleared after `sendMessage` completes.
|
|
443
|
+
|
|
444
|
+
### Bugs Fixed This Phase
|
|
445
|
+
1. **Streaming fragmentation** — With fast models (Gemini Flash), multiple SSE events called `DraftStream.update()` concurrently. All saw `messageId === null` and each called `sendMessage`, creating N separate messages. Fixed with `sending` guard flag.
|
|
446
|
+
2. **Model/agent override lost on `/new`** — `sessionManager.remove()` deleted the entry with overrides, then `getOrCreate()` created a clean one. Fixed by saving overrides before remove and restoring after.
|
|
447
|
+
|
|
448
|
+
### Lessons Learned
|
|
449
|
+
- Telegram requires valid PNG encoding — hand-crafted zlib fails, must use `zlib.deflateSync()`.
|
|
450
|
+
- gramjs `CustomFile(name, size, path, buffer)` for sending files in E2E tests.
|
|
451
|
+
- E2E fragmentation test: count bot messages between events, assert ≤ expected (not exact count).
|
|
452
|
+
- `DraftStream` race condition only manifests with fast models (high SSE event rate).
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Phase 6.5 — Production Hardening + Bot Control API ✅
|
|
457
|
+
|
|
458
|
+
**Status:** Complete — deployed to prod as v1.3.0
|
|
459
|
+
**Date:** 2026-02-08
|
|
460
|
+
**Plan:** See `docs/plans/phase-6.5.md`
|
|
461
|
+
**Audit:** See `docs/AUDIT.md`
|
|
462
|
+
|
|
463
|
+
### Delivered
|
|
464
|
+
- **EventBus auto-reconnect (G1/G5)** — SSE stream break → auto-reconnect with exponential backoff (2s→30s, 1.8x factor, ±25% jitter). Cancellable via `stop()`.
|
|
465
|
+
- **Grammy `apiThrottler()` (G2)** — API transformer that queues and retries on Telegram 429 rate limits.
|
|
466
|
+
- **Grammy `sequentialize()` (G3)** — First middleware, ensures per-chat serial update processing. Prevents race conditions with webhooks.
|
|
467
|
+
- **Smart /model filtering (G12)** — Only connected providers, models grouped by family (most recent per family), active model marked with ✓. Reduces ~400 models → ~16.
|
|
468
|
+
- **Bot Control API** — Hono HTTP server on `127.0.0.1:4097` exposing 7 endpoints for AI-driven model/agent/session control.
|
|
469
|
+
- **SKILL.md** — Teaches AI how to discover its sessionId and use the Bot Control API.
|
|
470
|
+
|
|
471
|
+
### Tests
|
|
472
|
+
| Type | Count | Status |
|
|
473
|
+
|------|-------|--------|
|
|
474
|
+
| Unit (bun test src/) | 295 | ✅ all pass |
|
|
475
|
+
| E2E Phase 6.5 | 9 | ✅ all pass |
|
|
476
|
+
| E2E Full regression | 41 | ✅ all pass |
|
|
477
|
+
|
|
478
|
+
### Files Created
|
|
479
|
+
```
|
|
480
|
+
src/
|
|
481
|
+
api-server.ts ← Hono app + createApiServer (Bot Control API)
|
|
482
|
+
api-server.test.ts ← 15 tests (all endpoints via app.request())
|
|
483
|
+
e2e/
|
|
484
|
+
phase-6.5.test.ts ← 9 E2E tests (/model filtering + Bot Control API)
|
|
485
|
+
docs/plans/
|
|
486
|
+
phase-6.5.md ← Phase plan
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Deployed
|
|
490
|
+
- SKILL.md at `~/.claude/skills/telegram-control/` (WSL + VPS)
|
|
491
|
+
- npm: `@pedrohnas/opencode-telegram@1.3.0`
|
|
492
|
+
|
|
493
|
+
### Files Modified
|
|
494
|
+
```
|
|
495
|
+
src/
|
|
496
|
+
event-bus.ts ← reconnectLoop + exponential backoff + cancellable sleep
|
|
497
|
+
event-bus.test.ts ← 13 new tests (reconnect, backoff, jitter, error recovery)
|
|
498
|
+
bot.ts ← sequentialize() middleware + filterModels in mdl: callback
|
|
499
|
+
bot.test.ts ← 3 new tests (sequentialize, throttler import)
|
|
500
|
+
config.ts ← Added apiPort config (TELEGRAM_API_PORT, default 4097)
|
|
501
|
+
config.test.ts ← 2 new tests for apiPort
|
|
502
|
+
index.ts ← apiThrottler + createApiServer + apiServer.stop in shutdown
|
|
503
|
+
handlers/models.ts ← filterModels(), formatProviderList(connected), formatModelList(activeModelID)
|
|
504
|
+
handlers/models.test.ts ← 8 new tests (filtering, connected, ✓ marker)
|
|
505
|
+
package.json ← Added hono, @grammyjs/transformer-throttler, @grammyjs/runner
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### New Dependencies
|
|
509
|
+
- `hono` ^4.x — HTTP framework for Bot Control API
|
|
510
|
+
- `@grammyjs/transformer-throttler` ^1.x — Telegram API 429 rate limit handling
|
|
511
|
+
- `@grammyjs/runner` ^2.x — sequentialize middleware for per-chat serial processing
|
|
512
|
+
|
|
513
|
+
### Key Design Decisions
|
|
514
|
+
- **Bot Control API on localhost** — AI calls it via `curl` from bash. No auth needed (bound to 127.0.0.1 only).
|
|
515
|
+
- **AI self-identification** — AI discovers its sessionId by calling OpenCode session list (`localhost:4096/session`), matches title pattern `"Telegram {chatId}"`.
|
|
516
|
+
- **filterModels pattern** — Group by family, sort by release_date descending, pick first. Handles missing family (uses model id as key).
|
|
517
|
+
- **Hono test pattern** — `app.request()` — no real Bun.serve needed in tests.
|
|
518
|
+
- **EventBus backoff** — `delay = min(maxDelay, initialDelay × factor^attempt) × (1 ± jitter)`. Cancellable sleep via sleepReject.
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# Phase 5 — Model & Agent Selection
|
|
2
|
+
|
|
3
|
+
**Goal:** Let users pick which AI model and agent to use per-chat via inline
|
|
4
|
+
keyboards. The SDK already supports per-prompt overrides (`session.prompt({
|
|
5
|
+
model, agent })`), so this phase adds the UI layer and per-chat state.
|
|
6
|
+
|
|
7
|
+
## What This Phase Delivers
|
|
8
|
+
|
|
9
|
+
1. **`/model` command** — Shows a two-step inline keyboard:
|
|
10
|
+
first pick a provider, then pick a model from that provider.
|
|
11
|
+
The selection is stored as a per-chat override applied to every subsequent prompt.
|
|
12
|
+
|
|
13
|
+
2. **`/agent` command** — Shows available agents (non-hidden) as an inline keyboard.
|
|
14
|
+
Selecting one stores the override per-chat.
|
|
15
|
+
|
|
16
|
+
3. **Per-chat overrides in SessionEntry** — Optional `modelOverride` and
|
|
17
|
+
`agentOverride` fields, passed to `sdk.session.prompt()` on every message.
|
|
18
|
+
|
|
19
|
+
4. **Reset buttons** — Both `/model` and `/agent` include a "Reset to default"
|
|
20
|
+
button to clear the override.
|
|
21
|
+
|
|
22
|
+
## SDK Endpoints Used
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// Fetch all providers with their models
|
|
26
|
+
sdk.provider.list()
|
|
27
|
+
// → { all: Provider[] }
|
|
28
|
+
// Each Provider: { id, name, models: { [modelID]: Model } }
|
|
29
|
+
// Each Model: { id, name, cost?, limit?, capabilities?, ... }
|
|
30
|
+
|
|
31
|
+
// Fetch all agents
|
|
32
|
+
sdk.app.agents()
|
|
33
|
+
// → Agent[]
|
|
34
|
+
// Each Agent: { name, description?, mode, hidden?, ... }
|
|
35
|
+
|
|
36
|
+
// Per-prompt override (existing — just need to pass the fields)
|
|
37
|
+
sdk.session.prompt({
|
|
38
|
+
sessionID: string,
|
|
39
|
+
parts: [...],
|
|
40
|
+
model?: { providerID: string, modelID: string }, // ← override
|
|
41
|
+
agent?: string, // ← override
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Architecture
|
|
46
|
+
|
|
47
|
+
### Model Selection Flow
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
/model
|
|
51
|
+
→ sdk.provider.list()
|
|
52
|
+
→ Filter providers that have ≥1 model
|
|
53
|
+
→ Show provider keyboard (1 per row, max 8)
|
|
54
|
+
→ callback_data: "mdl:{providerID}"
|
|
55
|
+
|
|
56
|
+
User clicks provider
|
|
57
|
+
→ sdk.provider.list() (re-fetch for models)
|
|
58
|
+
→ Show model keyboard for that provider + "⬅ Back" button
|
|
59
|
+
→ callback_data: "mdl:{providerID}:{modelID}" or "mdl:back"
|
|
60
|
+
|
|
61
|
+
User clicks model
|
|
62
|
+
→ sessionManager.get(chatKey) → set modelOverride
|
|
63
|
+
→ editMessageText: "Model set to: {providerName} / {modelName}"
|
|
64
|
+
|
|
65
|
+
User clicks "⬅ Back"
|
|
66
|
+
→ Re-show provider list
|
|
67
|
+
|
|
68
|
+
User clicks "Reset to default"
|
|
69
|
+
→ Clear modelOverride from SessionEntry
|
|
70
|
+
→ editMessageText: "Model reset to default."
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Agent Selection Flow
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
/agent
|
|
77
|
+
→ sdk.app.agents()
|
|
78
|
+
→ Filter: not hidden
|
|
79
|
+
→ Show agent keyboard (1 per row) + "Reset to default" button
|
|
80
|
+
→ callback_data: "agt:{agentName}" or "agt:reset"
|
|
81
|
+
|
|
82
|
+
User clicks agent
|
|
83
|
+
→ sessionManager.get(chatKey) → set agentOverride
|
|
84
|
+
→ editMessageText: "Agent set to: {agentName}"
|
|
85
|
+
|
|
86
|
+
User clicks reset
|
|
87
|
+
→ Clear agentOverride from SessionEntry
|
|
88
|
+
→ editMessageText: "Agent reset to default."
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Override Passing in handleMessage
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
BEFORE:
|
|
95
|
+
sdk.session.prompt({
|
|
96
|
+
sessionID: entry.sessionId,
|
|
97
|
+
parts: [{ type: "text", text }],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
AFTER:
|
|
101
|
+
sdk.session.prompt({
|
|
102
|
+
sessionID: entry.sessionId,
|
|
103
|
+
parts: [{ type: "text", text }],
|
|
104
|
+
...(entry.modelOverride && { model: entry.modelOverride }),
|
|
105
|
+
...(entry.agentOverride && { agent: entry.agentOverride }),
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Callback Data Design
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Model callbacks:
|
|
113
|
+
mdl:{providerID} → show models for provider
|
|
114
|
+
mdl:{providerID}:{modelID} → select model
|
|
115
|
+
mdl:back → back to provider list
|
|
116
|
+
mdl:reset → clear model override
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
"mdl:anthropic" = 14 bytes ✓
|
|
120
|
+
"mdl:anthropic:claude-sonnet-4-5-20250929" = 43 bytes ✓
|
|
121
|
+
"mdl:back" = 8 bytes ✓
|
|
122
|
+
"mdl:reset" = 9 bytes ✓
|
|
123
|
+
|
|
124
|
+
Worst case: "mdl:" + 20-char provider + ":" + 38-char model = 63 bytes ✓
|
|
125
|
+
|
|
126
|
+
Agent callbacks:
|
|
127
|
+
agt:{agentName} → select agent
|
|
128
|
+
agt:reset → clear agent override
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
"agt:code" = 8 bytes ✓
|
|
132
|
+
"agt:reset" = 9 bytes ✓
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## SessionEntry Changes
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
export type SessionEntry = {
|
|
139
|
+
sessionId: string
|
|
140
|
+
directory: string
|
|
141
|
+
createdAt: number
|
|
142
|
+
lastAccessAt: number
|
|
143
|
+
modelOverride?: { providerID: string; modelID: string } // ← NEW
|
|
144
|
+
agentOverride?: string // ← NEW
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Grammy Middleware Stack (updated)
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
1. allowlistMiddleware(config.allowedUsers)
|
|
152
|
+
2. bot.command("start", ...)
|
|
153
|
+
3. bot.command("new", ...)
|
|
154
|
+
4. bot.command("list", ...)
|
|
155
|
+
5. bot.command("rename", ...)
|
|
156
|
+
6. bot.command("delete", ...)
|
|
157
|
+
7. bot.command("info", ...)
|
|
158
|
+
8. bot.command("history", ...)
|
|
159
|
+
9. bot.command("summarize", ...)
|
|
160
|
+
10. bot.command("model", ...) ← NEW
|
|
161
|
+
11. bot.command("agent", ...) ← NEW
|
|
162
|
+
12. bot.command("cancel", ...)
|
|
163
|
+
13. bot.on("callback_query:data", ...) ← Extended with mdl: and agt: prefixes
|
|
164
|
+
14. bot.on("message:text", ...) ← Modified (pass overrides to prompt)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## New Files
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
src/
|
|
171
|
+
handlers/
|
|
172
|
+
models.ts ← formatProviderList, formatModelList,
|
|
173
|
+
parseModelCallback, formatCurrentModel,
|
|
174
|
+
handleModel, handleModelSelect
|
|
175
|
+
models.test.ts ← ~18 tests
|
|
176
|
+
agents.ts ← formatAgentList, parseAgentCallback,
|
|
177
|
+
handleAgent, handleAgentSelect
|
|
178
|
+
agents.test.ts ← ~10 tests
|
|
179
|
+
e2e/
|
|
180
|
+
phase-5.test.ts ← 3 E2E tests
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Modified Files
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
src/
|
|
187
|
+
session-manager.ts ← Add modelOverride? and agentOverride? to SessionEntry
|
|
188
|
+
session-manager.test.ts ← 3 new tests for override fields
|
|
189
|
+
bot.ts ← /model, /agent commands, mdl: + agt: callback routing,
|
|
190
|
+
pass overrides in handleMessage prompt call
|
|
191
|
+
bot.test.ts ← ~6 new tests (override passing, new commands)
|
|
192
|
+
index.ts ← Add model/agent to setMyCommands
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## TDD Execution Order (bottom-up by dependency)
|
|
196
|
+
|
|
197
|
+
### Group A — Foundational / Independent Pieces
|
|
198
|
+
|
|
199
|
+
#### A1. session-manager.ts — model/agent override fields (3 new tests)
|
|
200
|
+
|
|
201
|
+
Add optional fields to SessionEntry type. No logic changes needed — `set()`
|
|
202
|
+
already stores the full entry object and `get()` returns it.
|
|
203
|
+
|
|
204
|
+
Tests:
|
|
205
|
+
1. `set/get` with `modelOverride` preserves value
|
|
206
|
+
2. `set/get` with `agentOverride` preserves value
|
|
207
|
+
3. `getOrCreate` returns entry without overrides by default
|
|
208
|
+
|
|
209
|
+
#### A2. handlers/models.ts — model selection (18 tests)
|
|
210
|
+
|
|
211
|
+
Pure functions + thin async handlers.
|
|
212
|
+
|
|
213
|
+
**Pure functions:**
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
formatProviderList(providers: any[]): { text: string; reply_markup: any }
|
|
217
|
+
// → Inline keyboard: one row per provider, button text = provider name
|
|
218
|
+
// → callback_data = "mdl:{providerID}"
|
|
219
|
+
// → Last row: "Reset to default" button (mdl:reset) if override active
|
|
220
|
+
|
|
221
|
+
formatModelList(providerID: string, providerName: string, models: any[]): { text: string; reply_markup: any }
|
|
222
|
+
// → Inline keyboard: one row per model, button text = model name
|
|
223
|
+
// → callback_data = "mdl:{providerID}:{modelID}"
|
|
224
|
+
// → Last row: "⬅ Back" button (mdl:back)
|
|
225
|
+
|
|
226
|
+
parseModelCallback(data: string):
|
|
227
|
+
| { type: "provider"; providerID: string }
|
|
228
|
+
| { type: "model"; providerID: string; modelID: string }
|
|
229
|
+
| { type: "back" }
|
|
230
|
+
| { type: "reset" }
|
|
231
|
+
| null
|
|
232
|
+
|
|
233
|
+
formatCurrentModel(override?: { providerID: string; modelID: string }): string
|
|
234
|
+
// → "Current model: {providerID}/{modelID}" or "Using default model"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Async handlers:**
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
handleModel(params: { sdk, sessionManager, chatKey }):
|
|
241
|
+
Promise<{ text: string; reply_markup: any }>
|
|
242
|
+
// → Fetches providers, prepends current model text, returns provider keyboard
|
|
243
|
+
|
|
244
|
+
handleModelSelect(params: { chatKey, providerID, modelID, sessionManager }):
|
|
245
|
+
Promise<string>
|
|
246
|
+
// → Stores override in SessionEntry, returns confirmation text
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Tests:
|
|
250
|
+
1. `parseModelCallback("mdl:anthropic")` → `{ type: "provider", providerID: "anthropic" }`
|
|
251
|
+
2. `parseModelCallback("mdl:anthropic:claude-sonnet")` → `{ type: "model", providerID: "anthropic", modelID: "claude-sonnet" }`
|
|
252
|
+
3. `parseModelCallback("mdl:back")` → `{ type: "back" }`
|
|
253
|
+
4. `parseModelCallback("mdl:reset")` → `{ type: "reset" }`
|
|
254
|
+
5. `parseModelCallback("invalid")` → `null`
|
|
255
|
+
6. `parseModelCallback("mdl:")` → `null` (empty after prefix)
|
|
256
|
+
7. `formatProviderList` with 2 providers → 2 button rows
|
|
257
|
+
8. `formatProviderList` with 0 providers → "No providers available."
|
|
258
|
+
9. `formatProviderList` filters providers with 0 models
|
|
259
|
+
10. `formatModelList` with 3 models → 3 button rows + back button
|
|
260
|
+
11. `formatModelList` with 0 models → "No models available." + back button
|
|
261
|
+
12. `formatModelList` callback_data includes providerID and modelID
|
|
262
|
+
13. `formatCurrentModel` with override → shows provider/model
|
|
263
|
+
14. `formatCurrentModel` without override → "Using default model"
|
|
264
|
+
15. `handleModel` fetches providers and returns keyboard
|
|
265
|
+
16. `handleModel` returns "No providers" when list is empty
|
|
266
|
+
17. `handleModelSelect` stores override in sessionManager
|
|
267
|
+
18. `handleModelSelect` returns confirmation message
|
|
268
|
+
|
|
269
|
+
#### A3. handlers/agents.ts — agent selection (10 tests)
|
|
270
|
+
|
|
271
|
+
**Pure functions:**
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
formatAgentList(agents: any[]): { text: string; reply_markup: any }
|
|
275
|
+
// → Inline keyboard: one row per agent (non-hidden), button text = name
|
|
276
|
+
// → callback_data = "agt:{name}"
|
|
277
|
+
// → Last row: "Reset to default" button (agt:reset)
|
|
278
|
+
|
|
279
|
+
parseAgentCallback(data: string):
|
|
280
|
+
| { name: string }
|
|
281
|
+
| { action: "reset" }
|
|
282
|
+
| null
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Async handlers:**
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
handleAgent(params: { sdk, sessionManager, chatKey }):
|
|
289
|
+
Promise<{ text: string; reply_markup: any }>
|
|
290
|
+
|
|
291
|
+
handleAgentSelect(params: { chatKey, agentName, sessionManager }):
|
|
292
|
+
Promise<string>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Tests:
|
|
296
|
+
1. `parseAgentCallback("agt:code")` → `{ name: "code" }`
|
|
297
|
+
2. `parseAgentCallback("agt:reset")` → `{ action: "reset" }`
|
|
298
|
+
3. `parseAgentCallback("invalid")` → `null`
|
|
299
|
+
4. `parseAgentCallback("agt:")` → `null`
|
|
300
|
+
5. `formatAgentList` with 2 agents → 2 rows + reset row
|
|
301
|
+
6. `formatAgentList` filters hidden agents
|
|
302
|
+
7. `formatAgentList` with 0 agents → "No agents available."
|
|
303
|
+
8. `handleAgent` fetches agents and returns keyboard
|
|
304
|
+
9. `handleAgentSelect` stores override in sessionManager
|
|
305
|
+
10. `handleAgentSelect` returns confirmation message
|
|
306
|
+
|
|
307
|
+
### Group B — Wiring (bot.ts + index.ts)
|
|
308
|
+
|
|
309
|
+
#### B4. bot.ts — New commands, callback routing, override passing (6 new tests)
|
|
310
|
+
|
|
311
|
+
Changes:
|
|
312
|
+
- Add `/model` command → calls `handleModel`, replies with keyboard
|
|
313
|
+
- Add `/agent` command → calls `handleAgent`, replies with keyboard
|
|
314
|
+
- Add `mdl:` callback routing (provider select → show models, model select → store, back → providers, reset → clear)
|
|
315
|
+
- Add `agt:` callback routing (select → store, reset → clear)
|
|
316
|
+
- Modify `handleMessage` prompt call to spread `modelOverride` and `agentOverride`
|
|
317
|
+
|
|
318
|
+
New tests:
|
|
319
|
+
1. `handleMessage` passes `modelOverride` to `sdk.session.prompt` when set
|
|
320
|
+
2. `handleMessage` passes `agentOverride` to `sdk.session.prompt` when set
|
|
321
|
+
3. `handleMessage` passes no model/agent when overrides not set
|
|
322
|
+
4. `handleMessage` passes both overrides simultaneously
|
|
323
|
+
5. (integration covered by E2E for /model and /agent commands)
|
|
324
|
+
|
|
325
|
+
#### B5. index.ts — Command menu update
|
|
326
|
+
|
|
327
|
+
Add to `setMyCommands` array:
|
|
328
|
+
```ts
|
|
329
|
+
{ command: "model", description: "Select model" },
|
|
330
|
+
{ command: "agent", description: "Select agent" },
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Group C — E2E Tests
|
|
334
|
+
|
|
335
|
+
#### C6. E2E: phase-5.test.ts (3 tests) + full regression
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
describe("Phase 5 — Model & Agent Selection", () => {
|
|
339
|
+
test("/model shows provider buttons", async () => {
|
|
340
|
+
const reply = await sendAndWait(client, BOT, "/model", 15000)
|
|
341
|
+
assertHasButtons(reply)
|
|
342
|
+
}, 30000)
|
|
343
|
+
|
|
344
|
+
test("/agent shows agent buttons", async () => {
|
|
345
|
+
const reply = await sendAndWait(client, BOT, "/agent", 15000)
|
|
346
|
+
assertHasButtons(reply)
|
|
347
|
+
}, 30000)
|
|
348
|
+
|
|
349
|
+
test("regression: text message still works", async () => {
|
|
350
|
+
const reply = await sendAndWait(client, BOT, "Say hello", 60000)
|
|
351
|
+
expect(reply.text.length).toBeGreaterThan(0)
|
|
352
|
+
}, 90000)
|
|
353
|
+
})
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Full regression: phases 0-5 E2E all pass.
|
|
357
|
+
|
|
358
|
+
## Edge Cases
|
|
359
|
+
|
|
360
|
+
### Model Selection
|
|
361
|
+
- Provider with 0 models → filtered out of list
|
|
362
|
+
- Model ID with colons in it → `parseModelCallback` splits on first two colons only
|
|
363
|
+
- `modelOverride` set but provider deleted from server → prompt fails gracefully (SDK error, not bot crash)
|
|
364
|
+
- User switches session via `/list` → override stays (it's per-chatKey, not per-session)
|
|
365
|
+
|
|
366
|
+
### Agent Selection
|
|
367
|
+
- Hidden agents → filtered out of keyboard
|
|
368
|
+
- Agent name with special chars → callback_data must be safe ASCII
|
|
369
|
+
- No agents configured → "No agents available."
|
|
370
|
+
|
|
371
|
+
### Override Persistence
|
|
372
|
+
- Overrides live in SessionManager (in-memory) → lost on bot restart
|
|
373
|
+
- This is acceptable: model/agent preference is lightweight, user re-selects if needed
|
|
374
|
+
- SessionEntry eviction (LRU/TTL) also clears overrides — consistent behavior
|
|
375
|
+
|
|
376
|
+
## Acceptance Criteria
|
|
377
|
+
|
|
378
|
+
- [ ] `/model` shows provider list with inline keyboard
|
|
379
|
+
- [ ] Selecting provider shows model list for that provider
|
|
380
|
+
- [ ] Selecting model stores override and shows confirmation
|
|
381
|
+
- [ ] "Reset to default" clears model override
|
|
382
|
+
- [ ] "⬅ Back" returns to provider list
|
|
383
|
+
- [ ] `/agent` shows agent list with inline keyboard
|
|
384
|
+
- [ ] Selecting agent stores override and shows confirmation
|
|
385
|
+
- [ ] "Reset to default" clears agent override
|
|
386
|
+
- [ ] Hidden agents excluded from list
|
|
387
|
+
- [ ] Next prompt uses selected model/agent override
|
|
388
|
+
- [ ] Overrides are per-chat (different chats have independent settings)
|
|
389
|
+
- [ ] Commands appear in Telegram "/" menu
|
|
390
|
+
- [ ] `bun test src/` passes (all unit tests)
|
|
391
|
+
- [ ] `bun test ./e2e/phase-5.test.ts` passes
|
|
392
|
+
- [ ] All Phase 0-4 E2E tests still pass (regression)
|
|
393
|
+
|
|
394
|
+
## Estimated Scope
|
|
395
|
+
|
|
396
|
+
- 2 new source files + 2 test files + 1 E2E test file
|
|
397
|
+
- ~200-250 LOC (src) + ~350-400 LOC (tests)
|
|
398
|
+
- Modified: session-manager.ts, session-manager.test.ts, bot.ts, bot.test.ts, index.ts
|
|
399
|
+
|
|
400
|
+
### Test Count Estimate
|
|
401
|
+
|
|
402
|
+
| File | New Tests |
|
|
403
|
+
|------|-----------|
|
|
404
|
+
| session-manager.test.ts | 3 |
|
|
405
|
+
| handlers/models.test.ts | 18 |
|
|
406
|
+
| handlers/agents.test.ts | 10 |
|
|
407
|
+
| bot.test.ts | 4 |
|
|
408
|
+
| **Unit total** | **~35 new → ~222 total** |
|
|
409
|
+
| E2E phase-5.test.ts | 3 |
|
|
410
|
+
| **E2E total** | **3 new → ~20 total** |
|