From 6132c3d014952a99a91cb56e4e104b5ecb08bad1 Mon Sep 17 00:00:00 2001 From: Chloe Date: Mon, 26 Jan 2026 21:47:09 -0800 Subject: [PATCH 01/10] fix(telegram): include AccountId in native command context for multi-agent routing When running multiple Telegram bot accounts bound to different agents, the /new command (and other slash commands) would send confirmation messages via the wrong bot because the context was missing AccountId. The fix adds AccountId: route.accountId to the context payload in registerTelegramNativeCommands, matching how bot-message-context.ts handles regular messages. Fixes #2537 --- src/telegram/bot-native-commands.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 3415ea927..73a5a148a 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -468,6 +468,7 @@ export const registerTelegramNativeCommands = ({ CommandAuthorized: commandAuthorized, CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, + AccountId: route.accountId, CommandTargetSessionKey: sessionKey, MessageThreadId: threadIdForSend, IsForum: isForum, From fcc53bcf1bca757a2c330e6dab2383adb563aef3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 29 Jan 2026 10:16:07 +0530 Subject: [PATCH 02/10] fix: include AccountId in telegram native command context (#2942) (thanks @Chloe-VP) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1a74637..f9484e26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Status: beta. - Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. - Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. +- Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP. - Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin. - TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. From 22b59d24cea2e6fe3fd47035e642231744b6bb17 Mon Sep 17 00:00:00 2001 From: HirokiKobayashi-R Date: Wed, 28 Jan 2026 20:35:36 +0900 Subject: [PATCH 03/10] fix(mentions): check mentionPatterns even when explicit mention is available --- src/auto-reply/reply/mentions.test.ts | 15 ++++++++++++++- src/auto-reply/reply/mentions.ts | 4 +++- ...s-guild-messages-mentionpatterns-match.test.ts | 6 +++--- ...lt.sends-tool-summaries-responseprefix.test.ts | 6 +++--- ...tionpatterns-match-without-botusername.test.ts | 5 +++-- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index 7742b4f30..07cff23a9 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js"; describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bclawd\b/i]; - it("prefers explicit mentions when other mentions are present", () => { + it("checks mentionPatterns even when explicit mention is available", () => { const result = matchesMentionWithExplicit({ text: "@clawd hello", mentionRegexes, @@ -14,6 +14,19 @@ describe("matchesMentionWithExplicit", () => { canResolveExplicit: true, }, }); + expect(result).toBe(true); + }); + + it("returns false when explicit is false and no regex match", () => { + const result = matchesMentionWithExplicit({ + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); expect(result).toBe(false); }); diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 71964ac5f..9554a3c7b 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -90,7 +90,9 @@ export function matchesMentionWithExplicit(params: { const explicit = params.explicit?.isExplicitlyMentioned === true; const explicitAvailable = params.explicit?.canResolveExplicit === true; const hasAnyMention = params.explicit?.hasAnyMention === true; - if (hasAnyMention && explicitAvailable) return explicit; + if (hasAnyMention && explicitAvailable) { + return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); + } if (!cleaned) return explicit; return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); } diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index 80bb5ff8f..bd4ec38ca 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => { expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); - it("skips guild messages when another user is explicitly mentioned", async () => { + it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { @@ -211,8 +211,8 @@ describe("discord tool result dispatch", () => { client, ); - expect(dispatchMock).not.toHaveBeenCalled(); - expect(sendMock).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); it("accepts guild reply-to-bot messages as implicit mentions", async () => { diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index ce7015399..4481d7589 100644 --- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -392,7 +392,7 @@ describe("monitorSlackProvider tool results", () => { expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); - it("skips channel messages when another user is explicitly mentioned", async () => { + it("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => { slackTestState.config = { messages: { responsePrefix: "PFX", @@ -433,8 +433,8 @@ describe("monitorSlackProvider tool results", () => { controller.abort(); await run; - expect(replyMock).not.toHaveBeenCalled(); - expect(sendMock).not.toHaveBeenCalled(); + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); it("treats replies to bot threads as implicit mentions", async () => { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 8bfe1fdd3..03aaeebd7 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -212,7 +212,7 @@ describe("createTelegramBot", () => { ); }); - it("skips group messages when another user is explicitly mentioned", async () => { + it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); @@ -249,7 +249,8 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(replySpy).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { From 16a5549ec0b64528f6513627512abafdabe41e39 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 29 Jan 2026 10:31:08 +0530 Subject: [PATCH 04/10] docs: update changelog for mention patterns (#3303) (thanks @HirokiKobayashi-R) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9484e26c..f3998d892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Status: beta. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. - Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. - Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. - Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. From 34291321b4fa2b5aea5e12e93cab2cf15cc94cb9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 29 Jan 2026 10:33:25 +0530 Subject: [PATCH 05/10] chore: update clawtributors (add @HirokiKobayashi-R) --- README.md | 66 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7e884be33..70ca70157 100644 --- a/README.md +++ b/README.md @@ -479,36 +479,38 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg - rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 vignesh07 czekaj mukhtharcm sebslight maxsumrall - xadenryan rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak - jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] - lc0rp mousberg adam91holt hougangdev gumadeiras mteam88 hirefrank joeynyc orlyjamie dbhurley - Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein - nachx639 shakkernerd pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b - cpojer scald thewilloftheshadow andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee rafaelreis-r - dominicnunez ratulsarna lutr0 danielz1z AdeboyeDN Alg0rix papago2355 emanuelst KristijanJovanovski rdev - rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc - pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok obviyus - mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer - JonUleis bjesuiter cheeeee robbyczgw-cla Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman - ysqander aj47 kennyklee superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic - dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse - dougvk erikpr1994 fal3 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr - neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 - manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis - zats 24601 ameno- Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten larlyssa - odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot - Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey - jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd - robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker 0oAstro abhaymundhara alejandro maza - Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro chenyuan99 Clawdbot Maintainers conhecendoia - dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen Felix Krause foeken ganghyun kim grrowl gtsifrikas - HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jane Jarvis Jefferson Nunn jogi47 kentaro Kevin Lin - kitze Kiwitwitter levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 - reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai - siraht snopoke Suksham-sharma techboss testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin - Wimmie wolfred wstock yazinsai YiWang24 ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee - atalovesyou Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder - Quentin Randy Torres rhjoh ronak-guliani William Stock + rahthakor vrknetha radek-paclt vignesh07 Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall + xadenryan rodrigouroz juanpablodlc hsrvc magimetal zerone0x tyler6204 meaningfool patelhiren NicholasSpisak + jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Mariano Belinky Hyaxia dantelex SocialNerd42069 daveonkels + google-labs-jules[bot] lc0rp mousberg adam91holt hougangdev shakkernerd gumadeiras mteam88 hirefrank joeynyc + orlyjamie dbhurley Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua + benostein elliotsecops nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat + petter-b thewilloftheshadow cpojer scald andranik-sahakyan davidguttman sleontenko denysvitali sircrumpet peschee + nonggialiang rafaelreis-r dominicnunez lploc94 ratulsarna lutr0 danielz1z AdeboyeDN Alg0rix papago2355 + emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek + ryancontent artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby + obviyus buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 + roshanasingh4 tosh-hamburg azade-c dlauer JonUleis shivamraut101 bjesuiter cheeeee robbyczgw-cla Josh Phillips + YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 kennyklee superman32432432 Yurii Chukhlib + grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic + kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 fal3 Ghost jonasjancarik + Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo + iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff + siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 ameno- Chris Taylor dguido + Django Navarro evalexpr henrino3 humanwritten larlyssa Lukavyi odysseus0 oswalpalash pcty-nextgen-service-account pi0 + rmorse Roopak Nijhara Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx + EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior + jeffersonwarrior jverdi longmaba MarvinCui mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd + robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML tewatia travisp VAC william arzt zknicker 0oAstro + abhaymundhara aduk059 alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier araa47 arthyn Asleep123 + bguidolim bolismauro chenyuan99 Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen + dylanneve1 Felix Krause foeken frankekn ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna + Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze Kiwitwitter + levifig Lloyd longjos loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn + MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe + Rolf Fredheim Rony Kelner Samrat Jha senoldogann sergical shiv19 shiyuanhai siraht snopoke techboss + testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wolfred wstock YangHuang2280 + yazinsai YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade + carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres + rhjoh ronak-guliani William Stock

