@pedrohnas/opencode-telegram 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
  2. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
  3. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
  4. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
  5. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
  6. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
  7. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
  8. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
  9. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
  10. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
  11. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
  12. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
  13. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
  14. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
  15. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
  16. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
  17. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
  18. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
  19. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
  20. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
  21. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
  22. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
  23. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
  24. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
  25. package/docs/AUDIT.md +193 -0
  26. package/docs/PROGRESS.md +188 -0
  27. package/docs/plans/phase-5.md +410 -0
  28. package/docs/plans/phase-6.5.md +426 -0
  29. package/docs/plans/phase-6.md +349 -0
  30. package/e2e/helpers.ts +34 -0
  31. package/e2e/phase-5.test.ts +295 -0
  32. package/e2e/phase-6.5.test.ts +239 -0
  33. package/e2e/phase-6.test.ts +302 -0
  34. package/package.json +5 -2
  35. package/src/api-server.test.ts +309 -0
  36. package/src/api-server.ts +201 -0
  37. package/src/bot.test.ts +354 -0
  38. package/src/bot.ts +200 -2
  39. package/src/config.test.ts +16 -0
  40. package/src/config.ts +4 -0
  41. package/src/event-bus.test.ts +337 -1
  42. package/src/event-bus.ts +83 -3
  43. package/src/handlers/agents.test.ts +122 -0
  44. package/src/handlers/agents.ts +93 -0
  45. package/src/handlers/media.test.ts +264 -0
  46. package/src/handlers/media.ts +168 -0
  47. package/src/handlers/models.test.ts +319 -0
  48. package/src/handlers/models.ts +191 -0
  49. package/src/index.ts +15 -0
  50. package/src/send/draft-stream.test.ts +76 -0
  51. package/src/send/draft-stream.ts +13 -1
  52. package/src/session-manager.test.ts +46 -0
  53. package/src/session-manager.ts +10 -1
@@ -0,0 +1,349 @@
1
+ # Phase 6 — Media & Files
2
+
3
+ **Goal:** Accept photos, documents, audio, voice messages, and video from Telegram
4
+ users, download them, convert to data URLs, and send as `FilePartInput` alongside
5
+ text to the OpenCode SDK. This allows the AI to process images, read documents,
6
+ and receive audio/video files.
7
+
8
+ ## What This Phase Delivers
9
+
10
+ 1. **Photo handling** — Photos sent to the bot are downloaded (highest resolution),
11
+ converted to a base64 data URL, and sent as a `FilePartInput` part to the SDK.
12
+
13
+ 2. **Document handling** — Documents (PDF, code files, etc.) are downloaded and
14
+ sent the same way. The original filename is preserved.
15
+
16
+ 3. **Voice/Audio handling** — Voice messages (OGG) and audio files are downloaded
17
+ and sent as file parts.
18
+
19
+ 4. **Video handling** — Video files are downloaded and sent as file parts.
20
+
21
+ 5. **Caption support** — If a media message has a caption, it becomes the text part.
22
+ If no caption, only the file part is sent.
23
+
24
+ 6. **Media groups** — When a user sends multiple photos at once, each is processed
25
+ individually (Grammy delivers them as separate updates).
26
+
27
+ 7. **File size limit** — Telegram Bot API limits file downloads to 20MB. Files
28
+ exceeding this are rejected with a user-friendly message.
29
+
30
+ ## SDK Integration
31
+
32
+ The SDK `session.prompt()` accepts mixed part arrays:
33
+
34
+ ```ts
35
+ sdk.session.prompt({
36
+ sessionID: string,
37
+ parts: [
38
+ { type: "text", text: "What's in this image?" },
39
+ { type: "file", mime: "image/jpeg", url: "data:image/jpeg;base64,...", filename: "photo.jpg" },
40
+ ],
41
+ })
42
+ ```
43
+
44
+ `FilePartInput` type (from SDK v2):
45
+ ```ts
46
+ type FilePartInput = {
47
+ id?: string
48
+ type: "file"
49
+ mime: string
50
+ filename?: string
51
+ url: string // data URL: "data:{mime};base64,{base64data}"
52
+ source?: FilePartSource
53
+ }
54
+ ```
55
+
56
+ ## Architecture
57
+
58
+ ### Download Flow
59
+
60
+ ```
61
+ User sends photo/document/voice/audio/video
62
+ → Grammy filter: bot.on("message:photo") / bot.on("message:document") / etc.
63
+ → downloadTelegramFile(fileId, token)
64
+ → ctx.api.getFile(fileId) → file_path
65
+ → fetch("https://api.telegram.org/file/bot{token}/{file_path}")
66
+ → Buffer.from(arrayBuffer)
67
+ → return { buffer, mime, filename }
68
+ → bufferToDataUrl(buffer, mime)
69
+ → "data:{mime};base64,{base64}"
70
+ → Build parts array: [FilePartInput, TextPartInput?]
71
+ → handleMessage({ chatId, text, parts, sdk, ... })
72
+ ```
73
+
74
+ ### Grammy Handlers
75
+
76
+ ```
77
+ bot.on("message:photo", handler) ← Photo (pick highest res from photo array)
78
+ bot.on("message:document", handler) ← Document (any file type)
79
+ bot.on("message:voice", handler) ← Voice message (OGG)
80
+ bot.on("message:audio", handler) ← Audio file
81
+ bot.on("message:video", handler) ← Video file
82
+ bot.on("message:text", handler) ← Text-only (existing — unchanged)
83
+ ```
84
+
85
+ All media handlers share the same logic pattern, consolidated into a single
86
+ `handleMediaMessage` function.
87
+
88
+ ### handleMessage Changes
89
+
90
+ Currently `handleMessage` accepts `text: string` and builds
91
+ `parts: [{ type: "text", text }]`. This needs to be generalized to accept
92
+ an optional pre-built `parts` array.
93
+
94
+ ```
95
+ BEFORE:
96
+ handleMessage({ chatId, text, sdk, ... })
97
+ → parts = [{ type: "text", text }]
98
+ → sdk.session.prompt({ parts, ... })
99
+
100
+ AFTER:
101
+ handleMessage({ chatId, text, parts?, sdk, ... })
102
+ → if parts provided: use them directly
103
+ → else: parts = [{ type: "text", text }]
104
+ → sdk.session.prompt({ parts, ... })
105
+ ```
106
+
107
+ ## New Files
108
+
109
+ ```
110
+ src/
111
+ handlers/
112
+ media.ts ← downloadTelegramFile, bufferToDataUrl,
113
+ buildMediaParts, handleMediaMessage
114
+ media.test.ts ← ~18 tests
115
+ e2e/
116
+ phase-6.test.ts ← 3-4 E2E tests
117
+ ```
118
+
119
+ ## Modified Files
120
+
121
+ ```
122
+ src/
123
+ bot.ts ← Add media Grammy handlers (photo, document,
124
+ voice, audio, video), generalize handleMessage
125
+ to accept optional parts array
126
+ bot.test.ts ← ~4 new tests for parts passthrough
127
+ ```
128
+
129
+ ## TDD Execution Order (bottom-up by dependency)
130
+
131
+ ### Group A — Pure Functions (Independent)
132
+
133
+ #### A1. handlers/media.ts — File download & conversion (18 tests)
134
+
135
+ **Pure functions:**
136
+
137
+ ```ts
138
+ bufferToDataUrl(buffer: Buffer, mime: string): string
139
+ // → "data:{mime};base64,{base64data}"
140
+
141
+ buildFilePart(params: { buffer: Buffer; mime: string; filename: string }): FilePartInput
142
+ // → { type: "file", mime, url: dataUrl, filename }
143
+
144
+ buildMediaParts(params: {
145
+ buffer: Buffer
146
+ mime: string
147
+ filename: string
148
+ caption?: string
149
+ }): Array<TextPartInput | FilePartInput>
150
+ // → [FilePartInput] or [TextPartInput, FilePartInput] if caption exists
151
+
152
+ getMimeFromFileName(filename: string): string
153
+ // → Fallback MIME type from extension when Telegram doesn't provide one
154
+
155
+ extractFileRef(msg: TelegramMessage): { fileId: string; filename?: string; mime?: string } | null
156
+ // → Extract file_id and metadata from photo/document/voice/audio/video
157
+ ```
158
+
159
+ **Async functions:**
160
+
161
+ ```ts
162
+ downloadTelegramFile(params: {
163
+ fileId: string
164
+ token: string
165
+ getFile: (fileId: string) => Promise<{ file_path?: string; file_size?: number }>
166
+ }): Promise<{ buffer: Buffer; mime: string; filename: string } | null>
167
+ // → Downloads file from Telegram API, returns buffer + metadata
168
+ // → Returns null if file_path missing or download fails
169
+ // → Checks file_size against 20MB limit
170
+ ```
171
+
172
+ **Tests:**
173
+
174
+ *bufferToDataUrl:*
175
+ 1. Converts buffer to correct data URL format
176
+ 2. Handles empty buffer
177
+ 3. Handles different MIME types (image/jpeg, application/pdf, audio/ogg)
178
+
179
+ *buildFilePart:*
180
+ 4. Creates FilePartInput with correct fields
181
+ 5. URL is a valid data URL
182
+
183
+ *buildMediaParts:*
184
+ 6. Without caption → returns [FilePartInput] only
185
+ 7. With caption → returns [TextPartInput, FilePartInput]
186
+ 8. Empty caption treated as no caption
187
+
188
+ *getMimeFromFileName:*
189
+ 9. `.jpg` → `image/jpeg`
190
+ 10. `.pdf` → `application/pdf`
191
+ 11. `.py` → `text/x-python`
192
+ 12. Unknown extension → `application/octet-stream`
193
+
194
+ *extractFileRef:*
195
+ 13. Photo message → picks last (highest res) photo, returns file_id
196
+ 14. Document message → returns file_id + file_name + mime_type
197
+ 15. Voice message → returns file_id with audio/ogg mime
198
+ 16. Audio message → returns file_id + mime_type
199
+ 17. Video message → returns file_id with video/mp4 mime
200
+ 18. Message with no media → returns null
201
+
202
+ *downloadTelegramFile:*
203
+ (Tested with mocked getFile and fetch)
204
+ 19. Downloads and returns buffer + mime + filename
205
+ 20. Returns null when file_path is missing
206
+ 21. Returns null when file_size > 20MB
207
+
208
+ ### Group B — Wiring (bot.ts)
209
+
210
+ #### B2. bot.ts — Media handlers + handleMessage generalization (4 tests)
211
+
212
+ Changes:
213
+ - **Generalize `handleMessage`** to accept optional `parts` array parameter
214
+ - Add `bot.on("message:photo", ...)` handler
215
+ - Add `bot.on("message:document", ...)` handler
216
+ - Add `bot.on("message:voice", ...)` handler
217
+ - Add `bot.on("message:audio", ...)` handler
218
+ - Add `bot.on("message:video", ...)` handler
219
+ - All media handlers share common logic:
220
+ 1. Extract file ref from message
221
+ 2. Download file
222
+ 3. Build parts (file + optional caption)
223
+ 4. Call handleMessage with parts
224
+
225
+ **New tests:**
226
+ 1. `handleMessage` with explicit parts array uses them directly (not text)
227
+ 2. `handleMessage` without parts builds text part from text param (backward compat)
228
+ 3. `handleMessage` with both text and parts — parts wins
229
+ 4. `handleMessage` with empty parts array still works
230
+
231
+ ### Group C — E2E Tests
232
+
233
+ #### C3. E2E: phase-6.test.ts (4 tests)
234
+
235
+ ```ts
236
+ describe("Phase 6 — Media & Files", () => {
237
+ test("sending photo gets AI response about the image", async () => {
238
+ // Send a photo to the bot
239
+ // Assert bot responds (text length > 0)
240
+ }, 120000)
241
+
242
+ test("sending document gets AI response", async () => {
243
+ // Send a small text file as document
244
+ // Assert bot responds
245
+ }, 120000)
246
+
247
+ test("photo with caption includes caption in response context", async () => {
248
+ // Send photo with caption "What is this?"
249
+ // Assert bot responds
250
+ }, 120000)
251
+
252
+ test("regression: text message still works after media", async () => {
253
+ // Send /new, then text message
254
+ // Assert bot responds
255
+ }, 120000)
256
+ })
257
+ ```
258
+
259
+ ## Grammy Middleware Stack (updated)
260
+
261
+ ```
262
+ 1. allowlistMiddleware(config.allowedUsers)
263
+ 2. bot.command("start", ...)
264
+ 3. bot.command("new", ...)
265
+ 4. bot.command("list", ...)
266
+ 5. bot.command("rename", ...)
267
+ 6. bot.command("delete", ...)
268
+ 7. bot.command("info", ...)
269
+ 8. bot.command("history", ...)
270
+ 9. bot.command("summarize", ...)
271
+ 10. bot.command("model", ...)
272
+ 11. bot.command("agent", ...)
273
+ 12. bot.command("cancel", ...)
274
+ 13. bot.on("callback_query:data", ...)
275
+ 14. bot.on("message:photo", ...) ← NEW
276
+ 15. bot.on("message:document", ...) ← NEW
277
+ 16. bot.on("message:voice", ...) ← NEW
278
+ 17. bot.on("message:audio", ...) ← NEW
279
+ 18. bot.on("message:video", ...) ← NEW
280
+ 19. bot.on("message:text", ...) ← Existing (text-only)
281
+ ```
282
+
283
+ **Important:** Media handlers must come BEFORE `message:text` because a photo
284
+ with a caption also matches `message:text`. Grammy processes handlers in order
285
+ and stops at the first match.
286
+
287
+ ## Edge Cases
288
+
289
+ ### File Download
290
+ - `getFile` returns no `file_path` → return null, reply "Could not download file"
291
+ - File > 20MB → reject with "File too large (max 20MB)"
292
+ - Network error during fetch → catch, reply with error message
293
+ - Telegram servers slow → fetch has no built-in timeout, but Grammy handles update timeout
294
+
295
+ ### MIME Types
296
+ - Photos always have MIME from Telegram (`image/jpeg` usually)
297
+ - Documents may have `mime_type` field or not → fallback to extension-based detection
298
+ - Voice messages are always `audio/ogg`
299
+ - Videos are usually `video/mp4`
300
+
301
+ ### Media Groups
302
+ - Grammy delivers each photo in a media group as a separate update
303
+ - Each triggers the photo handler independently → each gets its own prompt call
304
+ - This is correct behavior: each photo is processed in the context of the session
305
+
306
+ ### Caption Handling
307
+ - Photo with caption → caption becomes TextPartInput, photo becomes FilePartInput
308
+ - Photo without caption → only FilePartInput (AI infers what to do)
309
+ - Document with caption → same as photo
310
+
311
+ ### Data URL Size
312
+ - A 20MB file → ~27MB base64 string → sent as JSON body to OpenCode server
313
+ - This is within typical HTTP body limits
314
+ - The OpenCode server handles forwarding to AI provider
315
+
316
+ ### Sticker/Animation/Video Note
317
+ - Out of scope for Phase 6 — these are special Telegram types
318
+ - Can be added later if needed
319
+
320
+ ## Acceptance Criteria
321
+
322
+ - [ ] Sending photo → bot downloads and processes image, responds with AI analysis
323
+ - [ ] Sending document → bot processes file content and responds
324
+ - [ ] Sending voice message → bot receives audio file
325
+ - [ ] Sending audio → bot receives audio file
326
+ - [ ] Sending video → bot receives video file
327
+ - [ ] Caption included as text part alongside file part
328
+ - [ ] Media groups handled (each photo processed independently)
329
+ - [ ] File > 20MB rejected with friendly message
330
+ - [ ] Text-only messages still work (backward compatible)
331
+ - [ ] `bun test src/` passes (all unit tests)
332
+ - [ ] `bun test ./e2e/phase-6.test.ts` passes
333
+ - [ ] All Phase 0-5 E2E tests still pass (regression)
334
+
335
+ ## Estimated Scope
336
+
337
+ - 1 new source file + 1 test file + 1 E2E test file
338
+ - ~150-200 LOC (src) + ~250-300 LOC (tests)
339
+ - Modified: bot.ts, bot.test.ts
340
+
341
+ ### Test Count Estimate
342
+
343
+ | File | New Tests |
344
+ |------|-----------|
345
+ | handlers/media.test.ts | 21 |
346
+ | bot.test.ts | 4 |
347
+ | **Unit total** | **~25 new → ~247 total** |
348
+ | E2E phase-6.test.ts | 4 |
349
+ | **E2E total** | **4 new → ~24 total** |
package/e2e/helpers.ts CHANGED
@@ -106,6 +106,40 @@ export function assertContains(msg: Api.Message, pattern: string | RegExp): void
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * Click an inline button, then wait for the message to be edited.
111
+ * Returns the updated message after edit.
112
+ */
113
+ export async function clickAndWaitEdit(
114
+ client: TelegramClient,
115
+ botUsername: string,
116
+ msgId: number,
117
+ buttonText: string,
118
+ timeoutMs = 10000,
119
+ ): Promise<Api.Message> {
120
+ // Capture the message text before clicking
121
+ const before = await client.getMessages(botUsername, { ids: [msgId] })
122
+ const textBefore = before[0]?.text ?? before[0]?.message ?? ""
123
+
124
+ await clickInlineButton(client, botUsername, msgId, buttonText)
125
+
126
+ // Poll until the message text changes (bot edited it)
127
+ const deadline = Date.now() + timeoutMs
128
+ while (Date.now() < deadline) {
129
+ await sleep(1500)
130
+ const messages = await client.getMessages(botUsername, { ids: [msgId] })
131
+ const msg = messages[0]
132
+ const textNow = msg?.text ?? msg?.message ?? ""
133
+ if (textNow !== textBefore) {
134
+ return msg!
135
+ }
136
+ }
137
+
138
+ // Return whatever the message is — it may have been edited to same-looking text
139
+ const final = await client.getMessages(botUsername, { ids: [msgId] })
140
+ return final[0]!
141
+ }
142
+
109
143
  /**
110
144
  * Assert that a message has inline keyboard buttons.
111
145
  */
