diff --git a/docs/channels/clickclack.md b/docs/channels/clickclack.md index 40b549bcbf24..7846b1e5c630 100644 --- a/docs/channels/clickclack.md +++ b/docs/channels/clickclack.md @@ -469,7 +469,9 @@ Accepted bot messages also pass through OpenClaw's shared bot-pair loop guard. Use `botLoopProtection` on the account or `channels.defaults.botLoopProtection` to tune its window, budget, cooldown, or enabled state. Group-level `allowBots` and `botLoopProtection` values follow the same exact-channel, wildcard, then -account-level precedence as the other group policies. +account-level precedence as the other group policies. Top-level channel +messages share a channel budget, while replies in different ClickClack threads +use independent thread-root budgets. Older ClickClack responses may omit `author.kind`. Those messages intentionally remain on the legacy `allowFrom` path: `allowFrom: ["*"]` can admit them, and diff --git a/extensions/clickclack/src/access.ts b/extensions/clickclack/src/access.ts index 05edd9a966eb..f1efcd44b4a2 100644 --- a/extensions/clickclack/src/access.ts +++ b/extensions/clickclack/src/access.ts @@ -53,6 +53,18 @@ type ClickClackPreparedInboundRoute = { revoked: boolean; }; +function resolveClickClackBotLoopConversationId(params: { + message: ClickClackMessage; + isDirect: boolean; +}): string { + if (params.message.parent_message_id && params.message.thread_root_id) { + return params.message.thread_root_id; + } + return params.isDirect + ? (params.message.direct_conversation_id ?? params.message.author_id) + : (params.message.channel_id ?? params.message.thread_root_id ?? params.message.author_id); +} + function resolveAccountAgentRoute(params: { cfg: OpenClawConfig; account: ResolvedClickClackAccount; @@ -235,11 +247,10 @@ export async function resolveClickClackInboundAccess(params: { // The workspace is the shared boundary; account IDs would let the // same conversation evade the budget by alternating receivers. scopeId: params.account.workspace, - conversationId: preparedRoute.isDirect - ? (params.message.direct_conversation_id ?? params.message.author_id) - : (params.message.channel_id ?? - params.message.thread_root_id ?? - params.message.author_id), + conversationId: resolveClickClackBotLoopConversationId({ + message: params.message, + isDirect: preparedRoute.isDirect, + }), senderId: params.message.author_id, receiverId: params.account.botUserId, eventId: params.message.id, diff --git a/extensions/clickclack/src/group-policy.ts b/extensions/clickclack/src/group-policy.ts index a568a96b9728..fbbf989cb891 100644 --- a/extensions/clickclack/src/group-policy.ts +++ b/extensions/clickclack/src/group-policy.ts @@ -50,9 +50,7 @@ export function resolveClickClackBotPolicy(params: { // account-wide override. const groups = channelKey ? account.groups : undefined; const wildcard = groups?.["*"]; - const exact = channelKey - ? Object.entries(groups ?? {}).find(([key]) => key.trim() === channelKey)?.[1] - : undefined; + const exact = channelKey ? groups?.[channelKey] : undefined; return { allowBots: exact?.allowBots ?? wildcard?.allowBots ?? account.allowBots ?? false, botLoopProtection: mergePairLoopGuardConfig( diff --git a/extensions/clickclack/src/inbound.mention-gating.test.ts b/extensions/clickclack/src/inbound.mention-gating.test.ts index 67089ff71f4c..98a4dd6082d5 100644 --- a/extensions/clickclack/src/inbound.mention-gating.test.ts +++ b/extensions/clickclack/src/inbound.mention-gating.test.ts @@ -286,11 +286,45 @@ describe("ClickClack inbound mention gating", () => { conversationId: "chn_1", senderId: "usr_sender", receiverId: "usr_receiver", + eventId: "msg_1", defaultsConfig: { maxEventsPerWindow: 7 }, defaultEnabled: true, }); }); + it("isolates bot loop budgets by ClickClack thread root", async () => { + const runtime = createRuntime(); + setClickClackRuntime(runtime); + const account = createAgentAccount({ allowFrom: ["usr_sender"], allowBots: true }); + const author = createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }); + + const threadA = await resolveClickClackInboundAccess({ + account, + config: {} satisfies CoreConfig, + message: createMessage({ + id: "msg_thread_a_reply", + author_id: "usr_sender", + parent_message_id: "msg_thread_a", + thread_root_id: "msg_thread_a", + author, + }), + }); + const threadB = await resolveClickClackInboundAccess({ + account, + config: {} satisfies CoreConfig, + message: createMessage({ + id: "msg_thread_b_reply", + author_id: "usr_sender", + parent_message_id: "msg_thread_b", + thread_root_id: "msg_thread_b", + author, + }), + }); + + expect(threadA.botLoopProtection?.conversationId).toBe("msg_thread_a"); + expect(threadB.botLoopProtection?.conversationId).toBe("msg_thread_b"); + }); + it("does not let bot opt-in bypass the wildcard human allowFrom default", async () => { const runtime = createRuntime(); setClickClackRuntime(runtime);