@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.
Files changed (39) hide show
  1. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-40-45-070Z.png +0 -0
  2. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-15-698Z.yml +168 -0
  3. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-25-514Z.yml +219 -0
  4. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-40-888Z.yml +221 -0
  5. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-46-079Z.yml +230 -0
  6. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-53-985Z.yml +235 -0
  7. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-03-227Z.yml +235 -0
  8. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-08-587Z.yml +248 -0
  9. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-16-524Z.yml +234 -0
  10. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-26-086Z.yml +196 -0
  11. package/docs/AUDIT.md +193 -0
  12. package/docs/PROGRESS.md +191 -0
  13. package/docs/plans/phase-5.md +410 -0
  14. package/docs/plans/phase-6.5.md +426 -0
  15. package/docs/plans/phase-6.md +349 -0
  16. package/e2e/helpers.ts +34 -0
  17. package/e2e/phase-5.test.ts +295 -0
  18. package/e2e/phase-6.5.test.ts +239 -0
  19. package/e2e/phase-6.test.ts +302 -0
  20. package/package.json +5 -2
  21. package/src/api-server.test.ts +309 -0
  22. package/src/api-server.ts +201 -0
  23. package/src/bot.test.ts +354 -0
  24. package/src/bot.ts +200 -2
  25. package/src/config.test.ts +16 -0
  26. package/src/config.ts +4 -0
  27. package/src/event-bus.test.ts +337 -1
  28. package/src/event-bus.ts +83 -3
  29. package/src/handlers/agents.test.ts +122 -0
  30. package/src/handlers/agents.ts +93 -0
  31. package/src/handlers/media.test.ts +264 -0
  32. package/src/handlers/media.ts +168 -0
  33. package/src/handlers/models.test.ts +319 -0
  34. package/src/handlers/models.ts +191 -0
  35. package/src/index.ts +15 -0
  36. package/src/send/draft-stream.test.ts +76 -0
  37. package/src/send/draft-stream.ts +13 -1
  38. package/src/session-manager.test.ts +46 -0
  39. 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** |