alvin-bot 4.9.2 → 4.9.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.9.3] — 2026-04-11
6
+
7
+ ### 🛠 Two UX bugs found in production after v4.9.2 — now closed
8
+
9
+ Ali triggered `/cron run Daily Job Alert` after the v4.9.2 deploy and saw 13 minutes of chat silence followed by nothing. Forensics on the live bot revealed two distinct problems on top of an already-successful run:
10
+
11
+ **1. `subagent-delivery` has been silently dropping every banner for days.** Err.log: `GrammyError: Call to 'sendMessage' failed! (400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636)`. The daily-job-alert sub-agent produces markdown-dense output (`|` tables, `**bold**`, `\|` escapes, mixed asterisks). Telegram's Markdown parser refuses it, `api.sendMessage(..., parse_mode: "Markdown")` throws, and the bare try/catch in `deliverSubAgentResult` logs + bails. **Result: the user has never seen a sub-agent-delivery banner, even when the underlying run succeeded perfectly and emailed the HTML report correctly.**
12
+
13
+ Fix in `src/services/subagent-delivery.ts`: new `sendWithMarkdownFallback()` helper that detects the "can't parse entities" pattern and retries the SAME text without `parse_mode`. All three code paths (file-upload case, single-message case, chunked case) now flow through the helper. 3 new tests drive the happy path, non-parse errors, and the chunked path.
14
+
15
+ **2. `/cron run` had zero proof-of-life for 13 minutes.** The handler used to `await runJobNow(...)` synchronously and reply only when finished. Telegram's typing indicator expires after 5s. Users saw: command sent → typing indicator blip → nothing → nothing → (much later, if at all) result. For cron jobs that take 10-15 min (daily job alert, Perseus health, Polyseus P&L), this is indistinguishable from a dead bot.
16
+
17
+ Fix — new handler flow:
18
+
19
+ ```
20
+ bot: 🚀 Started *Daily Job Alert* — working… ← instant ack
21
+ bot: 🔄 Running *Daily Job Alert* · 1m 0s elapsed… ← edit every 60s
22
+ bot: 🔄 Running *Daily Job Alert* · 2m 0s elapsed… ← edit
23
+ ...
24
+ bot: ✅ Done — *Daily Job Alert* · 13m 17s ← final edit
25
+ bot: ✅ *Daily Job Alert* completed · 13m · 2.6M/28k ← subagent-delivery
26
+ [full report body, Markdown-safe with plain-text fallback]
27
+ ```
28
+
29
+ The ticker uses a single `editMessageText` call per minute on the same message — zero notification spam, clean visual progress. Every edit is wrapped with `isHarmlessTelegramError` so the inevitable "message is not modified" races stay silent. The ack itself falls back to plain text if the first `reply` hits a parse error, and the final edit falls back to a fresh plain message if the edit fails.
30
+
31
+ New module: `src/handlers/cron-progress.ts` with pure helpers — `formatElapsed`, `escapeMarkdown`, `buildTickerText`, `buildDoneText`. 8 tests cover the formatting rules and markdown-safety escapes so future cron jobs with weird names (`weird_job*name`) can't break the ticker.
32
+
33
+ **186 tests total** (+11 new). All green. Timeouts remain unlimited.
34
+
35
+ **What you see after this upgrade:**
36
+ - Instant "🚀 Started" ack on `/cron run`
37
+ - Live elapsed-time ticker every minute
38
+ - Final "✅ Done" when the sub-agent finishes
39
+ - A separate banner+body message with the full report — **this time actually delivered**, even when the body contains broken Markdown
40
+
5
41
  ## [4.9.2] — 2026-04-11
6
42
 
7
43
  ### 🔍 Post-review polish: three edge cases from the strict audit
@@ -16,6 +16,9 @@ import { getMCPStatus, getMCPTools, callMCPTool } from "../services/mcp.js";
16
16
  import { listCustomTools, executeCustomTool } from "../services/custom-tools.js";
17
17
  import { screenshotUrl, extractText, generatePdf, hasPlaywright } from "../services/browser.js";
18
18
  import { listJobs, createJob, deleteJob, toggleJob, runJobNow, formatNextRun, humanReadableSchedule } from "../services/cron.js";
19
+ import { resolveJobByNameOrId } from "../services/cron-resolver.js";
20
+ import { buildTickerText, buildDoneText, escapeMarkdown } from "./cron-progress.js";
21
+ import { isHarmlessTelegramError } from "../util/telegram-error-filter.js";
19
22
  import { storePassword, revokePassword, getSudoStatus, verifyPassword } from "../services/sudo.js";
20
23
  import { config } from "../config.js";
21
24
  import { BOT_VERSION } from "../version.js";
@@ -1442,11 +1445,25 @@ export function registerCommands(bot) {
1442
1445
  return;
1443
1446
  }
1444
1447
  // /cron run <name-or-id>
1448
+ //
1449
+ // UX contract:
1450
+ // 1. Instantly post a "🚀 Started …" message so the user knows
1451
+ // the command was received.
1452
+ // 2. Every 60s edit that message with the elapsed-time ticker
1453
+ // so the chat shows proof-of-life during 10+ min sub-agent
1454
+ // runs (the Daily Job Alert takes ~13 min in production).
1455
+ // 3. When runJobNow returns, edit the same message into a
1456
+ // final "✅ Done" / "❌ error" / "⏳ already running" state.
1457
+ // 4. The heavy lifting (banner + full body + chunking) stays in
1458
+ // subagent-delivery.ts — which now has a Markdown→plain-text
1459
+ // fallback so it actually reaches the user.
1445
1460
  if (arg.startsWith("run ")) {
1446
1461
  const nameOrId = arg.slice(4).trim();
1447
- await ctx.api.sendChatAction(ctx.chat.id, "typing");
1448
- const outcome = await runJobNow(nameOrId);
1449
- if (outcome.status === "not-found") {
1462
+ // Resolve up-front so we can show the real job name in the
1463
+ // "Started" ack, and so we handle the not-found case BEFORE
1464
+ // spending a Telegram round-trip on a pointless placeholder.
1465
+ const resolved = resolveJobByNameOrId(listJobs(), nameOrId);
1466
+ if (!resolved) {
1450
1467
  const jobs = listJobs();
1451
1468
  const hint = jobs.length > 0
1452
1469
  ? `\n\nAvailable:\n${jobs.slice(0, 10).map(j => `• ${j.name}`).join("\n")}`
@@ -1454,15 +1471,80 @@ export function registerCommands(bot) {
1454
1471
  await ctx.reply(`❌ No job matches <code>${nameOrId}</code>.${hint}`, { parse_mode: "HTML" });
1455
1472
  return;
1456
1473
  }
1457
- if (outcome.status === "already-running") {
1458
- await ctx.reply(`⏳ Job "${outcome.job.name}" is already running — not starting a duplicate. ` +
1459
- `Wait for the current run to finish, or /subagents cancel to abort it.`);
1460
- return;
1474
+ const jobName = resolved.name;
1475
+ const startedAt = Date.now();
1476
+ // Post initial ack we'll edit THIS message for the ticker and
1477
+ // the final state.
1478
+ let ackMessageId = null;
1479
+ try {
1480
+ const ack = await ctx.reply(`🚀 Started *${escapeMarkdown(jobName)}* — working…`, { parse_mode: "Markdown" });
1481
+ ackMessageId = ack.message_id;
1482
+ }
1483
+ catch (err) {
1484
+ // If even the initial ack fails, fall back to plain text so
1485
+ // the user still knows we received the command.
1486
+ try {
1487
+ const ack = await ctx.reply(`🚀 Started ${jobName} — working…`);
1488
+ ackMessageId = ack.message_id;
1489
+ }
1490
+ catch { /* give up on the ack — run still fires below */ }
1491
+ }
1492
+ const chatId = ctx.chat.id;
1493
+ // Progress ticker: edit the ack message with elapsed time every
1494
+ // 60s. Errors from editMessageText (including the harmless
1495
+ // "message is not modified") are swallowed via the central filter.
1496
+ const ticker = setInterval(async () => {
1497
+ if (ackMessageId === null)
1498
+ return;
1499
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
1500
+ try {
1501
+ await ctx.api.editMessageText(chatId, ackMessageId, buildTickerText(jobName, elapsed), { parse_mode: "Markdown" });
1502
+ }
1503
+ catch (err) {
1504
+ if (!isHarmlessTelegramError(err)) {
1505
+ console.warn(`[cron:run] ticker edit failed:`, err);
1506
+ }
1507
+ }
1508
+ }, 60_000);
1509
+ let outcome;
1510
+ try {
1511
+ outcome = await runJobNow(nameOrId);
1512
+ }
1513
+ finally {
1514
+ clearInterval(ticker);
1515
+ }
1516
+ // Final state — edit the ack message one last time.
1517
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
1518
+ const finalText = (() => {
1519
+ if (outcome.status === "not-found") {
1520
+ // Shouldn't happen — we already resolved successfully above —
1521
+ // but handle it for completeness.
1522
+ return `❌ ${escapeMarkdown(jobName)} — not found (race?)`;
1523
+ }
1524
+ if (outcome.status === "already-running") {
1525
+ return buildDoneText(outcome.job.name, elapsed, { ok: true, skipped: true });
1526
+ }
1527
+ return buildDoneText(outcome.job.name, elapsed, {
1528
+ ok: !outcome.error,
1529
+ error: outcome.error,
1530
+ });
1531
+ })();
1532
+ if (ackMessageId !== null) {
1533
+ try {
1534
+ await ctx.api.editMessageText(chatId, ackMessageId, finalText, { parse_mode: "Markdown" });
1535
+ }
1536
+ catch (err) {
1537
+ if (!isHarmlessTelegramError(err)) {
1538
+ // Last-ditch fallback: post as a new plain message so the
1539
+ // user sees the result even if the edit failed.
1540
+ await ctx.reply(finalText).catch(() => { });
1541
+ }
1542
+ }
1543
+ }
1544
+ else {
1545
+ // We never got an ack message id — just post fresh
1546
+ await ctx.reply(finalText, { parse_mode: "Markdown" }).catch(() => ctx.reply(finalText));
1461
1547
  }
1462
- const output = outcome.output
1463
- ? `\`\`\`\n${outcome.output.slice(0, 2000)}\n\`\`\``
1464
- : "(no output)";
1465
- await ctx.reply(`🔧 Job "${outcome.job.name}" executed:\n${output}${outcome.error ? `\n\n❌ ${outcome.error}` : ""}`, { parse_mode: "Markdown" });
1466
1548
  return;
1467
1549
  }
1468
1550
  await ctx.reply("Unknown cron command. Use /cron for help.");
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure helpers for the /cron run progress ticker.
3
+ *
4
+ * Separated from commands.ts so the formatting and safety rules can be
5
+ * unit-tested without standing up the entire grammy Context. The command
6
+ * handler wires these into a setInterval that edits a single Telegram
7
+ * message once per tick, giving the user visible proof-of-life during
8
+ * long-running (10+ min) cron jobs.
9
+ *
10
+ * See test/cron-progress-ticker.test.ts for the contract.
11
+ */
12
+ /** Human-readable elapsed time — adapts unit to magnitude. */
13
+ export function formatElapsed(seconds) {
14
+ if (seconds < 60)
15
+ return `${seconds}s`;
16
+ const minutes = Math.floor(seconds / 60);
17
+ const remSec = seconds % 60;
18
+ if (minutes < 60)
19
+ return `${minutes}m ${remSec}s`;
20
+ const hours = Math.floor(minutes / 60);
21
+ const remMin = minutes % 60;
22
+ return `${hours}h ${remMin}m`;
23
+ }
24
+ /**
25
+ * Escape Markdown-breaking characters in untrusted display strings so
26
+ * an edit-message call can safely use `parse_mode: Markdown` without
27
+ * triggering "can't parse entities" — the exact bug that killed every
28
+ * daily-job-alert banner for days.
29
+ *
30
+ * We use Telegram Markdown (v1) escape rules: only `*`, `_`, `[`, `` ` ``.
31
+ * The rest flow through unchanged.
32
+ */
33
+ export function escapeMarkdown(text) {
34
+ return text.replace(/([*_[\]`])/g, "\\$1");
35
+ }
36
+ /** Intermediate ticker text: "🔄 Running *name* · 2m 5s elapsed…" */
37
+ export function buildTickerText(jobName, elapsedSeconds) {
38
+ const safe = escapeMarkdown(jobName);
39
+ return `🔄 Running *${safe}* · ${formatElapsed(elapsedSeconds)} elapsed…`;
40
+ }
41
+ /** Final ticker state: "✅ Done — *name* · 13m 17s" (or ❌ / ⏳). */
42
+ export function buildDoneText(jobName, elapsedSeconds, outcome) {
43
+ const safe = escapeMarkdown(jobName);
44
+ if (outcome.skipped) {
45
+ return `⏳ *${safe}* is already running — not starting a duplicate`;
46
+ }
47
+ if (!outcome.ok) {
48
+ const errLine = outcome.error ? `\n\n${outcome.error.slice(0, 500)}` : "";
49
+ return `❌ *${safe}* — ${formatElapsed(elapsedSeconds)}${errLine}`;
50
+ }
51
+ return `✅ Done — *${safe}* · ${formatElapsed(elapsedSeconds)}`;
52
+ }
@@ -10,6 +10,35 @@
10
10
  * module with a fake bot via __setBotApiForTest.
11
11
  */
12
12
  import { getVisibility } from "./subagents.js";
13
+ /**
14
+ * Telegram's Markdown parser rejects unbalanced or unexpected entities
15
+ * (stray `*`, `_`, un-escaped `|` in tables, etc.). Sub-agent outputs
16
+ * mix all of these. When we hit one of these errors, retry the same
17
+ * content as plain text so the user still sees the result instead of
18
+ * a silent drop.
19
+ */
20
+ function isTelegramParseError(err) {
21
+ if (!err || typeof err !== "object")
22
+ return false;
23
+ const e = err;
24
+ const haystack = `${e.message ?? ""} ${e.description ?? ""}`;
25
+ return /can't parse entities|can't find end of the entity/i.test(haystack);
26
+ }
27
+ /**
28
+ * Send a Markdown message with an automatic plain-text retry on parse
29
+ * errors. Any other error propagates to the caller's outer catch.
30
+ */
31
+ async function sendWithMarkdownFallback(api, chatId, text) {
32
+ try {
33
+ await api.sendMessage(chatId, text, { parse_mode: "Markdown" });
34
+ }
35
+ catch (err) {
36
+ if (!isTelegramParseError(err))
37
+ throw err;
38
+ console.warn(`[subagent-delivery] Markdown parse failed, retrying as plain text`);
39
+ await api.sendMessage(chatId, text);
40
+ }
41
+ }
13
42
  const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
14
43
  const FILE_UPLOAD_THRESHOLD = 20_000; // switch to .md file upload above this
15
44
  let injectedApi = null;
@@ -243,7 +272,7 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
243
272
  try {
244
273
  // Case 1: very long output → file upload with a short banner
245
274
  if (body.length > FILE_UPLOAD_THRESHOLD) {
246
- await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
275
+ await sendWithMarkdownFallback(api, info.parentChatId, banner);
247
276
  try {
248
277
  const { InputFile } = await import("grammy");
249
278
  const buf = Buffer.from(body, "utf-8");
@@ -257,12 +286,14 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
257
286
  }
258
287
  // Case 2: fits in a single message → banner + body joined
259
288
  if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
260
- await api.sendMessage(info.parentChatId, `${banner}\n\n${body}`, { parse_mode: "Markdown" });
289
+ await sendWithMarkdownFallback(api, info.parentChatId, `${banner}\n\n${body}`);
261
290
  return;
262
291
  }
263
292
  // Case 3: medium output → banner as its own message, body chunked
264
- await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
293
+ await sendWithMarkdownFallback(api, info.parentChatId, banner);
265
294
  for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
295
+ // Body chunks are always sent as plain text — markdown across
296
+ // arbitrary chunk boundaries would be inconsistent anyway.
266
297
  await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
267
298
  }
268
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.9.2",
3
+ "version": "4.9.3",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Fix #15 (B) — /cron run must give visible feedback during long runs.
3
+ *
4
+ * Regression from production: a 13-minute Daily Job Alert run showed
5
+ * the user ZERO feedback between trigger time and completion. The
6
+ * sub-agent was actually working (and eventually succeeded), but the
7
+ * Telegram chat was silent for the whole duration.
8
+ *
9
+ * This test doesn't exercise grammy directly — it tests the pure
10
+ * helper that drives the live progress message so we can verify the
11
+ * formatting, cadence math, and safety edges in isolation.
12
+ */
13
+ import { describe, it, expect } from "vitest";
14
+ import { formatElapsed, buildTickerText, buildDoneText } from "../src/handlers/cron-progress.js";
15
+
16
+ describe("formatElapsed (Fix #15B)", () => {
17
+ it("formats seconds under a minute", () => {
18
+ expect(formatElapsed(0)).toBe("0s");
19
+ expect(formatElapsed(45)).toBe("45s");
20
+ expect(formatElapsed(59)).toBe("59s");
21
+ });
22
+
23
+ it("formats minutes+seconds above a minute", () => {
24
+ expect(formatElapsed(60)).toBe("1m 0s");
25
+ expect(formatElapsed(61)).toBe("1m 1s");
26
+ expect(formatElapsed(125)).toBe("2m 5s");
27
+ expect(formatElapsed(797)).toBe("13m 17s"); // real prod duration
28
+ });
29
+
30
+ it("formats hours+minutes above 60m", () => {
31
+ expect(formatElapsed(3600)).toBe("1h 0m");
32
+ expect(formatElapsed(3660)).toBe("1h 1m");
33
+ });
34
+ });
35
+
36
+ describe("buildTickerText (Fix #15B)", () => {
37
+ it("shows job name and elapsed time in the running state", () => {
38
+ const text = buildTickerText("Daily Job Alert", 125);
39
+ expect(text).toContain("Daily Job Alert");
40
+ expect(text).toContain("2m 5s");
41
+ expect(text).toMatch(/🔄|running/i);
42
+ });
43
+
44
+ it("escapes markdown-breaking characters in the job name", () => {
45
+ // Underscores and asterisks in job names would otherwise break
46
+ // the Markdown edit and trigger "can't parse entities".
47
+ const text = buildTickerText("weird_job*name", 10);
48
+ expect(text).not.toContain("_job*"); // no raw unescaped asterisk
49
+ // We expect some form of escaping — back-slashes are fine
50
+ expect(text).toMatch(/weird/);
51
+ });
52
+ });
53
+
54
+ describe("buildDoneText (Fix #15B)", () => {
55
+ it("shows green check for a clean completion", () => {
56
+ const text = buildDoneText("Daily Job Alert", 797, { ok: true });
57
+ expect(text).toContain("✅");
58
+ expect(text).toContain("Daily Job Alert");
59
+ expect(text).toContain("13m 17s");
60
+ });
61
+
62
+ it("shows red cross and error excerpt for a failure", () => {
63
+ const text = buildDoneText("Daily Job Alert", 10, {
64
+ ok: false,
65
+ error: "Sub-agent cancelled: timeout",
66
+ });
67
+ expect(text).toContain("❌");
68
+ expect(text).toContain("timeout");
69
+ });
70
+
71
+ it("shows warning for an already-running skip", () => {
72
+ const text = buildDoneText("Daily Job Alert", 0, { ok: true, skipped: true });
73
+ expect(text).toContain("⏳");
74
+ expect(text).toMatch(/already running|in progress/i);
75
+ });
76
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Fix #15 (A) — subagent-delivery must retry without parse_mode when
3
+ * Telegram rejects the Markdown entities.
4
+ *
5
+ * Real regression: Daily Job Alert banners have been silently failing
6
+ * with "Bad Request: can't parse entities: Can't find end of the entity"
7
+ * every single day since the subagent-delivery module shipped. The
8
+ * result text contains mixed `|`, `**`, `\|`, emoji, and asterisks that
9
+ * Telegram's Markdown parser chokes on. The code currently logs the
10
+ * error and drops the delivery, so the user never sees the banner.
11
+ *
12
+ * Contract: when `sendMessage(..., parse_mode: Markdown)` throws with
13
+ * the "can't parse entities" pattern, retry the SAME text WITHOUT
14
+ * `parse_mode`. Any other error still logs + bails.
15
+ *
16
+ * This file uses a minimal bot-api stub so we can drive both the happy
17
+ * path and the parse-error path deterministically.
18
+ */
19
+ import { describe, it, expect, vi, beforeEach } from "vitest";
20
+ import { deliverSubAgentResult, __setBotApiForTest } from "../src/services/subagent-delivery.js";
21
+ import type { SubAgentInfo, SubAgentResult } from "../src/services/subagents.js";
22
+
23
+ interface Sent {
24
+ chatId: number;
25
+ text: string;
26
+ parseMode?: string;
27
+ }
28
+
29
+ function makeInfo(overrides: Partial<SubAgentInfo> = {}): SubAgentInfo {
30
+ return {
31
+ id: "id-1",
32
+ name: "Daily Job Alert",
33
+ status: "completed",
34
+ startedAt: 0,
35
+ depth: 0,
36
+ source: "cron",
37
+ parentChatId: 42,
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function makeResult(output: string): SubAgentResult {
43
+ return {
44
+ id: "id-1",
45
+ name: "Daily Job Alert",
46
+ status: "completed",
47
+ output,
48
+ tokensUsed: { input: 1000, output: 200 },
49
+ duration: 60_000,
50
+ };
51
+ }
52
+
53
+ beforeEach(() => {
54
+ __setBotApiForTest(null);
55
+ });
56
+
57
+ describe("deliverSubAgentResult Markdown fallback (Fix #15)", () => {
58
+ it("retries without parse_mode when Telegram rejects entity parsing", async () => {
59
+ const sent: Sent[] = [];
60
+ let callCount = 0;
61
+
62
+ __setBotApiForTest({
63
+ sendMessage: async (chatId: number, text: string, opts?: Record<string, unknown>) => {
64
+ callCount++;
65
+ const parseMode = opts?.parse_mode as string | undefined;
66
+ // First call (Markdown) throws the real production error
67
+ if (callCount === 1 && parseMode === "Markdown") {
68
+ const err = Object.assign(
69
+ new Error("Call to 'sendMessage' failed! (400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636)"),
70
+ {
71
+ description: "Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 2636",
72
+ error_code: 400,
73
+ },
74
+ );
75
+ throw err;
76
+ }
77
+ sent.push({ chatId, text, parseMode });
78
+ return { message_id: 1 };
79
+ },
80
+ sendDocument: async () => ({}),
81
+ });
82
+
83
+ const info = makeInfo();
84
+ const result = makeResult("This **has** | broken markdown \\| entities that fail Markdown parsing");
85
+
86
+ await deliverSubAgentResult(info, result);
87
+
88
+ // Must have retried at least once WITHOUT parse_mode
89
+ const plainAttempt = sent.find((s) => s.parseMode === undefined);
90
+ expect(plainAttempt).toBeDefined();
91
+ expect(plainAttempt?.text).toContain("Daily Job Alert");
92
+ expect(plainAttempt?.text).toContain("broken markdown");
93
+ });
94
+
95
+ it("does NOT retry for non-parse errors (e.g. chat not found)", async () => {
96
+ let callCount = 0;
97
+ __setBotApiForTest({
98
+ sendMessage: async () => {
99
+ callCount++;
100
+ const err = Object.assign(new Error("Forbidden: bot was blocked by the user"), {
101
+ description: "Forbidden: bot was blocked by the user",
102
+ error_code: 403,
103
+ });
104
+ throw err;
105
+ },
106
+ sendDocument: async () => ({}),
107
+ });
108
+
109
+ await deliverSubAgentResult(makeInfo(), makeResult("some text"));
110
+
111
+ // Should have tried once and given up — no retry
112
+ expect(callCount).toBe(1);
113
+ });
114
+
115
+ it("chunked delivery also retries without parse_mode on parse errors", async () => {
116
+ const sent: Sent[] = [];
117
+ let callCount = 0;
118
+
119
+ __setBotApiForTest({
120
+ sendMessage: async (chatId: number, text: string, opts?: Record<string, unknown>) => {
121
+ callCount++;
122
+ const parseMode = opts?.parse_mode as string | undefined;
123
+ // First banner attempt fails — should retry without parse_mode
124
+ if (callCount === 1 && parseMode === "Markdown") {
125
+ const err = Object.assign(
126
+ new Error("400: Bad Request: can't parse entities"),
127
+ { description: "can't parse entities", error_code: 400 },
128
+ );
129
+ throw err;
130
+ }
131
+ sent.push({ chatId, text, parseMode });
132
+ return { message_id: callCount };
133
+ },
134
+ sendDocument: async () => ({}),
135
+ });
136
+
137
+ const info = makeInfo();
138
+ // Large body forces the chunked path
139
+ const result = makeResult("x".repeat(5000));
140
+
141
+ await deliverSubAgentResult(info, result);
142
+
143
+ // At least one plain-text delivery must have landed
144
+ expect(sent.length).toBeGreaterThan(0);
145
+ expect(sent.some((s) => s.parseMode === undefined)).toBe(true);
146
+ });
147
+ });