diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8773edab7..132cf05836a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - CLI/Voice Call: scope `voicecall` command activation to the Voice Call plugin so setup and smoke checks no longer broad-load unrelated plugin runtimes or hang after printing JSON. Thanks @vincentkoc. - Doctor/plugins: warn when restrictive `plugins.allow` is paired with wildcard or plugin-owned tool allowlists, making the exclusive plugin allowlist behavior visible before users hit empty callable-tool runs. Refs #58009 and #64982. Thanks @KR-Python and @BKF-Gitty. - Google Meet/Voice Call: keep Twilio Meet joins in conversation mode and reuse the realtime intro prompt when no voice-call-specific intro is configured, so answered phone bridge calls speak instead of joining silently. Refs #72478. Thanks @DougButdorf. +- Auto-reply/group chats: keep the `message` tool available for message-tool-only visible replies and apply group-scoped tool policy before deciding fallback delivery, so Discord/Slack-style rooms reply visibly in the correct channel after upgrades. Fixes #74842; refs #75207. Thanks @davelutztx and @aa-on-ai. - Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07. - Telegram/agents: keep typing indicators and optional generation tools off the reply critical path, so fresh Telegram replies no longer stall while provider catalogs and media models load. (#75360) Thanks @obviyus. - Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337. diff --git a/docs/channels/qa-channel.md b/docs/channels/qa-channel.md index 9fa85e82cf9..bf649a279df 100644 --- a/docs/channels/qa-channel.md +++ b/docs/channels/qa-channel.md @@ -14,7 +14,9 @@ read_when: - Slack-class target grammar: - `dm:` - `channel:` + - `group:` - `thread:/` +- Shared `channel:` and `group:` conversations are surfaced to agents as group/channel room turns, so they exercise the same visible-reply and message-tool routing policy used by Discord, Slack, Telegram, and similar transports. - HTTP-backed synthetic bus for inbound message injection, outbound transcript capture, thread creation, reactions, edits, deletes, and search/read actions. - Host-side self-check runner that writes a Markdown report to `.artifacts/qa-e2e/`. diff --git a/extensions/qa-channel/src/bus-client.test.ts b/extensions/qa-channel/src/bus-client.test.ts index 899cbb50e95..10a4baf268c 100644 --- a/extensions/qa-channel/src/bus-client.test.ts +++ b/extensions/qa-channel/src/bus-client.test.ts @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { afterEach, describe, expect, it } from "vitest"; -import { getQaBusState, pollQaBus } from "./bus-client.js"; +import { buildQaTarget, getQaBusState, parseQaTarget, pollQaBus } from "./bus-client.js"; async function startJsonServer( handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string }, @@ -40,6 +40,19 @@ describe("qa-bus client", () => { await Promise.all(stops.splice(0).map((stop) => stop())); }); + it("roundtrips explicit group targets", () => { + expect(parseQaTarget("group:ops-room")).toEqual({ + chatType: "group", + conversationId: "ops-room", + }); + expect( + buildQaTarget({ + chatType: "group", + conversationId: "ops-room", + }), + ).toBe("group:ops-room"); + }); + it("rejects malformed JSON responses instead of throwing from the stream callback", async () => { const server = await startJsonServer(() => ({ body: '{"cursor":1,"events":[', diff --git a/extensions/qa-channel/src/bus-client.ts b/extensions/qa-channel/src/bus-client.ts index a5cae3ffd1d..a1c4bffbe3d 100644 --- a/extensions/qa-channel/src/bus-client.ts +++ b/extensions/qa-channel/src/bus-client.ts @@ -118,7 +118,7 @@ export function normalizeQaTarget(raw: string): string | undefined { } export function parseQaTarget(raw: string): { - chatType: "direct" | "channel"; + chatType: "direct" | "channel" | "group"; conversationId: string; threadId?: string; } { @@ -144,6 +144,12 @@ export function parseQaTarget(raw: string): { conversationId: normalized.slice("channel:".length), }; } + if (normalized.startsWith("group:")) { + return { + chatType: "group", + conversationId: normalized.slice("group:".length), + }; + } if (normalized.startsWith("dm:")) { return { chatType: "direct", @@ -157,14 +163,14 @@ export function parseQaTarget(raw: string): { } export function buildQaTarget(params: { - chatType: "direct" | "channel"; + chatType: "direct" | "channel" | "group"; conversationId: string; threadId?: string | null; }) { if (params.threadId) { return `thread:${params.conversationId}/${params.threadId}`; } - return `${params.chatType === "direct" ? "dm" : "channel"}:${params.conversationId}`; + return `${params.chatType === "direct" ? "dm" : params.chatType}:${params.conversationId}`; } export async function pollQaBus(params: { diff --git a/extensions/qa-channel/src/channel-actions.ts b/extensions/qa-channel/src/channel-actions.ts index 8d1cc01075a..ca257706466 100644 --- a/extensions/qa-channel/src/channel-actions.ts +++ b/extensions/qa-channel/src/channel-actions.ts @@ -65,7 +65,7 @@ function readQaSendTarget(params: Record) { if (!target) { return undefined; } - if (/^(dm|channel):|^thread:[^/]+\/.+/i.test(target)) { + if (/^(dm|channel|group):|^thread:[^/]+\/.+/i.test(target)) { return target; } return buildQaTarget({ chatType: "channel", conversationId: target }); diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 3d09d42192a..066ead1bca7 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -26,6 +26,14 @@ function createMockQaRuntime(params?: { const sessionUpdatedAt = new Map(); return { channel: { + mentions: { + buildMentionRegexes() { + return [/^@openclaw\b/i]; + }, + matchesMentionPatterns(text: string, patterns: RegExp[]) { + return patterns.some((pattern) => pattern.test(text)); + }, + }, routing: { resolveAgentRoute({ accountId, @@ -142,6 +150,35 @@ describe("qa-channel plugin", () => { expect(route?.threadId).toBeUndefined(); }); + it("derives group outbound session routes from explicit group targets", async () => { + const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({ + cfg: {}, + agentId: "main", + accountId: "default", + target: "group:qa-room", + }); + + expect(route).toMatchObject({ + sessionKey: "agent:main:qa-channel:group:group:qa-room", + baseSessionKey: "agent:main:qa-channel:group:group:qa-room", + chatType: "group", + to: "group:qa-room", + }); + }); + + it("normalizes explicit group targets for session group policy lookup", () => { + const resolved = qaChannelPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "group:qa-room", + }); + + expect(resolved).toMatchObject({ + id: "qa-room", + baseConversationId: "qa-room", + parentConversationCandidates: ["qa-room"], + }); + }); + it("recovers thread-aware outbound session routes from currentSessionKey", async () => { const route = await qaChannelPlugin.messaging?.resolveOutboundSessionRoute?.({ cfg: {}, @@ -197,6 +234,53 @@ describe("qa-channel plugin", () => { } }); + it( + "surfaces shared group traffic with the room target as From", + { timeout: 20_000 }, + async () => { + let dispatchedCtx: Record | null = null; + const harness = await startQaChannelTestHarness({ + allowFrom: ["*"], + runtime: createMockQaRuntime({ + onDispatch: (ctx) => { + dispatchedCtx = ctx; + }, + }), + }); + + try { + harness.state.addInboundMessage({ + conversation: { id: "qa-room", kind: "group", title: "QA Room" }, + senderId: "alice", + senderName: "Alice", + text: "@openclaw hello", + }); + + const outbound = await harness.state.waitFor({ + kind: "message-text", + textIncludes: "qa-echo: @openclaw hello", + direction: "outbound", + timeoutMs: 15_000, + }); + + expect(dispatchedCtx).toMatchObject({ + ChatType: "group", + From: "group:qa-room", + To: "group:qa-room", + SessionKey: "qa-agent:group:group:qa-room", + SenderId: "alice", + GroupSubject: "QA Room", + }); + expect("conversation" in outbound && outbound.conversation).toMatchObject({ + id: "qa-room", + kind: "group", + }); + } finally { + await harness.stop(); + } + }, + ); + it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => { let dispatchedCtx: Record | null = null; const harness = await startQaChannelTestHarness({ @@ -396,4 +480,41 @@ describe("qa-channel plugin", () => { await bus.stop(); } }); + + it("routes group send targets to group qa bus conversations", async () => { + installQaChannelTestRegistry(); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + + try { + const cfg = createQaChannelConfig({ baseUrl: bus.baseUrl }); + + const result = await qaChannelPlugin.actions?.handleAction?.({ + channel: "qa-channel", + action: "send", + cfg, + accountId: "default", + params: { + target: "group:qa-room", + message: "hello group", + }, + }); + const payload = extractToolPayload(result); + expect(payload).toMatchObject({ message: { text: "hello group" } }); + + const outbound = await state.waitFor({ + kind: "message-text", + direction: "outbound", + textIncludes: "hello group", + timeoutMs: 5_000, + }); + expect("conversation" in outbound).toBe(true); + if (!("conversation" in outbound)) { + throw new Error("expected outbound message match"); + } + expect(outbound.conversation).toMatchObject({ id: "qa-room", kind: "group" }); + } finally { + await bus.stop(); + } + }); }); diff --git a/extensions/qa-channel/src/channel.ts b/extensions/qa-channel/src/channel.ts index 3b3388bb4f7..e3c130df4d2 100644 --- a/extensions/qa-channel/src/channel.ts +++ b/extensions/qa-channel/src/channel.ts @@ -64,8 +64,8 @@ export const qaChannelPlugin: ChannelPlugin = createCh inferTargetChatType: ({ to }) => parseQaTarget(to).chatType, targetResolver: { looksLikeId: (raw) => - /^((dm|channel):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0, - hint: "", + /^((dm|channel|group):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0, + hint: "", }, resolveOutboundSessionRoute: ({ cfg, @@ -83,7 +83,12 @@ export const qaChannelPlugin: ChannelPlugin = createCh channel: CHANNEL_ID, accountId, peer: { - kind: parsed.chatType === "direct" ? "direct" : "channel", + kind: + parsed.chatType === "direct" + ? "direct" + : parsed.chatType === "group" + ? "group" + : "channel", id: buildQaTarget(parsed), }, chatType: parsed.chatType, @@ -99,6 +104,18 @@ export const qaChannelPlugin: ChannelPlugin = createCh route.chatType !== "direct" || (cfg.session?.dmScope ?? "main") !== "main", }); }, + resolveSessionConversation: ({ rawId }) => { + const parsed = parseQaTarget(rawId); + if (parsed.chatType === "direct") { + return null; + } + return { + id: parsed.conversationId, + threadId: parsed.threadId, + baseConversationId: parsed.conversationId, + parentConversationCandidates: [parsed.conversationId], + }; + }, }, status: qaChannelStatus, gateway: { diff --git a/extensions/qa-channel/src/config-schema.ts b/extensions/qa-channel/src/config-schema.ts index 545da4f7c97..f521e0de308 100644 --- a/extensions/qa-channel/src/config-schema.ts +++ b/extensions/qa-channel/src/config-schema.ts @@ -1,4 +1,7 @@ -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; +import { + ToolPolicySchema, + buildChannelConfigSchema, +} from "openclaw/plugin-sdk/channel-config-schema"; import { z } from "openclaw/plugin-sdk/zod"; const QaChannelActionConfigSchema = z @@ -10,6 +13,14 @@ const QaChannelActionConfigSchema = z }) .strict(); +const QaChannelGroupConfigSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema.optional(), + toolsBySender: z.record(z.string(), ToolPolicySchema).optional(), + }) + .strict(); + export const QaChannelAccountConfigSchema = z .object({ name: z.string().optional(), @@ -19,6 +30,9 @@ export const QaChannelAccountConfigSchema = z botDisplayName: z.string().optional(), pollTimeoutMs: z.number().int().min(100).max(30_000).optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groups: z.record(z.string(), QaChannelGroupConfigSchema).optional(), defaultTo: z.string().optional(), actions: QaChannelActionConfigSchema.optional(), }) diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index 9afc823cc62..a190b6979ff 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -77,7 +77,12 @@ export async function handleQaInbound(params: { channel: params.channelId, accountId: params.account.accountId, peer: { - kind: inbound.conversation.kind === "direct" ? "direct" : "channel", + kind: + inbound.conversation.kind === "direct" + ? "direct" + : inbound.conversation.kind === "group" + ? "group" + : "channel", id: target, }, }); @@ -113,10 +118,7 @@ export async function handleQaInbound(params: { BodyForAgent: inbound.text, RawBody: inbound.text, CommandBody: inbound.text, - From: buildQaTarget({ - chatType: inbound.conversation.kind, - conversationId: inbound.senderId, - }), + From: target, To: target, SessionKey: route.sessionKey, AccountId: route.accountId ?? params.account.accountId, @@ -127,10 +129,9 @@ export async function handleQaInbound(params: { inbound.conversation.title || inbound.senderName || inbound.conversation.id, - GroupSubject: - inbound.conversation.kind === "channel" - ? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id - : undefined, + GroupSubject: isGroup + ? inbound.threadTitle || inbound.conversation.title || inbound.conversation.id + : undefined, GroupChannel: inbound.conversation.kind === "channel" ? inbound.conversation.id : undefined, NativeChannelId: inbound.conversation.id, MessageThreadId: inbound.threadId, diff --git a/extensions/qa-channel/src/types.ts b/extensions/qa-channel/src/types.ts index 0a1c4a2538f..6b2e242739a 100644 --- a/extensions/qa-channel/src/types.ts +++ b/extensions/qa-channel/src/types.ts @@ -13,6 +13,16 @@ export type QaChannelAccountConfig = { botDisplayName?: string; pollTimeoutMs?: number; allowFrom?: Array; + groupPolicy?: "open" | "allowlist" | "disabled"; + groupAllowFrom?: Array; + groups?: Record< + string, + { + requireMention?: boolean; + tools?: Record; + toolsBySender?: Record>; + } + >; defaultTo?: string; actions?: QaChannelActionConfig; }; diff --git a/extensions/qa-lab/src/bus-queries.ts b/extensions/qa-lab/src/bus-queries.ts index be478257be9..b7194f8e534 100644 --- a/extensions/qa-lab/src/bus-queries.ts +++ b/extensions/qa-lab/src/bus-queries.ts @@ -39,6 +39,11 @@ export function normalizeConversationFromTarget(target: string): { conversation: { id: trimmed.slice("channel:".length), kind: "channel" }, }; } + if (trimmed.startsWith("group:")) { + return { + conversation: { id: trimmed.slice("group:".length), kind: "group" }, + }; + } if (trimmed.startsWith("dm:")) { return { conversation: { id: trimmed.slice("dm:".length), kind: "direct" }, diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 5d92c69853c..1999dd8beac 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -149,6 +149,9 @@ const QA_STREAMING_PROMPT_RE = /(?:partial|quiet) streaming qa check/i; const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i; const QA_TOOL_PROGRESS_ERROR_PROMPT_RE = /tool progress error qa check/i; const QA_TOOL_PROGRESS_PROMPT_RE = /tool progress qa check/i; +const QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE = /qa group visible reply tool check/i; +const QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE = + /qa group message unavailable fallback check/i; const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i; const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i; const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK"; @@ -1325,6 +1328,21 @@ async function buildResponsesPayload( }, ]); } + if (QA_GROUP_VISIBLE_REPLY_TOOL_PROMPT_RE.test(allInputText)) { + const marker = exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-TOOL-OK"; + if (!toolOutput && hasDeclaredTool(body, "message")) { + return buildToolCallEventsWithArgs("message", { + action: "send", + message: marker, + }); + } + return buildAssistantEvents(""); + } + if (QA_GROUP_MESSAGE_UNAVAILABLE_FALLBACK_PROMPT_RE.test(allInputText)) { + return buildAssistantEvents( + exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-FALLBACK-OK", + ); + } if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) { return buildAssistantEvents(exactReplyDirective); } diff --git a/extensions/qa-lab/src/runtime-api.ts b/extensions/qa-lab/src/runtime-api.ts index 07e7da977de..e8607e9abc7 100644 --- a/extensions/qa-lab/src/runtime-api.ts +++ b/extensions/qa-lab/src/runtime-api.ts @@ -24,6 +24,7 @@ export { export type { QaBusAttachment, QaBusConversation, + QaBusConversationKind, QaBusCreateThreadInput, QaBusDeleteMessageInput, QaBusEditMessageInput, diff --git a/extensions/qa-lab/src/suite.ts b/extensions/qa-lab/src/suite.ts index 4363e78e3c5..64a8bbb9239 100644 --- a/extensions/qa-lab/src/suite.ts +++ b/extensions/qa-lab/src/suite.ts @@ -704,6 +704,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise applyQaMergePatch(cfg, gatewayConfigPatch) as OpenClawConfig : undefined, }); + writeQaSuiteProgress( + progressEnabled, + `gateway ready: ${sanitizeQaSuiteProgressValue(gateway.baseUrl)}`, + ); lab.setControlUi({ controlUiProxyTarget: gateway.baseUrl, controlUiToken: gateway.token, diff --git a/qa/scenarios/channels/group-message-tool-unavailable-fallback.md b/qa/scenarios/channels/group-message-tool-unavailable-fallback.md new file mode 100644 index 00000000000..4ecbb1f021e --- /dev/null +++ b/qa/scenarios/channels/group-message-tool-unavailable-fallback.md @@ -0,0 +1,98 @@ +# Group fallback when message tool is unavailable + +```yaml qa-scenario +id: group-message-tool-unavailable-fallback +title: Group fallback when message tool is unavailable +surface: channel +coverage: + primary: + - channels.group-visible-replies + secondary: + - channels.qa-channel + - tools.message +objective: Reproduce the group-visible-reply bug class where message_tool mode selected tool-only delivery even though group tool policy removed the message tool. +gatewayConfigPatch: + messages: + groupChat: + visibleReplies: message_tool + channels: + qa-channel: + groups: + qa-fallback-room: + tools: + allow: + - read +successCriteria: + - The group policy removes the message tool for this room. + - The mock provider returns a normal final answer with the marker. + - OpenClaw falls back to automatic delivery and posts the marker to the same group. +docsRefs: + - docs/channels/groups.md + - docs/channels/qa-channel.md +codeRefs: + - src/auto-reply/reply/dispatch-from-config.ts + - extensions/qa-channel/src/inbound.ts +execution: + kind: flow + summary: Verify message_tool visible replies degrade to automatic delivery when the active group policy removes message. + config: + conversationId: qa-fallback-room + promptSnippet: qa group message unavailable fallback check + prompt: "@openclaw qa group message unavailable fallback check. exact marker: `QA-GROUP-FALLBACK-OK`" + expectedMarker: QA-GROUP-FALLBACK-OK +``` + +```yaml qa-flow +steps: + - name: falls back to final-answer delivery when message is not available + actions: + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - call: reset + - set: requestCountBefore + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0" + - call: state.addInboundMessage + args: + - conversation: + id: + expr: config.conversationId + kind: group + title: QA Fallback Room + senderId: alice + senderName: Alice + text: + expr: config.prompt + - call: waitForOutboundMessage + saveAs: outbound + args: + - ref: state + - lambda: + params: [candidate] + expr: "candidate.conversation.id === config.conversationId && candidate.conversation.kind === 'group' && !candidate.threadId && candidate.text.includes(config.expectedMarker)" + - expr: liveTurnTimeoutMs(env, 180000) + - set: matchingOutbound + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId && message.conversation.kind === 'group' && String(message.text ?? '').includes(config.expectedMarker))" + - assert: + expr: matchingOutbound.length === 1 + message: + expr: "`expected exactly one fallback group reply, saw ${matchingOutbound.length}`" + - set: scenarioRequests + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)) : []" + - assert: + expr: "!env.mock || scenarioRequests.length > 0" + message: expected mock request evidence for fallback scenario + - assert: + expr: "!env.mock || scenarioRequests.every((request) => request.plannedToolName !== 'message')" + message: + expr: "`message tool should not be planned when group policy removes it, saw ${JSON.stringify(scenarioRequests.map((request) => request.plannedToolName ?? null))}`" + detailsExpr: "`${outbound.conversation.kind}:${outbound.conversation.id}:${outbound.text}`" +``` diff --git a/qa/scenarios/channels/group-visible-reply-tool.md b/qa/scenarios/channels/group-visible-reply-tool.md new file mode 100644 index 00000000000..fe012192c4c --- /dev/null +++ b/qa/scenarios/channels/group-visible-reply-tool.md @@ -0,0 +1,96 @@ +# Group visible reply via message tool + +```yaml qa-scenario +id: group-visible-reply-tool +title: Group visible reply via message tool +surface: channel +coverage: + primary: + - channels.group-visible-replies + secondary: + - channels.qa-channel + - tools.message +objective: Verify a group-sourced QA channel turn replies visibly through message(action=send) in the same room. +gatewayConfigPatch: + messages: + groupChat: + visibleReplies: message_tool +successCriteria: + - Agent receives a synthetic shared-room turn. + - Mock provider calls the shared message tool instead of relying on final-answer delivery. + - The visible reply lands once in the same group transcript. +docsRefs: + - docs/channels/groups.md + - docs/channels/qa-channel.md +codeRefs: + - extensions/qa-channel/src/inbound.ts + - extensions/qa-channel/src/outbound.ts + - src/auto-reply/reply/dispatch-from-config.ts +execution: + kind: flow + summary: Send a mentioned group message and verify visible output uses the message tool in the source group. + config: + conversationId: qa-visible-tool-room + promptSnippet: qa group visible reply tool check + prompt: "@openclaw qa group visible reply tool check. Use the visible room reply path. exact marker: `QA-GROUP-TOOL-OK`" + expectedMarker: QA-GROUP-TOOL-OK +``` + +```yaml qa-flow +steps: + - name: posts visible room output through message tool + actions: + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - call: reset + - set: requestCountBefore + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0" + - call: state.addInboundMessage + args: + - conversation: + id: + expr: config.conversationId + kind: group + title: QA Visible Tool Room + senderId: alice + senderName: Alice + text: + expr: config.prompt + - call: waitForCondition + args: + - lambda: + async: true + params: [] + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).find((request) => String(request.allInputText ?? '').includes(config.promptSnippet)) : true" + - expr: liveTurnTimeoutMs(env, 180000) + - set: scenarioRequests + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)) : []" + - assert: + expr: "!env.mock || scenarioRequests.some((request) => request.plannedToolName === 'message' && request.plannedToolArgs?.action === 'send' && request.plannedToolArgs?.message === config.expectedMarker)" + message: + expr: "`expected message(action=send) with marker, saw ${JSON.stringify(scenarioRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, toolOutput: request.toolOutput ?? '', tools: Array.isArray(request.body?.tools) ? request.body.tools.map((tool) => tool?.name ?? tool?.function?.name ?? tool?.type ?? null).filter(Boolean).slice(0, 25) : [] })))} `" + - call: waitForOutboundMessage + saveAs: outbound + args: + - ref: state + - lambda: + params: [candidate] + expr: "candidate.conversation.id === config.conversationId && candidate.conversation.kind === 'group' && !candidate.threadId && candidate.text.includes(config.expectedMarker)" + - expr: liveTurnTimeoutMs(env, 180000) + - set: matchingOutbound + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.conversationId && message.conversation.kind === 'group' && String(message.text ?? '').includes(config.expectedMarker))" + - assert: + expr: matchingOutbound.length === 1 + message: + expr: "`expected exactly one visible group reply, saw ${matchingOutbound.length}`" + detailsExpr: "`${outbound.conversation.kind}:${outbound.conversation.id}:${outbound.text}`" +``` diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index b3cc2791d8d..1d3bd8f8a01 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -244,13 +244,11 @@ export function resolveGroupContextFromSessionKey(sessionKey?: string | null): { const conversationKey = threadId ? baseSessionKey : raw; const conversation = parseRawSessionConversationRef(conversationKey); if (conversation) { - const resolvedConversation = /:(?:sender|thread|topic):/iu.test(conversation.rawId) - ? resolveSessionConversation({ - channel: conversation.channel, - kind: conversation.kind, - rawId: conversation.rawId, - }) - : null; + const resolvedConversation = resolveSessionConversation({ + channel: conversation.channel, + kind: conversation.kind, + rawId: conversation.rawId, + }); return { channel: conversation.channel, groupIds: collectUniqueStrings([ diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index ebf3ad2dead..5d18b7a9653 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1421,6 +1421,8 @@ export async function runAgentTurnWithFallback(params: { transcriptPrompt: params.transcriptCommandBody, extraSystemPrompt: params.followupRun.run.extraSystemPrompt, sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode, + forceMessageTool: + params.followupRun.run.sourceReplyDeliveryMode === "message_tool_only", silentReplyPromptMode: params.followupRun.run.silentReplyPromptMode, toolResultFormat: (() => { const channel = resolveMessageChannel( diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 846fb5484cd..a1edc20e0dc 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -4427,6 +4427,42 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => ); }); + it("falls back to automatic group/channel delivery when group tools remove the message tool", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + expect(opts?.sourceReplyDeliveryMode).toBe("automatic"); + return { text: "group policy fallback" } satisfies ReplyPayload; + }); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + ChatType: "channel", + From: "discord:channel:C1", + Provider: "discord", + Surface: "discord", + SessionKey: "agent:main:discord:channel:C1", + }), + cfg: { + channels: { + discord: { + groups: { + C1: { tools: { allow: ["read"] } }, + }, + }, + }, + } as OpenClawConfig, + dispatcher, + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(result.queuedFinal).toBe(true); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "group policy fallback" }), + ); + }); + it("falls back when a channel precomputed message-tool-only delivery but the message tool is unavailable", async () => { setNoAbort(); const dispatcher = createDispatcher(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 20a0ace2a57..f610f7919c0 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -8,7 +8,13 @@ import { import { isToolAllowedByPolicies, resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveSubagentToolPolicyForSession, } from "../../agents/pi-tools.policy.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../../agents/subagent-capabilities.js"; import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js"; import { resolveConversationBindingRecord, @@ -17,6 +23,7 @@ import { import { normalizeChatType } from "../../channels/chat-type.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import { applyMergePatch } from "../../config/merge-patch.js"; +import { resolveGroupSessionKey } from "../../config/sessions/group.js"; import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -82,6 +89,7 @@ import type { import { resolveEffectiveReplyRoute } from "./effective-reply-route.js"; import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js"; import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; import { resolveReplyRoutingDecision } from "./routing-policy.js"; import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; @@ -612,11 +620,57 @@ export async function dispatchReplyFromConfig( sessionKey: acpDispatchSessionKey, agentId: sessionAgentId, }); - const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow); - const providerProfilePolicy = mergeAlsoAllowPolicy( - resolveToolProfilePolicy(providerProfile), - providerProfileAlsoAllow, - ); + const chatType = normalizeChatType(ctx.ChatType); + const configuredVisibleReplies = + chatType === "group" || chatType === "channel" + ? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies) + : cfg.messages?.visibleReplies; + const prefersMessageToolDelivery = + params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" || + (params.replyOptions?.sourceReplyDeliveryMode === undefined && + ctx.CommandSource !== "native" && + (chatType === "group" || chatType === "channel" + ? configuredVisibleReplies !== "automatic" + : configuredVisibleReplies === "message_tool")); + const runtimeProfileAlsoAllow = prefersMessageToolDelivery ? ["message"] : []; + const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [ + ...(profileAlsoAllow ?? []), + ...runtimeProfileAlsoAllow, + ]); + const providerProfilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(providerProfile), [ + ...(providerProfileAlsoAllow ?? []), + ...runtimeProfileAlsoAllow, + ]); + const groupResolution = resolveGroupSessionKey(ctx); + const messageProvider = resolveOriginMessageProvider({ + originatingChannel: ctx.OriginatingChannel, + provider: ctx.Provider ?? ctx.Surface, + }); + const groupPolicy = resolveGroupToolPolicy({ + config: cfg, + sessionKey: acpDispatchSessionKey, + messageProvider, + groupId: groupResolution?.id, + groupChannel: + normalizeOptionalString(ctx.GroupChannel) ?? normalizeOptionalString(ctx.GroupSubject), + groupSpace: normalizeOptionalString(ctx.GroupSpace), + accountId: ctx.AccountId, + senderId: normalizeOptionalString(ctx.SenderId), + senderName: normalizeOptionalString(ctx.SenderName), + senderUsername: normalizeOptionalString(ctx.SenderUsername), + senderE164: normalizeOptionalString(ctx.SenderE164), + }); + const subagentStore = resolveSubagentCapabilityStore(acpDispatchSessionKey, { cfg }); + const subagentPolicy = + acpDispatchSessionKey && + isSubagentEnvelopeSession(acpDispatchSessionKey, { + cfg, + store: subagentStore, + }) + ? resolveSubagentToolPolicyForSession(cfg, acpDispatchSessionKey, { + store: subagentStore, + }) + : undefined; const messageToolAvailable = isToolAllowedByPolicies("message", [ profilePolicy, providerProfilePolicy, @@ -624,6 +678,8 @@ export async function dispatchReplyFromConfig( agentProviderPolicy, globalPolicy, agentPolicy, + groupPolicy, + subagentPolicy, ]); const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ cfg, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 63dca89b3f4..ed56582bc8f 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1380,6 +1380,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { expect(runEmbeddedPiAgentMock).toHaveBeenCalledWith( expect.objectContaining({ sourceReplyDeliveryMode: "message_tool_only", + forceMessageTool: true, }), ); expect(routeReplyMock).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 36b4ad3fd88..7843727e259 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -306,6 +306,7 @@ export function createFollowupRunner(params: { extraSystemPrompt: run.extraSystemPrompt, silentReplyPromptMode: run.silentReplyPromptMode, sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, + forceMessageTool: run.sourceReplyDeliveryMode === "message_tool_only", ownerNumbers: run.ownerNumbers, enforceFinalTag: run.enforceFinalTag, allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index fbd2d40cec9..1e3b6b40077 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -9786,6 +9786,92 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ ], }, }, + groupPolicy: { + type: "string", + enum: ["open", "allowlist", "disabled"], + }, + groupAllowFrom: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + ], + }, + }, + groups: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + requireMention: { + type: "boolean", + }, + tools: { + type: "object", + properties: { + allow: { + type: "array", + items: { + type: "string", + }, + }, + alsoAllow: { + type: "array", + items: { + type: "string", + }, + }, + deny: { + type: "array", + items: { + type: "string", + }, + }, + }, + additionalProperties: false, + }, + toolsBySender: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + allow: { + type: "array", + items: { + type: "string", + }, + }, + alsoAllow: { + type: "array", + items: { + type: "string", + }, + }, + deny: { + type: "array", + items: { + type: "string", + }, + }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: false, + }, + }, defaultTo: { type: "string", }, @@ -9849,6 +9935,92 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ ], }, }, + groupPolicy: { + type: "string", + enum: ["open", "allowlist", "disabled"], + }, + groupAllowFrom: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + ], + }, + }, + groups: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + requireMention: { + type: "boolean", + }, + tools: { + type: "object", + properties: { + allow: { + type: "array", + items: { + type: "string", + }, + }, + alsoAllow: { + type: "array", + items: { + type: "string", + }, + }, + deny: { + type: "array", + items: { + type: "string", + }, + }, + }, + additionalProperties: false, + }, + toolsBySender: { + type: "object", + propertyNames: { + type: "string", + }, + additionalProperties: { + type: "object", + properties: { + allow: { + type: "array", + items: { + type: "string", + }, + }, + alsoAllow: { + type: "array", + items: { + type: "string", + }, + }, + deny: { + type: "array", + items: { + type: "string", + }, + }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: false, + }, + }, defaultTo: { type: "string", }, diff --git a/src/plugin-sdk/qa-channel-protocol.ts b/src/plugin-sdk/qa-channel-protocol.ts index 6ded2033ae1..244beea4b8d 100644 --- a/src/plugin-sdk/qa-channel-protocol.ts +++ b/src/plugin-sdk/qa-channel-protocol.ts @@ -1,4 +1,4 @@ -export type QaBusConversationKind = "direct" | "channel"; +export type QaBusConversationKind = "direct" | "channel" | "group"; export type QaBusConversation = { id: string;