Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` now names the marketplaces that offer `<name>` 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. …`.
Expand Down
30 changes: 20 additions & 10 deletions packages/coding-agent/src/sdk/bus/telegram-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<Record<string, unknown>> {
if (frame.type !== "image_attachment") return frame;
Expand Down Expand Up @@ -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,
)
);
Expand Down Expand Up @@ -5070,6 +5074,8 @@ export class TelegramNotificationDaemon {
private readonly flatIdentitySent = new Set<string>();
/** 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). */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -7878,19 +7882,19 @@ 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;
result?: { message_thread_id?: unknown };
};
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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -9517,6 +9526,7 @@ export class TelegramNotificationDaemon {
}

private async pairedChatAllowsTopics(): Promise<boolean> {
if (this.topicCapabilityRefused) return false;
const privacy = await this.resolvePairedChatPrivacy();
return privacy === "private" || privacy === "validation-forum";
}
Expand Down
56 changes: 56 additions & 0 deletions packages/coding-agent/test/notifications-telegram-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading