diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 473789f01a..ec96e2bf5e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Made Telegram reference-client capability diagnostics safe for TUI embedding. - A Telegram notification daemon whose reconciliation pass fails no longer exits. The pass persists through the shared topic authority, and a momentarily unavailable authority (lock contention or a rejected compare-and-set) rejected out of both the scan timer and the run loop into the process-level fatal handler, killing the owner. Every session topic was then left behind as an unarchived shell that answers nothing — including for sessions that were still live and lost their notifications. The pass now reports the failure and the next scan interval retries it; the queue-flush timer is guarded the same way. - 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. - Slash commands now expand in non-interactive runs. `gjc -p "/init"` previously reached the model as the literal text `/init`, so no command body was injected, no file was written, and the model still answered as if the command had run. Print mode now loads the same bundled and file-based command list interactive mode uses before it prompts. - `gjc plugin install ` now names the marketplaces that offer `` when the npm resolution it falls back to fails, so a plugin name copied out of `gjc plugin discover` no longer dead-ends on a bare `install_failed`. - A remote multi-select ask now shows what is already selected. The ask tool re-issues one remote request per toggle, but the request carried no selection state, so Telegram kept posting an identical prompt with no sign that option 1 had been picked — the checkbox rendering existed only for durable workflow gates. `AskAnswerRequest` now carries `multi` and the selected option labels, the notification bus publishes them as `selectedOptionIndices` with the `(N selected)` question prefix while keeping the ask tool's own Next/Done control, and pre-numbered options (deep interview) are renumbered once instead of rendering as `1. ☑ 1. …`. diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts index c7505c14ec..c698783e50 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts @@ -385,6 +385,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; @@ -424,7 +428,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, ) ); @@ -5070,6 +5074,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). */ @@ -7838,8 +7844,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 { @@ -7878,10 +7882,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; @@ -7889,8 +7893,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); @@ -8011,7 +8015,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; } @@ -9517,6 +9526,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 1b4870b9a9..7eab126222 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon.test.ts @@ -11205,6 +11205,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();