From 0a1eeedc10c7b481e8d02f7568d2ac3c61efe640 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 07:07:37 +0000 Subject: [PATCH] fix: unblock control commands during active runs --- CHANGELOG.md | 1 + docs/concepts/compaction.md | 2 +- docs/gateway/configuration.md | 2 +- docs/start/faq.md | 1 + docs/web/control-ui.md | 2 +- src/auto-reply/command-detection.ts | 14 +++++++++++ src/auto-reply/reply/abort.test.ts | 24 ++++++++++++++++++- src/auto-reply/reply/abort.ts | 12 ++-------- ...gram-bot.installs-grammy-throttler.test.ts | 15 ++++++++++++ src/telegram/bot.ts | 7 ++++++ 10 files changed, 66 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19fca623b..4e66812ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). +- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers. - Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. - Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves). diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index 3743bfc7b..95b0586c4 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -16,7 +16,7 @@ Compaction **summarizes older conversation** into a compact summary entry and ke Compaction **persists** in the session’s JSONL history. ## Configuration -See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings. +See [Compaction config & modes](/concepts/compaction) for the `agents.defaults.compaction` settings. ## Auto-compaction (default on) When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a1f90fd7d..0aede3ec8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1541,7 +1541,7 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details. #### `agents.defaults.compaction` (reserve headroom + memory flush) -`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction). +`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/concepts/compaction](/concepts/compaction). `agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens` value for Pi compaction (default: `20000`). Set it to `0` to disable the floor. diff --git a/docs/start/faq.md b/docs/start/faq.md index 20eff130e..ed85fbed3 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -1412,6 +1412,7 @@ abort esc wait exit +interrupt ``` These are abort triggers (not slash commands). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 0408131d8..b7f5d2e18 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -62,7 +62,7 @@ Notes: - Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion. - Stop: - Click **Stop** (calls `chat.abort`) - - Type `/stop` (or `stop|esc|abort|wait|exit`) to abort out-of-band + - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session ## Tailnet access (recommended) diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index 3323903d9..40cfe7150 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -5,6 +5,7 @@ import { listChatCommandsForConfig, normalizeCommandBody, } from "./commands-registry.js"; +import { isAbortTrigger } from "./reply/abort.js"; export function hasControlCommand( text?: string, @@ -31,3 +32,16 @@ export function hasControlCommand( } return false; } + +export function isControlCommandMessage( + text?: string, + cfg?: ClawdbotConfig, + options?: CommandNormalizeOptions, +): boolean { + if (!text) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + if (hasControlCommand(trimmed, cfg, options)) return true; + const normalized = normalizeCommandBody(trimmed, options).trim().toLowerCase(); + return isAbortTrigger(normalized); +} diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 3d5bae140..804f5ed82 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; -import { isAbortTrigger } from "./abort.js"; +import { isAbortTrigger, tryFastAbortFromMessage } from "./abort.js"; import { initSessionState } from "./session.js"; describe("abort detection", () => { @@ -35,8 +35,30 @@ describe("abort detection", () => { expect(isAbortTrigger("abort")).toBe(true); expect(isAbortTrigger("wait")).toBe(true); expect(isAbortTrigger("exit")).toBe(true); + expect(isAbortTrigger("interrupt")).toBe(true); expect(isAbortTrigger("hello")).toBe(false); // /stop is NOT matched by isAbortTrigger - it's handled separately expect(isAbortTrigger("/stop")).toBe(false); }); + + it("fast-aborts even when text commands are disabled", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-abort-")); + const storePath = path.join(root, "sessions.json"); + const cfg = { session: { store: storePath }, commands: { text: false } } as ClawdbotConfig; + + const result = await tryFastAbortFromMessage({ + ctx: { + CommandBody: "/stop", + RawBody: "/stop", + SessionKey: "telegram:123", + Provider: "telegram", + Surface: "telegram", + From: "telegram:123", + To: "telegram:123", + }, + cfg, + }); + + expect(result.handled).toBe(true); + }); }); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 2af6dc232..32bdca299 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -9,11 +9,11 @@ import { } from "../../config/sessions.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveCommandAuthorization } from "../command-auth.js"; -import { normalizeCommandBody, shouldHandleTextCommands } from "../commands-registry.js"; +import { normalizeCommandBody } from "../commands-registry.js"; import type { MsgContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; -const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]); +const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit", "interrupt"]); const ABORT_MEMORY = new Map(); export function isAbortTrigger(text?: string): boolean { @@ -57,14 +57,6 @@ export async function tryFastAbortFromMessage(params: { cfg: ClawdbotConfig; }): Promise<{ handled: boolean; aborted: boolean }> { const { ctx, cfg } = params; - const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase(); - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface, - commandSource: ctx.CommandSource, - }); - if (!allowTextCommands) return { handled: false, aborted: false }; - const commandAuthorized = ctx.CommandAuthorized ?? true; const auth = resolveCommandAuthorization({ ctx, diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index 18cce5613..3a143a4f1 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -251,6 +251,21 @@ describe("createTelegramBot", () => { update: { message: { chat: { id: 555 } } }, }), ).toBe("telegram:555"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "/stop" }, + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "/status" }, + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "stop" }, + }), + ).toBe("telegram:123:control"); }); it("routes callback_query payloads as messages and answers callbacks", async () => { onSpy.mockReset(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b122a1777..ecdb115de 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -4,6 +4,7 @@ import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions } from "grammy"; import { Bot, webhookCallback } from "grammy"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { isControlCommandMessage } from "../auto-reply/command-detection.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import { @@ -66,6 +67,12 @@ export function getTelegramSequentialKey(ctx: { ctx.update?.edited_message ?? ctx.update?.callback_query?.message; const chatId = msg?.chat?.id ?? ctx.chat?.id; + const rawText = msg?.text ?? msg?.caption; + const botUsername = (ctx as { me?: { username?: string } }).me?.username; + if (rawText && isControlCommandMessage(rawText, undefined, botUsername ? { botUsername } : undefined)) { + if (typeof chatId === "number") return `telegram:${chatId}:control`; + return "telegram:control"; + } const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum; const threadId = resolveTelegramForumThreadId({ isForum,