@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.
Files changed (43) hide show
  1. package/.env.example +17 -0
  2. package/bunfig.toml +2 -0
  3. package/docs/PROGRESS.md +327 -0
  4. package/docs/mapping.md +326 -0
  5. package/docs/plans/phase-1.md +176 -0
  6. package/docs/plans/phase-2.md +235 -0
  7. package/docs/plans/phase-3.md +485 -0
  8. package/docs/plans/phase-4.md +566 -0
  9. package/docs/spec.md +2055 -0
  10. package/e2e/client.ts +24 -0
  11. package/e2e/helpers.ts +119 -0
  12. package/e2e/phase-0.test.ts +30 -0
  13. package/e2e/phase-1.test.ts +48 -0
  14. package/e2e/phase-2.test.ts +54 -0
  15. package/e2e/phase-3.test.ts +142 -0
  16. package/e2e/phase-4.test.ts +96 -0
  17. package/e2e/runner.ts +145 -0
  18. package/package.json +14 -12
  19. package/scripts/gen-session.ts +49 -0
  20. package/src/bot.test.ts +301 -0
  21. package/src/bot.ts +91 -0
  22. package/src/config.test.ts +130 -0
  23. package/src/config.ts +15 -0
  24. package/src/event-bus.test.ts +175 -0
  25. package/src/handlers/allowlist.test.ts +60 -0
  26. package/src/handlers/allowlist.ts +33 -0
  27. package/src/handlers/cancel.test.ts +105 -0
  28. package/src/handlers/permissions.test.ts +72 -0
  29. package/src/handlers/questions.test.ts +107 -0
  30. package/src/handlers/sessions.test.ts +479 -0
  31. package/src/handlers/sessions.ts +202 -0
  32. package/src/handlers/typing.test.ts +60 -0
  33. package/src/index.ts +26 -0
  34. package/src/pending-requests.test.ts +64 -0
  35. package/src/send/chunker.test.ts +74 -0
  36. package/src/send/draft-stream.test.ts +229 -0
  37. package/src/send/format.test.ts +143 -0
  38. package/src/send/tool-progress.test.ts +70 -0
  39. package/src/session-manager.test.ts +198 -0
  40. package/src/session-manager.ts +23 -0
  41. package/src/turn-manager.test.ts +155 -0
  42. package/src/turn-manager.ts +5 -0
  43. package/tsconfig.json +9 -0
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect, mock } from "bun:test"
2
+ import { startTypingLoop } from "./typing"
3
+
4
+ describe("startTypingLoop", () => {
5
+ test("calls sendAction immediately on start", () => {
6
+ const sendAction = mock(async () => {})
7
+ const ac = new AbortController()
8
+
9
+ startTypingLoop(123, sendAction, ac.signal)
10
+ expect(sendAction).toHaveBeenCalledTimes(1)
11
+ expect(sendAction).toHaveBeenCalledWith(123, "typing")
12
+
13
+ ac.abort()
14
+ })
15
+
16
+ test("calls sendAction again after ~4 seconds", async () => {
17
+ const sendAction = mock(async () => {})
18
+ const ac = new AbortController()
19
+
20
+ startTypingLoop(123, sendAction, ac.signal)
21
+ expect(sendAction).toHaveBeenCalledTimes(1)
22
+
23
+ // Wait a bit over 4 seconds for the next tick
24
+ await new Promise((r) => setTimeout(r, 4200))
25
+
26
+ expect(sendAction.mock.calls.length).toBeGreaterThanOrEqual(2)
27
+ ac.abort()
28
+ })
29
+
30
+ test("stops calling when signal is aborted", async () => {
31
+ const sendAction = mock(async () => {})
32
+ const ac = new AbortController()
33
+
34
+ startTypingLoop(123, sendAction, ac.signal)
35
+ expect(sendAction).toHaveBeenCalledTimes(1)
36
+
37
+ ac.abort()
38
+
39
+ // Wait long enough for another tick to fire (if it weren't aborted)
40
+ await new Promise((r) => setTimeout(r, 4500))
41
+
42
+ // Should still be 1 (no more calls after abort)
43
+ expect(sendAction).toHaveBeenCalledTimes(1)
44
+ }, 10000)
45
+
46
+ test("does not throw if sendAction rejects", async () => {
47
+ const sendAction = mock(async () => {
48
+ throw new Error("network error")
49
+ })
50
+ const ac = new AbortController()
51
+
52
+ // Should not throw
53
+ startTypingLoop(123, sendAction, ac.signal)
54
+ expect(sendAction).toHaveBeenCalledTimes(1)
55
+
56
+ // Wait for next tick to confirm no unhandled rejection
57
+ await new Promise((r) => setTimeout(r, 4500))
58
+ ac.abort()
59
+ })
60
+ })
package/src/index.ts CHANGED
@@ -52,6 +52,14 @@ const pendingRequests = new PendingRequests({
52
52
  ttlMs: 10 * 60 * 1000, // 10 minutes
53
53
  })
