@pedrohnas/opencode-telegram 0.1.0 → 1.2.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.
- package/.env.example +17 -0
- package/bunfig.toml +2 -0
- package/docs/PROGRESS.md +327 -0
- package/docs/mapping.md +326 -0
- package/docs/plans/phase-1.md +176 -0
- package/docs/plans/phase-2.md +235 -0
- package/docs/plans/phase-3.md +485 -0
- package/docs/plans/phase-4.md +566 -0
- package/docs/spec.md +2055 -0
- package/e2e/client.ts +24 -0
- package/e2e/helpers.ts +119 -0
- package/e2e/phase-0.test.ts +30 -0
- package/e2e/phase-1.test.ts +48 -0
- package/e2e/phase-2.test.ts +54 -0
- package/e2e/phase-3.test.ts +142 -0
- package/e2e/phase-4.test.ts +96 -0
- package/e2e/runner.ts +145 -0
- package/package.json +14 -12
- package/scripts/gen-session.ts +49 -0
- package/src/bot.test.ts +301 -0
- package/src/bot.ts +91 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +15 -0
- package/src/event-bus.test.ts +175 -0
- package/src/handlers/allowlist.test.ts +60 -0
- package/src/handlers/allowlist.ts +33 -0
- package/src/handlers/cancel.test.ts +105 -0
- package/src/handlers/permissions.test.ts +72 -0
- package/src/handlers/questions.test.ts +107 -0
- package/src/handlers/sessions.test.ts +479 -0
- package/src/handlers/sessions.ts +202 -0
- package/src/handlers/typing.test.ts +60 -0
- package/src/index.ts +26 -0
- package/src/pending-requests.test.ts +64 -0
- package/src/send/chunker.test.ts +74 -0
- package/src/send/draft-stream.test.ts +229 -0
- package/src/send/format.test.ts +143 -0
- package/src/send/tool-progress.test.ts +70 -0
- package/src/session-manager.test.ts +198 -0
- package/src/session-manager.ts +23 -0
- package/src/turn-manager.test.ts +155 -0
- package/src/turn-manager.ts +5 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
# Phase 3 — Streaming + UX
|
|
2
|
+
|
|
3
|
+
**Goal:** Make responses feel responsive and informative. Instead of waiting for `session.idle`
|
|
4
|
+
to send a single complete message, stream the response in real-time via Telegram `editMessageText`,
|
|
5
|
+
and show tool call progress during generation.
|
|
6
|
+
|
|
7
|
+
## What This Phase Delivers
|
|
8
|
+
|
|
9
|
+
1. **Draft Streaming** — When the first text part arrives via SSE, send a placeholder message.
|
|
10
|
+
Then throttle-edit it as more text streams in (400ms minimum between edits). This replaces
|
|
11
|
+
the current behavior where the bot waits for `session.idle` before sending any response.
|
|
12
|
+
|
|
13
|
+
2. **Tool Call Progress** — When `message.part.updated` events arrive with `part.type === "tool"`,
|
|
14
|
+
append a status line to the draft (e.g., `\n\n---\n⚙ Running bash: ls -la`). When the tool
|
|
15
|
+
completes, the status line is removed and the next text update replaces it.
|
|
16
|
+
|
|
17
|
+
3. **Final Response** — When `session.idle` fires, stop the draft stream and send the properly
|
|
18
|
+
formatted final response. If the final text fits in the existing draft message (≤4096 chars),
|
|
19
|
+
do a final edit. If it exceeds 4096 chars, delete the draft and send chunked final messages.
|
|
20
|
+
|
|
21
|
+
## Current Behavior vs New Behavior
|
|
22
|
+
|
|
23
|
+
**Current (Phase 1-2):**
|
|
24
|
+
```
|
|
25
|
+
User sends message
|
|
26
|
+
→ EventBus accumulates text parts in turn.accumulatedText
|
|
27
|
+
→ On session.idle: sendFormattedResponse(chatId, accumulatedText)
|
|
28
|
+
→ User sees nothing until the AI finishes
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**New (Phase 3):**
|
|
32
|
+
```
|
|
33
|
+
User sends message
|
|
34
|
+
→ First text part arrives: DraftStream sends initial message
|
|
35
|
+
→ More text: DraftStream edits message (throttled at 400ms)
|
|
36
|
+
→ Tool starts: DraftStream appends tool status line
|
|
37
|
+
→ Tool completes: status line removed, text continues
|
|
38
|
+
→ session.idle: DraftStream stops, final formatted response sent
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Architecture
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
TurnManager.start()
|
|
45
|
+
→ Creates ActiveTurn with AbortController
|
|
46
|
+
→ DraftStream created in bot.ts Grammy handler after handleMessage
|
|
47
|
+
|
|
48
|
+
EventBus.onEvent("message.part.updated", type: "text")
|
|
49
|
+
→ turn.accumulatedText = part.text (full text, not delta)
|
|
50
|
+
→ turn.toolSuffix = "" (clear tool suffix)
|
|
51
|
+
→ turn.draft.update(part.text)
|
|
52
|
+
|
|
53
|
+
EventBus.onEvent("message.part.updated", type: "tool")
|
|
54
|
+
→ turn.toolSuffix = formatToolStatus(part)
|
|
55
|
+
→ turn.draft.update(turn.accumulatedText + toolSuffix)
|
|
56
|
+
|
|
57
|
+
EventBus.onEvent("session.idle")
|
|
58
|
+
→ turn.draft.stop()
|
|
59
|
+
→ Finalize: if text ≤ 4096 and draft exists:
|
|
60
|
+
edit draft to final formatted HTML
|
|
61
|
+
else if draft exists:
|
|
62
|
+
delete draft, send chunked final
|
|
63
|
+
else:
|
|
64
|
+
send normally (no draft was ever sent)
|
|
65
|
+
→ turnManager.end(sessionId)
|
|
66
|
+
|
|
67
|
+
AbortSignal fires (turn end / cancel)
|
|
68
|
+
→ DraftStream.stop() auto-called via signal listener
|
|
69
|
+
→ All timers cleared
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## DraftStream Class Design
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// src/send/draft-stream.ts
|
|
76
|
+
|
|
77
|
+
export type DraftStreamDeps = {
|
|
78
|
+
sendMessage: (chatId: number, text: string, opts?: any) => Promise<{ message_id: number }>
|
|
79
|
+
editMessageText: (chatId: number, messageId: number, text: string, opts?: any) => Promise<void>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class DraftStream {
|
|
83
|
+
private messageId: number | null = null
|
|
84
|
+
private lastText = ""
|
|
85
|
+
private lastSentAt = 0
|
|
86
|
+
private pending = ""
|
|
87
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
88
|
+
private stopped = false
|
|
89
|
+
private flushing = false
|
|
90
|
+
private htmlFailed = false
|
|
91
|
+
readonly throttleMs: number
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly deps: DraftStreamDeps,
|
|
95
|
+
private readonly chatId: number,
|
|
96
|
+
private readonly signal: AbortSignal,
|
|
97
|
+
throttleMs = 400,
|
|
98
|
+
) {
|
|
99
|
+
this.throttleMs = throttleMs
|
|
100
|
+
signal.addEventListener("abort", () => this.stop(), { once: true })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async update(text: string): Promise<void>
|
|
104
|
+
private scheduleFlush(): void
|
|
105
|
+
private async flush(): Promise<void>
|
|
106
|
+
stop(): void
|
|
107
|
+
getMessageId(): number | null
|
|
108
|
+
isStopped(): boolean
|
|
109
|
+
hasHtmlFailed(): boolean
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Key design decisions:**
|
|
114
|
+
|
|
115
|
+
1. **Dependency injection for testability** — `DraftStreamDeps` abstracts `bot.api.sendMessage`
|
|
116
|
+
and `bot.api.editMessageText`. Tests inject mocks directly.
|
|
117
|
+
|
|
118
|
+
2. **Full text replacement, not delta** — SDK sends full `part.text` on each update,
|
|
119
|
+
not incremental deltas. DraftStream always receives the complete current text.
|
|
120
|
+
|
|
121
|
+
3. **Truncation during streaming** — During stream, text is truncated to 4096 chars for
|
|
122
|
+
the draft message. Full text is kept in `turn.accumulatedText` for final send.
|
|
123
|
+
|
|
124
|
+
4. **HTML fallback tracking** — If `editMessageText` with `parse_mode: "HTML"` fails with
|
|
125
|
+
"can't parse entities", DraftStream switches to plain text for subsequent edits and sets
|
|
126
|
+
`htmlFailed = true`. Finalization tries HTML one more time on the complete text.
|
|
127
|
+
|
|
128
|
+
5. **Flush mutex** — `flushing` flag prevents concurrent flush operations. If a flush is
|
|
129
|
+
in flight and another `update()` arrives, the new text is stored in `pending` and will
|
|
130
|
+
be picked up when the current flush completes.
|
|
131
|
+
|
|
132
|
+
6. **AbortSignal integration** — `signal.addEventListener("abort", stop, { once: true })`
|
|
133
|
+
ensures automatic cleanup. No closures over Grammy context.
|
|
134
|
+
|
|
135
|
+
## DraftStream.update() Flow
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
update(text):
|
|
139
|
+
if stopped or text is empty → return
|
|
140
|
+
pending = text
|
|
141
|
+
|
|
142
|
+
if no messageId yet (first update):
|
|
143
|
+
truncate to 4096
|
|
144
|
+
convert to HTML
|
|
145
|
+
sendMessage → store messageId
|
|
146
|
+
set lastText, lastSentAt
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
scheduleFlush()
|
|
150
|
+
|
|
151
|
+
scheduleFlush():
|
|
152
|
+
if timer already set → return (will pick up latest pending)
|
|
153
|
+
delay = max(0, throttleMs - (now - lastSentAt))
|
|
154
|
+
timer = setTimeout(flush, delay)
|
|
155
|
+
|
|
156
|
+
flush():
|
|
157
|
+
timer = null
|
|
158
|
+
if stopped → return
|
|
159
|
+
flushing = true
|
|
160
|
+
text = pending.slice(0, 4096)
|
|
161
|
+
if text === lastText → flushing = false; return
|
|
162
|
+
|
|
163
|
+
try HTML edit:
|
|
164
|
+
if htmlFailed: plain text edit instead
|
|
165
|
+
else: convert to HTML, editMessageText with parse_mode: "HTML"
|
|
166
|
+
catch:
|
|
167
|
+
"message is not modified" → ignore
|
|
168
|
+
"can't parse entities" → htmlFailed = true, retry plain text
|
|
169
|
+
"MESSAGE_ID_INVALID" / "message to edit not found" → stopped = true
|
|
170
|
+
other → log, continue
|
|
171
|
+
|
|
172
|
+
lastText = text
|
|
173
|
+
lastSentAt = now
|
|
174
|
+
flushing = false
|
|
175
|
+
|
|
176
|
+
if pending changed during flush (new update arrived):
|
|
177
|
+
scheduleFlush() // re-schedule with latest
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Tool Progress Display
|
|
181
|
+
|
|
182
|
+
Tool progress is **appended to the draft text as a suffix**, not sent as separate messages.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// src/send/tool-progress.ts
|
|
186
|
+
|
|
187
|
+
export function formatToolStatus(part: any): string | null {
|
|
188
|
+
if (part.type !== "tool") return null
|
|
189
|
+
|
|
190
|
+
const tool = part.tool ?? "tool"
|
|
191
|
+
const state = part.state
|
|
192
|
+
|
|
193
|
+
if (state?.status === "running" && state?.title) {
|
|
194
|
+
return `\n\n---\n⚙ Running ${tool}: ${state.title}`
|
|
195
|
+
}
|
|
196
|
+
if (state?.status === "pending") {
|
|
197
|
+
return `\n\n---\n⚙ Preparing ${tool}...`
|
|
198
|
+
}
|
|
199
|
+
// completed/error → no suffix (text will update)
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Final Response (session.idle)
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
async function finalizeResponse(chatId: number, turn: ActiveTurn) {
|
|
208
|
+
turn.draft?.stop()
|
|
209
|
+
const text = turn.accumulatedText
|
|
210
|
+
if (!text) return
|
|
211
|
+
|
|
212
|
+
const draftMsgId = turn.draft?.getMessageId() ?? null
|
|
213
|
+
const html = markdownToTelegramHtml(text)
|
|
214
|
+
const chunks = chunkMessage(html)
|
|
215
|
+
|
|
216
|
+
if (chunks.length === 1 && draftMsgId) {
|
|
217
|
+
// Single chunk — edit draft to final version
|
|
218
|
+
try {
|
|
219
|
+
await bot.api.editMessageText(chatId, draftMsgId, html, { parse_mode: "HTML" })
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const msg = String(err)
|
|
222
|
+
if (/message is not modified/i.test(msg)) return
|
|
223
|
+
if (/can't parse entities/i.test(msg)) {
|
|
224
|
+
await bot.api.editMessageText(chatId, draftMsgId, text.slice(0, 4096))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
if (/message to edit not found/i.test(msg) || /MESSAGE_ID_INVALID/i.test(msg)) {
|
|
228
|
+
await sendFormattedResponse(chatId, text)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
throw err
|
|
232
|
+
}
|
|
233
|
+
} else if (draftMsgId) {
|
|
234
|
+
// Multiple chunks — delete draft and send all
|
|
235
|
+
await bot.api.deleteMessage(chatId, draftMsgId).catch(() => {})
|
|
236
|
+
await sendFormattedResponse(chatId, text)
|
|
237
|
+
} else {
|
|
238
|
+
// No draft sent — send normally
|
|
239
|
+
await sendFormattedResponse(chatId, text)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## ActiveTurn Changes
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
export type ActiveTurn = {
|
|
248
|
+
sessionId: string
|
|
249
|
+
chatId: number
|
|
250
|
+
abortController: AbortController
|
|
251
|
+
accumulatedText: string
|
|
252
|
+
toolSuffix: string // NEW: current tool status suffix
|
|
253
|
+
timers: Set<ReturnType<typeof setTimeout>>
|
|
254
|
+
draft: DraftStream | null // NEW: streaming draft editor
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`draftMessageId` field from original spec is not needed separately — it lives on
|
|
259
|
+
the `DraftStream` instance via `turn.draft.getMessageId()`.
|
|
260
|
+
|
|
261
|
+
## New Files
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
src/
|
|
265
|
+
send/
|
|
266
|
+
draft-stream.ts ← DraftStream class
|
|
267
|
+
draft-stream.test.ts ← ~18 tests
|
|
268
|
+
tool-progress.ts ← formatToolStatus()
|
|
269
|
+
tool-progress.test.ts ← ~8 tests
|
|
270
|
+
e2e/
|
|
271
|
+
phase-3.test.ts ← 3 E2E tests
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Modified Files
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
src/
|
|
278
|
+
turn-manager.ts ← Add toolSuffix + draft fields to ActiveTurn
|
|
279
|
+
turn-manager.test.ts ← 3 new tests for new fields
|
|
280
|
+
bot.ts ← Create DraftStream in Grammy handler after handleMessage
|
|
281
|
+
index.ts ← Replace accumulate-then-send with DraftStream,
|
|
282
|
+
add tool progress, add finalizeResponse
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## TDD Execution Order (bottom-up by dependency)
|
|
286
|
+
|
|
287
|
+
### 1. send/tool-progress.ts — Pure formatting (8 tests)
|
|
288
|
+
|
|
289
|
+
Zero dependencies. Pure function.
|
|
290
|
+
|
|
291
|
+
**Tests:**
|
|
292
|
+
1. Returns null for non-tool part (type: "text")
|
|
293
|
+
2. Returns running status for tool with status "running" and title
|
|
294
|
+
3. Returns pending status for tool with status "pending"
|
|
295
|
+
4. Returns null for completed tool (no suffix needed)
|
|
296
|
+
5. Returns null for error tool (no suffix needed)
|
|
297
|
+
6. Running status includes tool name
|
|
298
|
+
7. Running status includes title text
|
|
299
|
+
8. Returns null when part.state is undefined
|
|
300
|
+
|
|
301
|
+
### 2. send/draft-stream.ts — Core streaming class (18 tests)
|
|
302
|
+
|
|
303
|
+
Depends on: DraftStreamDeps (injected mocks).
|
|
304
|
+
|
|
305
|
+
**Tests:**
|
|
306
|
+
|
|
307
|
+
*Initial send:*
|
|
308
|
+
1. First update() sends a new message via sendMessage
|
|
309
|
+
2. First update() stores messageId from sendMessage response
|
|
310
|
+
3. First update() truncates text to 4096 chars
|
|
311
|
+
4. First update() sends HTML formatted text
|
|
312
|
+
|
|
313
|
+
*Throttled edits:*
|
|
314
|
+
5. Second update() schedules an edit (not immediate)
|
|
315
|
+
6. After throttle delay, editMessageText is called with updated text
|
|
316
|
+
7. Multiple rapid updates only result in one edit (latest text wins)
|
|
317
|
+
8. Edit uses HTML parse_mode
|
|
318
|
+
|
|
319
|
+
*Error handling:*
|
|
320
|
+
9. "message is not modified" error is silently ignored
|
|
321
|
+
10. "can't parse entities" falls back to plain text edit
|
|
322
|
+
11. After HTML failure, subsequent edits use plain text
|
|
323
|
+
12. "message to edit not found" stops the stream
|
|
324
|
+
13. sendMessage failure on first update → messageId stays null
|
|
325
|
+
|
|
326
|
+
*stop():*
|
|
327
|
+
14. stop() clears pending timer
|
|
328
|
+
15. After stop(), update() is a no-op
|
|
329
|
+
16. stop() sets isStopped() to true
|
|
330
|
+
|
|
331
|
+
*AbortSignal:*
|
|
332
|
+
17. Aborting signal calls stop() automatically
|
|
333
|
+
18. Already-aborted signal stops immediately on construction
|
|
334
|
+
|
|
335
|
+
### 3. Modified: turn-manager.ts — New fields (3 new tests)
|
|
336
|
+
|
|
337
|
+
**Changes:**
|
|
338
|
+
- Add `toolSuffix: string` (default `""`) and `draft: DraftStream | null` (default `null`)
|
|
339
|
+
- `end()` calls `turn.draft?.stop()` before abort
|
|
340
|
+
|
|
341
|
+
**New tests:**
|
|
342
|
+
1. New turn has toolSuffix="" and draft=null
|
|
343
|
+
2. Draft can be set on turn and accessed
|
|
344
|
+
3. end() calls draft.stop() if draft exists
|
|
345
|
+
|
|
346
|
+
### 4. Modified: bot.ts — Create DraftStream in Grammy handler
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
bot.on("message:text", async (ctx) => {
|
|
350
|
+
const chatId = ctx.chat.id
|
|
351
|
+
const text = ctx.message.text.trim()
|
|
352
|
+
if (!text) return
|
|
353
|
+
|
|
354
|
+
const { turn } = await handleMessage({ chatId, text, sdk, sessionManager, turnManager })
|
|
355
|
+
|
|
356
|
+
// Start streaming draft
|
|
357
|
+
turn.draft = new DraftStream(
|
|
358
|
+
{
|
|
359
|
+
sendMessage: (id, t, o) => bot.api.sendMessage(id, t, o),
|
|
360
|
+
editMessageText: (id, m, t, o) => bot.api.editMessageText(id, m, t, o),
|
|
361
|
+
},
|
|
362
|
+
chatId,
|
|
363
|
+
turn.abortController.signal,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
startTypingLoop(chatId, (id, action) => bot.api.sendChatAction(id, action), turn.abortController.signal)
|
|
367
|
+
})
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### 5. Modified: index.ts — Wire draft updates, tool progress, finalization
|
|
371
|
+
|
|
372
|
+
**Changes to `onEvent` handler:**
|
|
373
|
+
- `message.part.updated` type "text": also call `turn.draft?.update(part.text)`
|
|
374
|
+
- `message.part.updated` type "tool": call `formatToolStatus(part)`, update draft
|
|
375
|
+
- `session.idle`: call `finalizeResponse()` instead of `sendFormattedResponse()`
|
|
376
|
+
|
|
377
|
+
### 6. Validate: `bun test` → all unit green (~130+ tests)
|
|
378
|
+
|
|
379
|
+
### 7. E2E: phase-3.test.ts → real validation
|
|
380
|
+
|
|
381
|
+
## E2E Tests
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
describe("Phase 3 — Streaming + UX", () => {
|
|
385
|
+
test("response streams via message edits (text grows)", async () => {
|
|
386
|
+
const client = getClient()
|
|
387
|
+
const bot = getBotUsername()
|
|
388
|
+
|
|
389
|
+
await client.sendMessage(bot, { message: "Explain what TypeScript is in 3 paragraphs" })
|
|
390
|
+
|
|
391
|
+
// Wait for initial draft
|
|
392
|
+
await sleep(3000)
|
|
393
|
+
const messages1 = await client.getMessages(bot, { limit: 1 })
|
|
394
|
+
const msg1 = messages1[0]
|
|
395
|
+
const text1 = msg1.text ?? msg1.message ?? ""
|
|
396
|
+
|
|
397
|
+
// Wait for more streaming
|
|
398
|
+
await sleep(5000)
|
|
399
|
+
const messages2 = await client.getMessages(bot, { ids: [msg1.id] })
|
|
400
|
+
const msg2 = messages2[0]
|
|
401
|
+
const text2 = msg2.text ?? msg2.message ?? ""
|
|
402
|
+
|
|
403
|
+
// Same message ID (edit, not new message), text grew or stayed
|
|
404
|
+
expect(msg2.id).toBe(msg1.id)
|
|
405
|
+
expect(text2.length).toBeGreaterThanOrEqual(text1.length)
|
|
406
|
+
}, 60000)
|
|
407
|
+
|
|
408
|
+
test("regression: /start still works", async () => {
|
|
409
|
+
const reply = await sendAndWait(getClient(), getBotUsername(), "/start")
|
|
410
|
+
assertContains(reply, "OpenCode Telegram Bot")
|
|
411
|
+
}, 20000)
|
|
412
|
+
|
|
413
|
+
test("regression: text message gets AI response", async () => {
|
|
414
|
+
const reply = await sendAndWait(
|
|
415
|
+
getClient(), getBotUsername(),
|
|
416
|
+
"Say exactly the word hello and nothing else",
|
|
417
|
+
60000,
|
|
418
|
+
)
|
|
419
|
+
assertContains(reply, /hello/i)
|
|
420
|
+
}, 90000)
|
|
421
|
+
})
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**Note:** E2E streaming test is inherently timing-dependent. The test verifies core behavior
|
|
425
|
+
(message is edited in-place) but does not assert strict intermediate growth, since fast AI
|
|
426
|
+
responses may complete before the second poll.
|
|
427
|
+
|
|
428
|
+
## Edge Cases
|
|
429
|
+
|
|
430
|
+
### Race: session.idle before first text part
|
|
431
|
+
- `turn.accumulatedText` will be empty → `finalizeResponse` returns early
|
|
432
|
+
- No draft was sent, no message to clean up
|
|
433
|
+
|
|
434
|
+
### Race: DraftStream.update() and DraftStream.stop()
|
|
435
|
+
- `stop()` sets `stopped = true` and clears timer
|
|
436
|
+
- `flush()` mid-flight checks `stopped` before editing
|
|
437
|
+
- `update()` after `stop()` returns early
|
|
438
|
+
|
|
439
|
+
### Race: Multiple rapid text updates
|
|
440
|
+
- Each `update()` sets `pending` to latest text (latest-wins)
|
|
441
|
+
- Timer is set only once, `flush()` uses latest `pending`
|
|
442
|
+
|
|
443
|
+
### Text exceeds 4096 during streaming
|
|
444
|
+
- Draft truncated to 4096 chars for display
|
|
445
|
+
- Full text in `turn.accumulatedText` for final send
|
|
446
|
+
- On `session.idle`, `finalizeResponse` chunks properly
|
|
447
|
+
|
|
448
|
+
### User deletes the draft message
|
|
449
|
+
- `editMessageText` fails → DraftStream stops
|
|
450
|
+
- `finalizeResponse` tries edit, same error, falls back to `sendFormattedResponse`
|
|
451
|
+
|
|
452
|
+
### Tool progress after text
|
|
453
|
+
- Tool suffix appended to display only, not stored in `accumulatedText`
|
|
454
|
+
- Next text part clears `toolSuffix`
|
|
455
|
+
|
|
456
|
+
### HTML parse error during streaming
|
|
457
|
+
- DraftStream switches to plain text (`htmlFailed = true`)
|
|
458
|
+
- Finalization tries HTML one more time on complete text
|
|
459
|
+
|
|
460
|
+
### Turn cancelled (/cancel) during streaming
|
|
461
|
+
- AbortSignal fires → DraftStream.stop() auto-called
|
|
462
|
+
- Draft message stays in chat (partially streamed)
|
|
463
|
+
- No cleanup of draft message (user sees what was generated)
|
|
464
|
+
|
|
465
|
+
## Acceptance Criteria
|
|
466
|
+
|
|
467
|
+
- [ ] Response streams via message edits (text grows over time in same message)
|
|
468
|
+
- [ ] Tool call progress shown as status line in draft
|
|
469
|
+
- [ ] Final response properly formatted after stream ends (HTML)
|
|
470
|
+
- [ ] Long streamed responses get chunked correctly on finalize
|
|
471
|
+
- [ ] Streaming respects 400ms throttle between edits
|
|
472
|
+
- [ ] "message is not modified" errors handled silently
|
|
473
|
+
- [ ] "can't parse entities" falls back to plain text
|
|
474
|
+
- [ ] "message to edit not found" stops streaming gracefully
|
|
475
|
+
- [ ] AbortSignal stops draft stream automatically
|
|
476
|
+
- [ ] No memory leaks (timers cleared, no dangling references)
|
|
477
|
+
- [ ] `bun test` passes (all unit tests, including regression)
|
|
478
|
+
- [ ] `bun test ./e2e/phase-3.test.ts` passes
|
|
479
|
+
- [ ] All Phase 0-2 E2E tests still pass (regression)
|
|
480
|
+
|
|
481
|
+
## Estimated Scope
|
|
482
|
+
|
|
483
|
+
- 2 new source files + 2 test files + 1 E2E test file
|
|
484
|
+
- ~250-300 LOC (src) + ~350-400 LOC (tests)
|
|
485
|
+
- Modified: turn-manager.ts, turn-manager.test.ts, bot.ts, index.ts
|