@pedrohnas/opencode-telegram 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +6 -3
  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
@@ -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** |