From a2d06e75b0cb00e1808e4cb14a399d542b035439 Mon Sep 17 00:00:00 2001 From: kiranjd Date: Tue, 27 Jan 2026 13:33:41 +0530 Subject: [PATCH 06/10] fix(telegram): notify users when agent returns empty response Co-Authored-By: Claude Opus 4.5 --- src/telegram/bot-message-dispatch.ts | 1 + src/telegram/bot-native-commands.ts | 3 ++- src/telegram/bot/delivery.ts | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index cead0628a..c5ade7466 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -228,6 +228,7 @@ export const dispatchTelegramMessage = async ({ onVoiceRecording: sendRecordVoice, linkPreview: telegramCfg.linkPreview, replyQuoteText, + notifyEmptyResponse: info.kind === "final", }); }, onError: (err, info) => { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 73a5a148a..5f2bbf1e6 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -488,7 +488,7 @@ export const registerTelegramNativeCommands = ({ cfg, dispatcherOptions: { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - deliver: async (payload) => { + deliver: async (payload, info) => { await deliverReplies({ replies: [payload], chatId: String(chatId), @@ -501,6 +501,7 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, + notifyEmptyResponse: info.kind === "final", }); }, onError: (err, info) => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 8c1e74b73..f0fd51c8c 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -44,7 +44,9 @@ export async function deliverReplies(params: { linkPreview?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; -}) { + /** If true, send a fallback message when all replies are empty. Default: false */ + notifyEmptyResponse?: boolean; +}): Promise<{ delivered: boolean }> { const { replies, chatId, @@ -58,6 +60,7 @@ export async function deliverReplies(params: { } = params; const chunkMode = params.chunkMode ?? "length"; let hasReplied = false; + let skippedEmpty = 0; const chunkText = (markdown: string) => { const markdownChunks = chunkMode === "newline" @@ -85,6 +88,7 @@ export async function deliverReplies(params: { continue; } runtime.error?.(danger("reply missing text/media")); + skippedEmpty++; continue; } const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); @@ -268,6 +272,17 @@ export async function deliverReplies(params: { } } } + + // If all replies were empty and notifyEmptyResponse is enabled, send a fallback message + if (!hasReplied && skippedEmpty > 0 && params.notifyEmptyResponse) { + const fallbackText = "No response generated. Please try again."; + await sendTelegramText(bot, chatId, fallbackText, runtime, { + messageThreadId, + }); + hasReplied = true; + } + + return { delivered: hasReplied }; } export async function resolveMedia( From 0761652701e3cb034e0674e747db0560db746e70 Mon Sep 17 00:00:00 2001 From: kiranjd Date: Tue, 27 Jan 2026 21:43:49 +0530 Subject: [PATCH 07/10] fix(telegram): handle empty reply array in notifyEmptyResponse Previous fix only checked skippedEmpty > 0, but when model returns content: [] no payloads are created at all. Now also checks replies.length === 0 to catch this case. Co-Authored-By: Claude Opus 4.5 --- src/telegram/bot/delivery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index f0fd51c8c..648ddeeab 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -274,7 +274,8 @@ export async function deliverReplies(params: { } // If all replies were empty and notifyEmptyResponse is enabled, send a fallback message - if (!hasReplied && skippedEmpty > 0 && params.notifyEmptyResponse) { + // Check both: (1) replies with no content (skippedEmpty), (2) no replies at all (empty array) + if (!hasReplied && (skippedEmpty > 0 || replies.length === 0) && params.notifyEmptyResponse) { const fallbackText = "No response generated. Please try again."; await sendTelegramText(bot, chatId, fallbackText, runtime, { messageThreadId, From c20035094ddf95d3186429e88c1b753b99edc36b Mon Sep 17 00:00:00 2001 From: Conroy Whitney Date: Thu, 29 Jan 2026 00:46:50 -0500 Subject: [PATCH 08/10] fix: use & instead of <> in XML escaping test for Windows NTFS compatibility (#3750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NTFS does not allow < or > in filenames, causing the XML filename escaping test to fail on Windows CI with ENOENT. Replace file.txt with file&test.txt — & is valid on all platforms and still requires XML escaping (&), preserving the test's intent. Fixes #3748 --- src/media-understanding/apply.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index e06adbc24..7a4d68136 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -550,10 +550,11 @@ describe("applyMediaUnderstanding", () => { it("escapes XML special characters in filenames to prevent injection", async () => { const { applyMediaUnderstanding } = await loadApply(); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-")); - // Create file with XML special characters in the name (what filesystem allows) + // Use & in filename — valid on all platforms (including Windows, which + // forbids < and > in NTFS filenames) and still requires XML escaping. // Note: The sanitizeFilename in store.ts would strip most dangerous chars, // but we test that even if some slip through, they get escaped in output - const filePath = path.join(dir, "file.txt"); + const filePath = path.join(dir, "file&test.txt"); await fs.writeFile(filePath, "safe content"); const ctx: MsgContext = { @@ -575,10 +576,9 @@ describe("applyMediaUnderstanding", () => { expect(result.appliedFile).toBe(true); // Verify XML special chars are escaped in the output - expect(ctx.Body).toContain("<"); - expect(ctx.Body).toContain(">"); - // The raw < and > should not appear unescaped in the name attribute - expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/); + expect(ctx.Body).toContain("&"); + // The name attribute should contain the escaped form, not a raw unescaped & + expect(ctx.Body).toMatch(/name="file&test\.txt"/); }); it("normalizes MIME types to prevent attribute injection", async () => { From 718bc3f9c818a3521c675a58d8d2942c51d39f4e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 29 Jan 2026 11:34:47 +0530 Subject: [PATCH 09/10] fix: avoid silent telegram empty replies (#3796) (#3796) --- CHANGELOG.md | 1 + src/auto-reply/reply/normalize-reply.test.ts | 27 +++++++++++ src/auto-reply/reply/normalize-reply.ts | 23 ++++++++-- src/auto-reply/reply/reply-dispatcher.ts | 33 ++++++++++---- src/telegram/bot-message-dispatch.ts | 47 ++++++++++++++++---- src/telegram/bot-native-commands.ts | 33 ++++++++++++-- src/telegram/bot/delivery.ts | 29 ++++++------ 7 files changed, 154 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3998d892..3c5321870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Status: beta. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. - Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. - Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts index 30fb5e3f5..b9547c2b1 100644 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ b/src/auto-reply/reply/normalize-reply.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; // Keep channelData-only payloads so channel-specific replies survive normalization. @@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => { expect(normalized?.text).toBeUndefined(); expect(normalized?.channelData).toEqual(payload.channelData); }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); }); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 7968088bd..9a58bebde 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -8,6 +8,8 @@ import { } from "./response-prefix-template.js"; import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; +export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; + export type NormalizeReplyOptions = { responsePrefix?: string; /** Context for template variable interpolation in responsePrefix */ @@ -15,6 +17,7 @@ export type NormalizeReplyOptions = { onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; + onSkip?: (reason: NormalizeReplySkipReason) => void; }; export function normalizeReplyPayload( @@ -26,12 +29,18 @@ export function normalizeReplyPayload( payload.channelData && Object.keys(payload.channelData).length > 0, ); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasChannelData) return null; + if (!trimmed && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasChannelData) return null; + if (!hasMedia && !hasChannelData) { + opts.onSkip?.("silent"); + return null; + } text = ""; } if (text && !trimmed) { @@ -43,14 +52,20 @@ export function normalizeReplyPayload( if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) { const stripped = stripHeartbeatToken(text, { mode: "message" }); if (stripped.didStrip) opts.onHeartbeatStrip?.(); - if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null; + if (stripped.shouldSkip && !hasMedia && !hasChannelData) { + opts.onSkip?.("heartbeat"); + return null; + } text = stripped.text; } if (text) { text = sanitizeUserFacingText(text); } - if (!text?.trim() && !hasMedia && !hasChannelData) return null; + if (!text?.trim() && !hasMedia && !hasChannelData) { + opts.onSkip?.("empty"); + return null; + } // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons) let enrichedPayload: ReplyPayload = { ...payload, text }; diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index f41667802..fd7fb5493 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,6 +1,6 @@ import type { HumanDelayConfig } from "../../config/types.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; +import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js"; import type { ResponsePrefixContext } from "./response-prefix-template.js"; import type { TypingController } from "./typing.js"; @@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final"; type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void; +type ReplyDispatchSkipHandler = ( + payload: ReplyPayload, + info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason }, +) => void; + type ReplyDispatchDeliverer = ( payload: ReplyPayload, info: { kind: ReplyDispatchKind }, @@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = { onHeartbeatStrip?: () => void; onIdle?: () => void; onError?: ReplyDispatchErrorHandler; + // AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback). + onSkip?: ReplyDispatchSkipHandler; /** Human-like delay between block replies for natural rhythm. */ humanDelay?: HumanDelayConfig; }; @@ -65,15 +72,16 @@ export type ReplyDispatcher = { getQueuedCounts: () => Record; }; +type NormalizeReplyPayloadInternalOptions = Pick< + ReplyDispatcherOptions, + "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" +> & { + onSkip?: (reason: NormalizeReplySkipReason) => void; +}; + function normalizeReplyPayloadInternal( payload: ReplyPayload, - opts: Pick< - ReplyDispatcherOptions, - | "responsePrefix" - | "responsePrefixContext" - | "responsePrefixContextProvider" - | "onHeartbeatStrip" - >, + opts: NormalizeReplyPayloadInternalOptions, ): ReplyPayload | null { // Prefer dynamic context provider over static context const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext; @@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal( responsePrefix: opts.responsePrefix, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, + onSkip: opts.onSkip, }); } @@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis }; const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { - const normalized = normalizeReplyPayloadInternal(payload, options); + const normalized = normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) return false; queuedCounts[kind] += 1; pending += 1; diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index c5ade7466..ea006e316 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + async function resolveStickerVisionSupport(cfg, agentId) { try { const catalog = await loadModelCatalog({ config: cfg }); @@ -198,6 +200,15 @@ export const dispatchTelegramMessage = async ({ } } + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, @@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({ await flushDraft(); draftStream?.stop(); } - - const replyQuoteText = - ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody - ? ctxPayload.ReplyToBody.trim() || undefined - : undefined; - await deliverReplies({ + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -228,8 +234,13 @@ export const dispatchTelegramMessage = async ({ onVoiceRecording: sendRecordVoice, linkPreview: telegramCfg.linkPreview, replyQuoteText, - notifyEmptyResponse: info.kind === "final", }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); @@ -261,7 +272,27 @@ export const dispatchTelegramMessage = async ({ }, }); draftStream?.stop(); - if (!queuedFinal) { + let sentFallback = false; + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + const result = await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: resolvedThreadId, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + replyQuoteText, + }); + sentFallback = result.delivered; + } + + const hasFinalResponse = queuedFinal || sentFallback; + if (!hasFinalResponse) { if (isGroup && historyKey) { clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); } diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 5f2bbf1e6..59f109a1f 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -50,6 +50,8 @@ import { import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; import { readTelegramAllowFromStore } from "./pairing-store.js"; +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + type TelegramNativeCommandContext = Context & { match?: string }; type TelegramCommandAuthResult = { @@ -483,13 +485,18 @@ export const registerTelegramNativeCommands = ({ : undefined; const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg, dispatcherOptions: { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - deliver: async (payload, info) => { - await deliverReplies({ + deliver: async (payload, _info) => { + const result = await deliverReplies({ replies: [payload], chatId: String(chatId), token: opts.token, @@ -501,8 +508,13 @@ export const registerTelegramNativeCommands = ({ tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, - notifyEmptyResponse: info.kind === "final", }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") deliveryState.skippedNonSilent += 1; }, onError: (err, info) => { runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); @@ -513,6 +525,21 @@ export const registerTelegramNativeCommands = ({ disableBlockStreaming, }, }); + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: threadIdForSend, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + } }); } diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 648ddeeab..669340b20 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -44,8 +44,6 @@ export async function deliverReplies(params: { linkPreview?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; - /** If true, send a fallback message when all replies are empty. Default: false */ - notifyEmptyResponse?: boolean; }): Promise<{ delivered: boolean }> { const { replies, @@ -60,7 +58,10 @@ export async function deliverReplies(params: { } = params; const chunkMode = params.chunkMode ?? "length"; let hasReplied = false; - let skippedEmpty = 0; + let hasDelivered = false; + const markDelivered = () => { + hasDelivered = true; + }; const chunkText = (markdown: string) => { const markdownChunks = chunkMode === "newline" @@ -88,7 +89,6 @@ export async function deliverReplies(params: { continue; } runtime.error?.(danger("reply missing text/media")); - skippedEmpty++; continue; } const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); @@ -118,6 +118,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -169,18 +170,21 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "image") { await withTelegramApiErrorLogging({ operation: "sendPhoto", runtime, fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "video") { await withTelegramApiErrorLogging({ operation: "sendVideo", runtime, fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }), }); + markDelivered(); } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) @@ -199,6 +203,7 @@ export async function deliverReplies(params: { shouldLog: (err) => !isVoiceMessagesForbidden(err), fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }), }); + markDelivered(); } catch (voiceErr) { // Fall back to text if voice messages are forbidden in this chat. // This happens when the recipient has Telegram Premium privacy settings @@ -225,6 +230,7 @@ export async function deliverReplies(params: { replyMarkup, replyQuoteText, }); + markDelivered(); // Skip this media item; continue with next. continue; } @@ -237,6 +243,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }), }); + markDelivered(); } } else { await withTelegramApiErrorLogging({ @@ -244,6 +251,7 @@ export async function deliverReplies(params: { runtime, fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }), }); + markDelivered(); } if (replyToId && !hasReplied) { hasReplied = true; @@ -264,6 +272,7 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: i === 0 ? replyMarkup : undefined, }); + markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } @@ -273,17 +282,7 @@ export async function deliverReplies(params: { } } - // If all replies were empty and notifyEmptyResponse is enabled, send a fallback message - // Check both: (1) replies with no content (skippedEmpty), (2) no replies at all (empty array) - if (!hasReplied && (skippedEmpty > 0 || replies.length === 0) && params.notifyEmptyResponse) { - const fallbackText = "No response generated. Please try again."; - await sendTelegramText(bot, chatId, fallbackText, runtime, { - messageThreadId, - }); - hasReplied = true; - } - - return { delivered: hasReplied }; + return { delivered: hasDelivered }; } export async function resolveMedia( From 6372242da756b49c7ff824526984ac0b6f77dea7 Mon Sep 17 00:00:00 2001 From: Tyler Yust <64381258+tyler6204@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:24:46 -0800 Subject: [PATCH 10/10] fix(ui): improve chat session dropdown and refresh behavior (#3682) * refactor(ui): enhance loadSessions function to accept overrides for session loading parameters - Updated loadSessions to include optional parameters for activeMinutes, limit, includeGlobal, and includeUnknown. - Modified refreshChat to use the new activeMinutes parameter when loading sessions. - Removed duplicate applySettingsFromUrl call in handleConnected function. * feat(ui): implement session refresh functionality after chat - Added `refreshSessionsAfterChat` property to `ChatHost` and `GatewayHost` types. - Introduced `isChatResetCommand` function to identify chat reset commands. - Updated `handleSendChat` to set `refreshSessions` based on chat reset commands. - Modified `handleGatewayEventUnsafe` to load sessions when chat is finalized and `refreshSessionsAfterChat` is true. - Enhanced `refreshChat` to load sessions with `activeMinutes` set to 0 for immediate refresh. --- ui/src/ui/app-chat.ts | 17 +++++++++- ui/src/ui/app-gateway.ts | 8 +++++ ui/src/ui/app-lifecycle.ts | 6 ++-- ui/src/ui/app-render.helpers.ts | 52 ++++++++++++++++++++++++++----- ui/src/ui/app.ts | 1 + ui/src/ui/controllers/sessions.ts | 21 ++++++++++--- 6 files changed, 89 insertions(+), 16 deletions(-) diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 77149f9ad..0b35fb445 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -21,6 +21,7 @@ type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; + refreshSessionsAfterChat: boolean; }; export function isChatBusy(host: ChatHost) { @@ -41,6 +42,14 @@ export function isChatStopCommand(text: string) { ); } +function isChatResetCommand(text: string) { + const trimmed = text.trim(); + if (!trimmed) return false; + const normalized = trimmed.toLowerCase(); + if (normalized === "/new" || normalized === "/reset") return true; + return normalized.startsWith("/new ") || normalized.startsWith("/reset "); +} + export async function handleAbortChat(host: ChatHost) { if (!host.connected) return; host.chatMessage = ""; @@ -71,6 +80,7 @@ async function sendChatMessageNow( attachments?: ChatAttachment[]; previousAttachments?: ChatAttachment[]; restoreAttachments?: boolean; + refreshSessions?: boolean; }, ) { resetToolStream(host as unknown as Parameters[0]); @@ -94,6 +104,9 @@ async function sendChatMessageNow( if (ok && !host.chatRunId) { void flushChatQueue(host); } + if (ok && opts?.refreshSessions) { + host.refreshSessionsAfterChat = true; + } return ok; } @@ -132,6 +145,7 @@ export async function handleSendChat( return; } + const refreshSessions = isChatResetCommand(message); if (messageOverride == null) { host.chatMessage = ""; // Clear attachments when sending @@ -149,13 +163,14 @@ export async function handleSendChat( attachments: hasAttachments ? attachmentsToSend : undefined, previousAttachments: messageOverride == null ? attachments : undefined, restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), + refreshSessions, }); } export async function refreshChat(host: ChatHost) { await Promise.all([ loadChatHistory(host as unknown as MoltbotApp), - loadSessions(host as unknown as MoltbotApp), + loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }), refreshChatAvatar(host), ]); scheduleChatScroll(host as unknown as Parameters[0], true); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..ba1df61e1 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -26,6 +26,7 @@ import { import type { MoltbotApp } from "./app"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import { loadAssistantIdentity } from "./controllers/assistant-identity"; +import { loadSessions } from "./controllers/sessions"; type GatewayHost = { settings: UiSettings; @@ -50,6 +51,7 @@ type GatewayHost = { assistantAgentId: string | null; sessionKey: string; chatRunId: string | null; + refreshSessionsAfterChat: boolean; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; }; @@ -194,6 +196,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { void flushChatQueueForEvent( host as unknown as Parameters[0], ); + if (host.refreshSessionsAfterChat) { + host.refreshSessionsAfterChat = false; + if (state === "final") { + void loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }); + } + } } if (state === "final") void loadChatHistory(host as unknown as MoltbotApp); return; diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 71af9d202..cf5214250 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -35,6 +35,9 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); + applySettingsFromUrl( + host as unknown as Parameters[0], + ); syncTabWithLocation( host as unknown as Parameters[0], true, @@ -46,9 +49,6 @@ export function handleConnected(host: LifecycleHost) { host as unknown as Parameters[0], ); window.addEventListener("popstate", host.popStateHandler); - applySettingsFromUrl( - host as unknown as Parameters[0], - ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 22f8d90db..c2190e1c9 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -5,6 +5,7 @@ import type { AppViewState } from "./app-view-state"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { icons } from "./icons"; import { loadChatHistory } from "./controllers/chat"; +import { refreshChat } from "./app-chat"; import { syncUrlWithSessionKey } from "./app-settings"; import type { SessionsListResult } from "./types"; import type { ThemeMode } from "./theme"; @@ -39,7 +40,12 @@ export function renderTab(state: AppViewState, tab: Tab) { } export function renderChatControls(state: AppViewState) { - const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); + const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); + const sessionOptions = resolveSessionOptions( + state.sessionKey, + state.sessionsResult, + mainSessionKey, + ); const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -87,9 +93,9 @@ export function renderChatControls(state: AppViewState) { ?disabled=${state.chatLoading || !state.connected} @click=${() => { state.resetToolStream(); - void loadChatHistory(state); + void refreshChat(state as unknown as Parameters[0]); }} - title="Refresh chat history" + title="Refresh chat data" > ${refreshIcon} @@ -132,15 +138,47 @@ export function renderChatControls(state: AppViewState) { `; } -function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { +type SessionDefaultsSnapshot = { + mainSessionKey?: string; + mainKey?: string; +}; + +function resolveMainSessionKey( + hello: AppViewState["hello"], + sessions: SessionsListResult | null, +): string | null { + const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; + const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + if (mainSessionKey) return mainSessionKey; + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + if (mainKey) return mainKey; + if (sessions?.sessions?.some((row) => row.key === "main")) return "main"; + return null; +} + +function resolveSessionOptions( + sessionKey: string, + sessions: SessionsListResult | null, + mainSessionKey?: string | null, +) { const seen = new Set(); const options: Array<{ key: string; displayName?: string }> = []; + const resolvedMain = + mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey); const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey); - // Add current session key first - seen.add(sessionKey); - options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + // Add main session key first + if (mainSessionKey) { + seen.add(mainSessionKey); + options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName }); + } + + // Add current session key next + if (!seen.has(sessionKey)) { + seen.add(sessionKey); + options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + } // Add sessions from the result if (sessions?.sessions) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 26f4a5836..50ffcdf76 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -258,6 +258,7 @@ export class MoltbotApp extends LitElement { private logsScrollFrame: number | null = null; private toolStreamById = new Map(); private toolStreamOrder: string[] = []; + refreshSessionsAfterChat = false; basePath = ""; private popStateHandler = () => onPopStateInternal( diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 5c5077037..7e87f1911 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -14,18 +14,29 @@ export type SessionsState = { sessionsIncludeUnknown: boolean; }; -export async function loadSessions(state: SessionsState) { +export async function loadSessions( + state: SessionsState, + overrides?: { + activeMinutes?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; + }, +) { if (!state.client || !state.connected) return; if (state.sessionsLoading) return; state.sessionsLoading = true; state.sessionsError = null; try { + const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; + const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; + const activeMinutes = + overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); + const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0); const params: Record = { - includeGlobal: state.sessionsIncludeGlobal, - includeUnknown: state.sessionsIncludeUnknown, + includeGlobal, + includeUnknown, }; - const activeMinutes = toNumber(state.sessionsFilterActive, 0); - const limit = toNumber(state.sessionsFilterLimit, 0); if (activeMinutes > 0) params.activeMinutes = activeMinutes; if (limit > 0) params.limit = limit; const res = (await state.client.request("sessions.list", params)) as