From e926f2ed548573c908bd7d7d1d48bb15ec792222 Mon Sep 17 00:00:00 2001 From: yazzang-homelab Date: Thu, 6 Aug 2026 03:32:30 +0900 Subject: [PATCH] fix(notifications): deliver Telegram frames when the paired chat is not a forum A paired private chat whose bot has no Threaded Mode answers createForumTopic with "Bad Request: the chat is not a forum". That description was not a recognized capability refusal, and the suppressed/rejected verdict lived in caller-local flags while getOrCreateTopic shares one in-flight creation, so every awaiter that joined the shared promise rethrew and dropped its frame - identity headers published after /resume, asks, and context updates never reached the chat. The rejection now travels as typed errors so every awaiter classifies it identically, the non-forum description counts as a capability refusal, and a confirmed refusal is latched so later frames stop re-issuing a rejected createForumTopic per message. Lore-id: 7c3a1f95 Confidence: high Scope-risk: narrow Reversibility: easy Tested: concurrent identity/ask frames against a non-forum private chat deliver flat with a single createForumTopic attempt Not-tested: live Telegram bot with Threaded Mode enabled mid-run (latch requires daemon restart) --- packages/coding-agent/CHANGELOG.md | 1 + .../src/sdk/bus/telegram-daemon.ts | 30 ++++++---- .../notifications-telegram-daemon.test.ts | 56 +++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ac96e79f13..3639b6e1d5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Made Telegram reference-client capability diagnostics safe for TUI embedding. - MiniMax M3 preset and profile ids canonicalized to `MiniMax-M3` (issue #3896): the `minimax` / `minimax-cn` onboarding presets and the `minimax-eco` / `minimax-medium` / `minimax-pro` builtin model profiles no longer reference the removed lowercase `minimax-m3` / `minimax-v3` first-class catalog ids. +- Telegram notifications no longer disappear in a paired private chat whose bot has no Threaded Mode. Telegram answers `createForumTopic` there with `Bad Request: the chat is not a forum`, which was not recognized as a capability refusal, and the refusal verdict lived in caller-local flags — so every frame that joined the shared in-flight topic creation rethrew and its message (identity headers after `/resume`, asks, context updates) was dropped instead of being delivered flat. The rejection is now carried by typed errors that every awaiter of the same creation classifies identically, `the chat is not a forum` counts as a capability refusal, and a confirmed refusal is latched so later frames stop re-issuing a rejected `createForumTopic` per message. ## [0.12.12] - 2026-08-05 diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts index cbf6963b61..34ccad469d 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts @@ -383,6 +383,10 @@ const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar va type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" }; type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" }; class ThreadedModeCapabilityRefusal extends Error {} +/** Telegram answered `createForumTopic` with an explicit `ok: false` rejection. */ +class TopicCreationRejected extends Error {} +/** The transport suppressed `createForumTopic` (no response body to classify). */ +class TopicCreationSuppressed extends Error {} async function prepareTelegramImageAttachment(frame: Record): Promise> { if (frame.type !== "image_attachment") return frame; @@ -422,7 +426,7 @@ function isThreadedModeCapabilityRefusal(response: unknown): boolean { return ( ok === false && typeof description === "string" && - /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?)/i.test( + /(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?|chat is not a forum)/i.test( description, ) ); @@ -5068,6 +5072,8 @@ export class TelegramNotificationDaemon { private readonly flatIdentitySent = new Set(); /** Cached delivery boundary for the private owner chat or validation forum. */ private pairedChatPrivacy: PairedChatPrivacy | undefined; + /** Latched once Telegram confirms this chat cannot host forum topics. */ + private topicCapabilityRefused = false; /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */ private botUsername: string | undefined; /** Sessions whose agent loop is currently busy (drives the typing indicator). */ @@ -7836,8 +7842,6 @@ export class TelegramNotificationDaemon { let acceptedTopicId: string | undefined; let acceptedTopicCompensated = false; let acceptedTopicArchiveAttempted = false; - let creationSuppressed = false; - let creationRejected = false; let adoptedTopicId: number | undefined; const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId); try { @@ -7876,10 +7880,10 @@ export class TelegramNotificationDaemon { const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as | { result?: { message_thread_id?: unknown } } | undefined; - if (res === undefined) { - creationSuppressed = true; - return undefined; - } + // Classification must travel with the rejection, not through + // closure flags: `getOrCreateTopic` shares one in-flight create + // promise, so every other awaiter observes only the error. + if (res === undefined) throw new TopicCreationSuppressed(); if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal(); const response = res as { ok?: unknown; @@ -7887,8 +7891,8 @@ export class TelegramNotificationDaemon { }; const tid = response.result?.message_thread_id; if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) { - creationRejected = response.ok === false; - if (!creationRejected) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey); + if (response.ok === false) throw new TopicCreationRejected(); + this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey); throw new Error("createForumTopic: invalid message_thread_id"); } acceptedTopicId = String(tid); @@ -8009,7 +8013,12 @@ export class TelegramNotificationDaemon { } catch (err) { if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId); if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch); - if (creationSuppressed || creationRejected || err instanceof ThreadedModeCapabilityRefusal) { + if ( + err instanceof TopicCreationSuppressed || + err instanceof TopicCreationRejected || + err instanceof ThreadedModeCapabilityRefusal + ) { + if (err instanceof ThreadedModeCapabilityRefusal) this.topicCapabilityRefused = true; if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics(); return undefined; } @@ -9515,6 +9524,7 @@ export class TelegramNotificationDaemon { } private async pairedChatAllowsTopics(): Promise { + if (this.topicCapabilityRefused) return false; const privacy = await this.resolvePairedChatPrivacy(); return privacy === "private" || privacy === "validation-forum"; } diff --git a/packages/coding-agent/test/notifications-telegram-daemon.test.ts b/packages/coding-agent/test/notifications-telegram-daemon.test.ts index e404515960..7a2f24f86d 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon.test.ts @@ -11173,6 +11173,62 @@ test("threaded mode off: frames fall back to the flat paired chat with a one-tim expect(ask).toBeTruthy(); expect(ask!.body.reply_markup?.inline_keyboard?.length).toBeGreaterThan(0); }); +test("private chat without Threaded Mode: concurrent frames all deliver flat", async () => { + const agentDir = tempAgentDir(); + const bot = new FakeBotApi(); + // Verbatim Bot API rejection for a paired private chat whose bot has no + // Threaded Mode; the slow response makes a second frame join the shared + // in-flight create instead of issuing its own. + bot.call = (async (method: string, body: unknown) => { + bot.calls.push({ method, body: body as never }); + if (method === "createForumTopic") { + await Bun.sleep(30); + return { ok: false, error_code: 400, description: "Bad Request: the chat is not a forum" }; + } + if (method === "getChat") return { ok: true, result: { type: "private" } }; + if (method === "sendMessage") return { ok: true, result: { message_id: bot.calls.length } }; + return { ok: true, result: true }; + }) as never; + const daemon = new TelegramNotificationDaemon({ + settings: settings(agentDir), + ownerId: "owner", + botToken: "tok", + chatId: "42", + botApi: bot, + rich: { enabled: false }, + sound: "none", + }); + const session = { sessionId: "S", token: "tok", ws: { readyState: 1, send() {} }, pending: new Map() }; + + await Promise.all([ + daemon.handleSessionMessage(session as never, { + type: "identity_header", + sessionId: "S", + repo: "r", + branch: "b", + }), + daemon.handleSessionMessage(session as never, { + type: "action_needed", + sessionId: "S", + id: "ask1", + kind: "ask", + question: "Proceed?", + options: ["Yes", "No"], + }), + ]); + // A later frame must not re-attempt the refused capability. + await daemon.handleSessionMessage(session as never, { + type: "context_update", + sessionId: "S", + lastMessage: "hello world", + }); + + expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(1); + const sends = bot.calls.filter(call => call.method === "sendMessage"); + expect(sends.every(call => call.body.message_thread_id === undefined)).toBe(true); + expect(sends.some(call => String(call.body.text).includes("Proceed?"))).toBe(true); + expect(sends.some(call => String(call.body.text).includes("hello world"))).toBe(true); +}); test("topic creation transport failures fail closed without flat delivery", async () => { const agentDir = tempAgentDir(); const bot = new FakeBotApi();