54
54
 
55
+ // --- Restore sessions from previous bot runs ---
56
+ try {
57
+ const restored = await sessionManager.restore(sdk)
58
+ if (restored > 0) console.log(`Restored ${restored} sessions`)
59
+ } catch (err) {
60
+ console.error("Session restore failed:", err)
61
+ }
62
+
55
63
  // --- Create bot with deps ---
56
64
  const bot = createBot(config, { sdk, sessionManager, turnManager, pendingRequests })
57
65
 
@@ -249,6 +257,24 @@ const shutdown = async () => {
249
257
  process.on("SIGINT", shutdown)
250
258
  process.on("SIGTERM", shutdown)
251
259
 
260
+ // --- Register Telegram command menu ---
261
+ try {
262
+ await bot.api.setMyCommands([
263
+ { command: "start", description: "Welcome message" },
264
+ { command: "new", description: "New session" },
265
+ { command: "cancel", description: "Stop generation" },
266
+ { command: "list", description: "List sessions" },
267
+ { command: "rename", description: "Rename session" },
268
+ { command: "delete", description: "Delete session" },
269
+ { command: "info", description: "Session info" },
270
+ { command: "history", description: "Recent messages" },
271
+ { command: "summarize", description: "Summarize session" },
272
+ ])
273
+ console.log("Command menu registered")
274
+ } catch (err) {
275
+ console.error("Failed to register commands:", err)
276
+ }
277
+
252
278
  // --- Start polling ---
253
279
  await bot.start({
254
280
  onStart: () => console.log("Bot started"),
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test"
2
+ import { PendingRequests, type PendingEntry } from "./pending-requests"
3
+
4
+ describe("PendingRequests", () => {
5
+ let pr: PendingRequests
6
+
7
+ beforeEach(() => {
8
+ pr = new PendingRequests({ maxEntries: 5, ttlMs: 1000 })
9
+ })
10
+
11
+ test("set() stores and get() retrieves", () => {
12
+ const entry: PendingEntry = { type: "permission", createdAt: Date.now() }
13
+ pr.set("req1", entry)
14
+ expect(pr.get("req1")).toEqual(entry)
15
+ })
16
+
17
+ test("get() returns undefined for unknown requestID", () => {
18
+ expect(pr.get("unknown")).toBeUndefined()
19
+ })
20
+
21
+ test("delete() removes entry and returns true", () => {
22
+ pr.set("req1", { type: "permission", createdAt: Date.now() })
23
+ expect(pr.delete("req1")).toBe(true)
24
+ expect(pr.get("req1")).toBeUndefined()
25
+ })
26
+
27
+ test("delete() returns false for unknown requestID", () => {
28
+ expect(pr.delete("unknown")).toBe(false)
29
+ })
30
+
31
+ test("evicts oldest when maxEntries exceeded", () => {
32
+ for (let i = 1; i <= 6; i++) {
33
+ pr.set(`req${i}`, { type: "permission", createdAt: Date.now() })
34
+ }
35
+ // req1 was evicted (oldest)
36
+ expect(pr.get("req1")).toBeUndefined()
37
+ // req6 still exists
38
+ expect(pr.get("req6")).toBeDefined()
39
+ expect(pr.size).toBe(5)
40
+ })
41
+
42
+ test("get() returns undefined for expired entries", async () => {
43
+ pr.set("req1", { type: "permission", createdAt: Date.now() })
44
+ expect(pr.get("req1")).toBeDefined()
45
+
46
+ // Wait for TTL to expire
47
+ await new Promise((r) => setTimeout(r, 1100))
48
+ expect(pr.get("req1")).toBeUndefined()
49
+ })
50
+
51
+ test("cleanup() removes all expired entries", async () => {
52
+ pr.set("req1", { type: "permission", createdAt: Date.now() })
53
+ pr.set("req2", { type: "question", createdAt: Date.now() })
54
+
55
+ await new Promise((r) => setTimeout(r, 1100))
56
+
57
+ // Add a fresh one
58
+ pr.set("req3", { type: "permission", createdAt: Date.now() })
59
+
60
+ pr.cleanup()
61
+ expect(pr.size).toBe(1)
62
+ expect(pr.get("req3")).toBeDefined()
63
+ })
64
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { chunkMessage } from "./chunker"
3
+
4
+ describe("chunkMessage", () => {
5
+ test("returns empty array for empty string", () => {
6
+ expect(chunkMessage("")).toEqual([])
7
+ })
8
+
9
+ test("returns single chunk for short message", () => {
10
+ const chunks = chunkMessage("hello world")
11
+ expect(chunks).toEqual(["hello world"])
12
+ })
13
+
14
+ test("returns single chunk at exactly the limit", () => {
15
+ const text = "a".repeat(4096)
16
+ const chunks = chunkMessage(text)
17
+ expect(chunks).toHaveLength(1)
18
+ expect(chunks[0]).toBe(text)
19
+ })
20
+
21
+ test("splits long message into multiple chunks", () => {
22
+ const text = "a".repeat(8192)
23
+ const chunks = chunkMessage(text)
24
+ expect(chunks.length).toBeGreaterThanOrEqual(2)
25
+ expect(chunks.join("")).toBe(text)
26
+ for (const chunk of chunks) {
27
+ expect(chunk.length).toBeLessThanOrEqual(4096)
28
+ }
29
+ })
30
+
31
+ test("splits at newline boundary when possible", () => {
32
+ // Create text where a newline appears near the split point
33
+ const line = "x".repeat(100) + "\n"
34
+ const text = line.repeat(50) // 50 * 101 = 5050 chars
35
+ const chunks = chunkMessage(text)
36
+ expect(chunks.length).toBeGreaterThanOrEqual(2)
37
+ // Each chunk should end at a newline (except possibly the last)
38
+ for (let i = 0; i < chunks.length - 1; i++) {
39
+ expect(chunks[i].endsWith("\n")).toBe(true)
40
+ }
41
+ })
42
+
43
+ test("never splits inside an HTML tag", () => {
44
+ // Create text with an HTML tag right at the split boundary
45
+ const before = "a".repeat(4090)
46
+ const tag = '<b>bold</b>'
47
+ const after = "b".repeat(100)
48
+ const text = before + tag + after
49
+ const chunks = chunkMessage(text)
50
+ // The tag should be entirely in one chunk
51
+ const fullText = chunks.join("")
52
+ expect(fullText).toBe(text)
53
+ for (const chunk of chunks) {
54
+ // No chunk should contain a partial tag (< without matching >)
55
+ const opens = (chunk.match(/</g) || []).length
56
+ const closes = (chunk.match(/>/g) || []).length
57
+ expect(opens).toBe(closes)
58
+ }
59
+ })
60
+
61
+ test("handles custom limit", () => {
62
+ const text = "a".repeat(200)
63
+ const chunks = chunkMessage(text, 100)
64
+ expect(chunks).toHaveLength(2)
65
+ expect(chunks[0].length).toBe(100)
66
+ expect(chunks[1].length).toBe(100)
67
+ })
68
+
69
+ test("preserves all content when chunking", () => {
70
+ const text = "Hello <b>bold</b> and <code>code</code> text ".repeat(200)
71
+ const chunks = chunkMessage(text)
72
+ expect(chunks.join("")).toBe(text)
73
+ })
74
+ })
@@ -0,0 +1,229 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test"
2
+ import { DraftStream, type DraftStreamDeps } from "./draft-stream"
3
+
4
+ function createMockDeps(): DraftStreamDeps & {
5
+ sendMessage: ReturnType<typeof mock>
6
+ editMessageText: ReturnType<typeof mock>
7
+ } {
8
+ return {
9
+ sendMessage: mock(async (_chatId: number, _text: string, _opts?: any) => ({
10
+ message_id: 42,
11
+ })),
12
+ editMessageText: mock(async (_chatId: number, _msgId: number, _text: string, _opts?: any) => {}),
13
+ }
14
+ }
15
+
16
+ function sleep(ms: number): Promise<void> {
17
+ return new Promise((r) => setTimeout(r, ms))
18
+ }
19
+
20
+ describe("DraftStream", () => {
21
+ let deps: ReturnType<typeof createMockDeps>
22
+ let ac: AbortController
23
+
24
+ beforeEach(() => {
25
+ deps = createMockDeps()
26
+ ac = new AbortController()
27
+ })
28
+
29
+ // --- Initial send ---
30
+
31
+ test("first update sends a new message via sendMessage", async () => {
32
+ const ds = new DraftStream(deps, 123, ac.signal)
33
+ await ds.update("hello")
34
+ expect(deps.sendMessage).toHaveBeenCalledTimes(1)
35
+ ac.abort()
36
+ })
37
+
38
+ test("first update stores messageId from sendMessage response", async () => {
39
+ const ds = new DraftStream(deps, 123, ac.signal)
40
+ expect(ds.getMessageId()).toBeNull()
41
+ await ds.update("hello")
42
+ expect(ds.getMessageId()).toBe(42)
43
+ ac.abort()
44
+ })
45
+
46
+ test("first update truncates text to 4096 chars", async () => {
47
+ const ds = new DraftStream(deps, 123, ac.signal)
48
+ const longText = "x".repeat(5000)
49
+ await ds.update(longText)
50
+ // The text sent should be truncated
51
+ const sentText = deps.sendMessage.mock.calls[0][1] as string
52
+ expect(sentText.length).toBeLessThanOrEqual(4096)
53
+ ac.abort()
54
+ })
55
+
56
+ test("first update sends with HTML parse_mode", async () => {
57
+ const ds = new DraftStream(deps, 123, ac.signal)
58
+ await ds.update("hello **bold**")
59
+ const opts = deps.sendMessage.mock.calls[0][2]
60
+ expect(opts?.parse_mode).toBe("HTML")
61
+ ac.abort()
62
+ })
63
+
64
+ // --- Throttled edits ---
65
+
66
+ test("second update schedules an edit (not immediate)", async () => {
67
+ const ds = new DraftStream(deps, 123, ac.signal, 100)
68
+ await ds.update("hello")
69
+ await ds.update("hello world")
70
+ // Edit not called yet (still in throttle window)
71
+ expect(deps.editMessageText).toHaveBeenCalledTimes(0)
72
+ ac.abort()
73
+ })
74
+
75
+ test("after throttle delay, editMessageText is called", async () => {
76
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
77
+ await ds.update("hello")
78
+ await ds.update("hello world")
79
+ await sleep(100)
80
+ expect(deps.editMessageText).toHaveBeenCalledTimes(1)
81
+ ac.abort()
82
+ })
83
+
84
+ test("multiple rapid updates only result in one edit (latest text wins)", async () => {
85
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
86
+ await ds.update("v1")
87
+ await ds.update("v2")
88
+ await ds.update("v3")
89
+ await ds.update("v4")
90
+ await sleep(100)
91
+ expect(deps.editMessageText).toHaveBeenCalledTimes(1)
92
+ // The edit should contain the latest text (v4), converted to HTML
93
+ const editedText = deps.editMessageText.mock.calls[0][2] as string
94
+ expect(editedText).toContain("v4")
95
+ ac.abort()
96
+ })
97
+
98
+ test("edit uses HTML parse_mode", async () => {
99
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
100
+ await ds.update("hello")
101
+ await ds.update("updated")
102
+ await sleep(100)
103
+ const opts = deps.editMessageText.mock.calls[0][3]
104
+ expect(opts?.parse_mode).toBe("HTML")
105
+ ac.abort()
106
+ })
107
+
108
+ // --- Error handling ---
109
+
110
+ test("message is not modified error is silently ignored", async () => {
111
+ deps.editMessageText = mock(async () => {
112
+ throw new Error("Bad Request: message is not modified")
113
+ })
114
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
115
+ await ds.update("hello")
116
+ await ds.update("hello") // same text but triggers edit
117
+ // Force different pending to trigger edit
118
+ await ds.update("different")
119
+ await sleep(100)
120
+ // Should not throw, stream still active
121
+ expect(ds.isStopped()).toBe(false)
122
+ ac.abort()
123
+ })
124
+
125
+ test("can't parse entities falls back to plain text edit", async () => {
126
+ let callCount = 0
127
+ deps.editMessageText = mock(async (_cid: number, _mid: number, _text: string, opts?: any) => {
128
+ callCount++
129
+ if (callCount === 1 && opts?.parse_mode === "HTML") {
130
+ throw new Error("Bad Request: can't parse entities")
131
+ }
132
+ })
133
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
134
+ await ds.update("hello")
135
+ await ds.update("**broken html")
136
+ await sleep(100)
137
+ // Should have retried without parse_mode
138
+ expect(deps.editMessageText.mock.calls.length).toBeGreaterThanOrEqual(2)
139
+ expect(ds.hasHtmlFailed()).toBe(true)
140
+ ac.abort()
141
+ })
142
+
143
+ test("after HTML failure, subsequent edits use plain text", async () => {
144
+ let callCount = 0
145
+ deps.editMessageText = mock(async (_cid: number, _mid: number, _text: string, opts?: any) => {
146
+ callCount++
147
+ if (callCount === 1 && opts?.parse_mode === "HTML") {
148
+ throw new Error("Bad Request: can't parse entities")
149
+ }
150
+ })
151
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
152
+ await ds.update("hello")
153
+ await ds.update("update1")
154
+ await sleep(100)
155
+ expect(ds.hasHtmlFailed()).toBe(true)
156
+
157
+ // Next edit should not use HTML
158
+ await ds.update("update2")
159
+ await sleep(100)
160
+ const lastCall = deps.editMessageText.mock.calls[deps.editMessageText.mock.calls.length - 1]
161
+ const lastOpts = lastCall[3]
162
+ expect(lastOpts?.parse_mode).toBeUndefined()
163
+ ac.abort()
164
+ })
165
+
166
+ test("message to edit not found stops the stream", async () => {
167
+ deps.editMessageText = mock(async () => {
168
+ throw new Error("Bad Request: message to edit not found")
169
+ })
170
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
171
+ await ds.update("hello")
172
+ await ds.update("updated")
173
+ await sleep(100)
174
+ expect(ds.isStopped()).toBe(true)
175
+ ac.abort()
176
+ })
177
+
178
+ test("sendMessage failure on first update keeps messageId null", async () => {
179
+ deps.sendMessage = mock(async () => {
180
+ throw new Error("network error")
181
+ })
182
+ const ds = new DraftStream(deps, 123, ac.signal)
183
+ await ds.update("hello")
184
+ expect(ds.getMessageId()).toBeNull()
185
+ ac.abort()
186
+ })
187
+
188
+ // --- stop() ---
189
+
190
+ test("stop clears pending timer", async () => {
191
+ const ds = new DraftStream(deps, 123, ac.signal, 200)
192
+ await ds.update("hello")
193
+ await ds.update("updated")
194
+ ds.stop()
195
+ await sleep(250)
196
+ // Edit should not have fired
197
+ expect(deps.editMessageText).toHaveBeenCalledTimes(0)
198
+ })
199
+
200
+ test("after stop, update is a no-op", async () => {
201
+ const ds = new DraftStream(deps, 123, ac.signal)
202
+ ds.stop()
203
+ await ds.update("hello")
204
+ expect(deps.sendMessage).toHaveBeenCalledTimes(0)
205
+ })
206
+
207
+ test("stop sets isStopped to true", () => {
208
+ const ds = new DraftStream(deps, 123, ac.signal)
209
+ expect(ds.isStopped()).toBe(false)
210
+ ds.stop()
211
+ expect(ds.isStopped()).toBe(true)
212
+ })
213
+
214
+ // --- AbortSignal ---
215
+
216
+ test("aborting signal calls stop automatically", async () => {
217
+ const ds = new DraftStream(deps, 123, ac.signal)
218
+ await ds.update("hello")
219
+ expect(ds.isStopped()).toBe(false)
220
+ ac.abort()
221
+ expect(ds.isStopped()).toBe(true)
222
+ })
223
+
224
+ test("already-aborted signal stops immediately on construction", () => {
225
+ ac.abort()
226
+ const ds = new DraftStream(deps, 123, ac.signal)
227
+ expect(ds.isStopped()).toBe(true)
228
+ })
229
+ })
@@ -0,0 +1,143 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { markdownToTelegramHtml } from "./format"
3
+
4
+ describe("markdownToTelegramHtml", () => {
5
+ // --- HTML escaping (must happen FIRST, before any tag insertion) ---
6
+
7
+ test("escapes & < > in plain text", () => {
8
+ expect(markdownToTelegramHtml("A & B < C > D")).toBe(
9
+ "A &amp; B &lt; C &gt; D",
10
+ )
11
+ })
12
+
13
+ test("does not double-escape already-escaped HTML", () => {
14
+ // Input is markdown, not HTML — so "&amp;" in markdown is literal text
15
+ // and should be escaped to "&amp;amp;"
16
+ expect(markdownToTelegramHtml("&amp;")).toBe("&amp;amp;")
17
+ })
18
+
19
+ // --- Bold ---
20
+
21
+ test("converts **bold** to <b>", () => {
22
+ expect(markdownToTelegramHtml("**bold**")).toBe("<b>bold</b>")
23
+ })
24
+
25
+ test("converts __bold__ to <b>", () => {
26
+ expect(markdownToTelegramHtml("__bold__")).toBe("<b>bold</b>")
27
+ })
28
+
29
+ // --- Italic ---
30
+
31
+ test("converts *italic* to <i>", () => {
32
+ expect(markdownToTelegramHtml("*italic*")).toBe("<i>italic</i>")
33
+ })
34
+
35
+ test("converts _italic_ to <i>", () => {
36
+ expect(markdownToTelegramHtml("_italic_")).toBe("<i>italic</i>")
37
+ })
38
+
39
+ // --- Strikethrough ---
40
+
41
+ test("converts ~~strike~~ to <s>", () => {
42
+ expect(markdownToTelegramHtml("~~strike~~")).toBe("<s>strike</s>")
43
+ })
44
+
45
+ // --- Inline code ---
46
+
47
+ test("converts `code` to <code>", () => {
48
+ expect(markdownToTelegramHtml("`code`")).toBe("<code>code</code>")
49
+ })
50
+
51
+ test("escapes HTML inside inline code", () => {
52
+ expect(markdownToTelegramHtml("`<div>&</div>`")).toBe(
53
+ "<code>&lt;div&gt;&amp;&lt;/div&gt;</code>",
54
+ )
55
+ })
56
+
57
+ // --- Code blocks ---
58
+
59
+ test("converts fenced code block without language", () => {
60
+ expect(markdownToTelegramHtml("```\ncode\n```")).toBe(
61
+ "<pre><code>code</code></pre>",
62
+ )
63
+ })
64
+
65
+ test("converts fenced code block with language", () => {
66
+ expect(markdownToTelegramHtml("```ts\nconst x = 1\n```")).toBe(
67
+ '<pre><code class="language-ts">const x = 1</code></pre>',
68
+ )
69
+ })
70
+
71
+ test("escapes HTML inside code blocks", () => {
72
+ expect(markdownToTelegramHtml("```\n<div>&</div>\n```")).toBe(
73
+ "<pre><code>&lt;div&gt;&amp;&lt;/div&gt;</code></pre>",
74
+ )
75
+ })
76
+
77
+ test("preserves newlines inside code blocks", () => {
78
+ expect(markdownToTelegramHtml("```\nline1\nline2\n```")).toBe(
79
+ "<pre><code>line1\nline2</code></pre>",
80
+ )
81
+ })
82
+
83
+ // --- Links ---
84
+
85
+ test("converts [text](url) to <a>", () => {
86
+ expect(markdownToTelegramHtml("[click](https://example.com)")).toBe(
87
+ '<a href="https://example.com">click</a>',
88
+ )
89
+ })
90
+
91
+ test("escapes HTML in link text", () => {
92
+ expect(markdownToTelegramHtml("[A & B](https://example.com)")).toBe(
93
+ '<a href="https://example.com">A &amp; B</a>',
94
+ )
95
+ })
96
+
97
+ // --- Nested formatting ---
98
+
99
+ test("handles bold + italic nested", () => {
100
+ const result = markdownToTelegramHtml("**bold *italic***")
101
+ expect(result).toContain("<b>")
102
+ expect(result).toContain("<i>")
103
+ expect(result).toContain("</b>")
104
+ })
105
+
106
+ // --- Edge cases ---
107
+
108
+ test("returns empty string for empty input", () => {
109
+ expect(markdownToTelegramHtml("")).toBe("")
110
+ })
111
+
112
+ test("passes through plain text unchanged (after escaping)", () => {
113
+ expect(markdownToTelegramHtml("hello world")).toBe("hello world")
114
+ })
115
+
116
+ test("handles multiple paragraphs", () => {
117
+ const result = markdownToTelegramHtml("paragraph 1\n\nparagraph 2")
118
+ expect(result).toContain("paragraph 1")
119
+ expect(result).toContain("paragraph 2")
120
+ })
121
+
122
+ // --- Headings (convert to bold since Telegram has no heading tags) ---
123
+
124
+ test("converts headings to bold text", () => {
125
+ const result = markdownToTelegramHtml("# Heading")
126
+ expect(result).toContain("<b>Heading</b>")
127
+ })
128
+
129
+ // --- Lists ---
130
+
131
+ test("preserves bullet list formatting", () => {
132
+ const result = markdownToTelegramHtml("- item 1\n- item 2")
133
+ expect(result).toContain("item 1")
134
+ expect(result).toContain("item 2")
135
+ })
136
+
137
+ // --- Blockquotes ---
138
+
139
+ test("converts blockquotes", () => {
140
+ const result = markdownToTelegramHtml("> quoted text")
141
+ expect(result).toContain("quoted text")
142
+ })
143
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { formatToolStatus } from "./tool-progress"
3
+
4
+ describe("formatToolStatus", () => {
5
+ test("returns null for non-tool part", () => {
6
+ expect(formatToolStatus({ type: "text", text: "hello" })).toBeNull()
7
+ })
8
+
9
+ test("returns running status for tool with status running and title", () => {
10
+ const result = formatToolStatus({
11
+ type: "tool",
12
+ tool: "bash",
13
+ state: { status: "running", title: "ls -la", time: { start: 1 } },
14
+ })
15
+ expect(result).not.toBeNull()
16
+ expect(result).toContain("Running")
17
+ })
18
+
19
+ test("returns pending status for tool with status pending", () => {
20
+ const result = formatToolStatus({
21
+ type: "tool",
22
+ tool: "edit",
23
+ state: { status: "pending", input: {} },
24
+ })
25
+ expect(result).not.toBeNull()
26
+ expect(result).toContain("Preparing")
27
+ })
28
+
29
+ test("returns null for completed tool", () => {
30
+ expect(
31
+ formatToolStatus({
32
+ type: "tool",
33
+ tool: "bash",
34
+ state: { status: "completed", input: {}, output: "", title: "done", time: { start: 1, end: 2 } },
35
+ }),
36
+ ).toBeNull()
37
+ })
38
+
39
+ test("returns null for error tool", () => {
40
+ expect(
41
+ formatToolStatus({
42
+ type: "tool",
43
+ tool: "bash",
44
+ state: { status: "error", input: {}, error: "fail", time: { start: 1, end: 2 } },
45
+ }),
46
+ ).toBeNull()
47
+ })
48
+
49
+ test("running status includes tool name", () => {
50
+ const result = formatToolStatus({
51
+ type: "tool",
52
+ tool: "bash",
53
+ state: { status: "running", title: "npm test", time: { start: 1 } },
54
+ })
55
+ expect(result).toContain("bash")
56
+ })
57
+
58
+ test("running status includes title text", () => {
59
+ const result = formatToolStatus({
60
+ type: "tool",
61
+ tool: "bash",
62
+ state: { status: "running", title: "npm test", time: { start: 1 } },
63
+ })
64
+ expect(result).toContain("npm test")
65
+ })
66
+
67
+ test("returns null when part.state is undefined", () => {
68
+ expect(formatToolStatus({ type: "tool", tool: "bash" })).toBeNull()
69
+ })
70
+ })