@@ -0,0 +1,295 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test"
2
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
3
+ import {
4
+ sendAndWait,
5
+ assertHasButtons,
6
+ assertContains,
7
+ clickInlineButton,
8
+ clickAndWaitEdit,
9
+ } from "./helpers"
10
+ import { Api } from "telegram/tl"
11
+
12
+ describe("Phase 5 — Model & Agent Selection", () => {
13
+ beforeAll(async () => {
14
+ await setup()
15
+ }, 90000)
16
+
17
+ afterAll(async () => {
18
+ await teardown()
19
+ })
20
+
21
+ // --- /model flow ---
22
+
23
+ test(
24
+ "/model shows provider buttons",
25
+ async () => {
26
+ const client = getClient()
27
+ const bot = getBotUsername()
28
+ const reply = await sendAndWait(client, bot, "/model", 15000)
29
+ const text = reply.text ?? reply.message ?? ""
30
+ expect(text.length).toBeGreaterThan(0)
31
+ assertHasButtons(reply)
32
+ },
33
+ 30000,
34
+ )
35
+
36
+ test(
37
+ "clicking provider shows model list with Back button",
38
+ async () => {
39
+ const client = getClient()
40
+ const bot = getBotUsername()
41
+
42
+ // Send /model to get provider list
43
+ const providerMsg = await sendAndWait(client, bot, "/model", 15000)
44
+ assertHasButtons(providerMsg)
45
+
46
+ // Get the first provider button text
47
+ const markup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
48
+ const firstButton = markup.rows[0].buttons[0]
49
+ const providerName = firstButton.text
50
+
51
+ // Click the first provider
52
+ const modelMsg = await clickAndWaitEdit(
53
+ client,
54
+ bot,
55
+ providerMsg.id,
56
+ providerName,
57
+ )
58
+
59
+ // Should now show model list
60
+ const text = modelMsg.text ?? modelMsg.message ?? ""
61
+ expect(text).toContain("Models for")
62
+ assertHasButtons(modelMsg)
63
+
64
+ // Should have a Back button
65
+ const modelMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
66
+ const allButtons = modelMarkup.rows.flatMap((r) => r.buttons)
67
+ const hasBack = allButtons.some((b) => b.text.includes("Back"))
68
+ expect(hasBack).toBe(true)
69
+ },
70
+ 30000,
71
+ )
72
+
73
+ test(
74
+ "clicking model stores override and shows confirmation",
75
+ async () => {
76
+ const client = getClient()
77
+ const bot = getBotUsername()
78
+
79
+ // Send /model → click first provider → click first model
80
+ const providerMsg = await sendAndWait(client, bot, "/model", 15000)
81
+ const provMarkup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
82
+ const provButton = provMarkup.rows[0].buttons[0]
83
+
84
+ const modelMsg = await clickAndWaitEdit(
85
+ client,
86
+ bot,
87
+ providerMsg.id,
88
+ provButton.text,
89
+ )
90
+
91
+ // Click the first model (not Back)
92
+ const mdlMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
93
+ const modelButton = mdlMarkup.rows[0].buttons[0]
94
+ // Skip if first row is Back button
95
+ const nonBackButton = mdlMarkup.rows
96
+ .flatMap((r) => r.buttons)
97
+ .find((b) => !b.text.includes("Back"))
98
+
99
+ expect(nonBackButton).toBeDefined()
100
+
101
+ const confirmMsg = await clickAndWaitEdit(
102
+ client,
103
+ bot,
104
+ providerMsg.id,
105
+ nonBackButton!.text,
106
+ )
107
+
108
+ const confirmText = confirmMsg.text ?? confirmMsg.message ?? ""
109
+ expect(confirmText).toContain("Model set to")
110
+ },
111
+ 30000,
112
+ )
113
+
114
+ // --- /agent flow ---
115
+
116
+ test(
117
+ "/agent shows agent buttons with Reset",
118
+ async () => {
119
+ const client = getClient()
120
+ const bot = getBotUsername()
121
+ const reply = await sendAndWait(client, bot, "/agent", 15000)
122
+ const text = reply.text ?? reply.message ?? ""
123
+ expect(text.length).toBeGreaterThan(0)
124
+ assertHasButtons(reply)
125
+
126
+ // Should have Reset button
127
+ const markup = reply.replyMarkup as Api.ReplyInlineMarkup
128
+ const allButtons = markup.rows.flatMap((r) => r.buttons)
129
+ const hasReset = allButtons.some((b) => b.text.includes("Reset"))
130
+ expect(hasReset).toBe(true)
131
+ },
132
+ 30000,
133
+ )
134
+
135
+ test(
136
+ "clicking agent stores override and shows confirmation",
137
+ async () => {
138
+ const client = getClient()
139
+ const bot = getBotUsername()
140
+
141
+ const agentMsg = await sendAndWait(client, bot, "/agent", 15000)
142
+ const markup = agentMsg.replyMarkup as Api.ReplyInlineMarkup
143
+
144
+ // Click the first non-Reset agent
145
+ const agentButton = markup.rows
146
+ .flatMap((r) => r.buttons)
147
+ .find((b) => !b.text.includes("Reset"))
148
+
149
+ expect(agentButton).toBeDefined()
150
+
151
+ const confirmMsg = await clickAndWaitEdit(
152
+ client,
153
+ bot,
154
+ agentMsg.id,
155
+ agentButton!.text,
156
+ )
157
+
158
+ const confirmText = confirmMsg.text ?? confirmMsg.message ?? ""
159
+ expect(confirmText).toContain("Agent set to")
160
+ },
161
+ 30000,
162
+ )
163
+
164
+ test(
165
+ "clicking Reset clears agent override",
166
+ async () => {
167
+ const client = getClient()
168
+ const bot = getBotUsername()
169
+
170
+ const agentMsg = await sendAndWait(client, bot, "/agent", 15000)
171
+ const resetMsg = await clickAndWaitEdit(
172
+ client,
173
+ bot,
174
+ agentMsg.id,
175
+ "Reset",
176
+ )
177
+
178
+ const text = resetMsg.text ?? resetMsg.message ?? ""
179
+ expect(text).toContain("reset to default")
180
+ },
181
+ 30000,
182
+ )
183
+
184
+ // --- model override integration ---
185
+
186
+ test(
187
+ "selecting model then sending message gets AI response",
188
+ async () => {
189
+ const client = getClient()
190
+ const bot = getBotUsername()
191
+
192
+ // 1. /new to start fresh
193
+ await sendAndWait(client, bot, "/new", 30000)
194
+
195
+ // 2. /model → pick first provider → pick first model
196
+ const providerMsg = await sendAndWait(client, bot, "/model", 15000)
197
+ assertHasButtons(providerMsg)
198
+
199
+ const provMarkup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
200
+ const provButton = provMarkup.rows[0].buttons[0]
201
+
202
+ const modelMsg = await clickAndWaitEdit(
203
+ client,
204
+ bot,
205
+ providerMsg.id,
206
+ provButton.text,
207
+ )
208
+
209
+ const mdlMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
210
+ const nonBackButton = mdlMarkup.rows
211
+ .flatMap((r) => r.buttons)
212
+ .find((b) => !b.text.includes("Back"))
213
+
214
+ expect(nonBackButton).toBeDefined()
215
+
216
+ const confirmMsg = await clickAndWaitEdit(
217
+ client,
218
+ bot,
219
+ providerMsg.id,
220
+ nonBackButton!.text,
221
+ )
222
+ const confirmText = confirmMsg.text ?? confirmMsg.message ?? ""
223
+ expect(confirmText).toContain("Model set to")
224
+
225
+ // 3. Now send a text message — should work with the override
226
+ const reply = await sendAndWait(
227
+ client,
228
+ bot,
229
+ "Respond with exactly the single word: pong",
230
+ 90000,
231
+ )
232
+ expect(reply).toBeDefined()
233
+ const text = reply.text ?? reply.message ?? ""
234
+ expect(text.length).toBeGreaterThan(0)
235
+ },
236
+ 180000,
237
+ )
238
+
239
+ test(
240
+ "model override persists after /new",
241
+ async () => {
242
+ const client = getClient()
243
+ const bot = getBotUsername()
244
+
245
+ // Model was set in previous test. Do /new to create fresh session.
246
+ await sendAndWait(client, bot, "/new", 30000)
247
+
248
+ // Send a message — should still work with the overridden model
249
+ const reply = await sendAndWait(
250
+ client,
251
+ bot,
252
+ "Respond with exactly the single word: pong",
253
+ 90000,
254
+ )
255
+ expect(reply).toBeDefined()
256
+ const text = reply.text ?? reply.message ?? ""
257
+ expect(text.length).toBeGreaterThan(0)
258
+
259
+ // Verify override is still shown in /model
260
+ const modelMsg = await sendAndWait(client, bot, "/model", 15000)
261
+ const modelText = modelMsg.text ?? modelMsg.message ?? ""
262
+ expect(modelText).toContain("Current model:")
263
+ },
264
+ 180000,
265
+ )
266
+
267
+ // --- regression ---
268
+
269
+ test(
270
+ "regression: text message still works after model/agent commands",
271
+ async () => {
272
+ const client = getClient()
273
+ const bot = getBotUsername()
274
+
275
+ // Reset model to default first
276
+ const modelMsg = await sendAndWait(client, bot, "/model", 15000)
277
+ assertHasButtons(modelMsg)
278
+ // Look for Reset button — if model was overridden, there should be one
279
+ // (But in E2E the mdl:reset callback is handled)
280
+
281
+ await sendAndWait(client, bot, "/new", 30000)
282
+
283
+ const reply = await sendAndWait(
284
+ client,
285
+ bot,
286
+ "Respond with exactly the single word: pong",
287
+ 60000,
288
+ )
289
+ expect(reply).toBeDefined()
290
+ const text = reply.text ?? reply.message ?? ""
291
+ expect(text.length).toBeGreaterThan(0)
292
+ },
293
+ 120000,
294
+ )
295
+ })