fix(auto-reply): keep group visible replies deliverable

This commit is contained in:
Peter Steinberger
2026-05-01 04:10:18 +01:00
committed by clawsweeper-repair
parent eabab1f64f
commit adbec93b8a
24 changed files with 712 additions and 31 deletions

View File

@@ -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.

View File

@@ -14,7 +14,9 @@ read_when:
- Slack-class target grammar:
- `dm:<user>`
- `channel:<room>`
- `group:<room>`
- `thread:<room>/<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/`.

View File

@@ -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":[',

View File

@@ -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: {

View File

@@ -65,7 +65,7 @@ function readQaSendTarget(params: Record<string, unknown>) {
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 });

View File

@@ -26,6 +26,14 @@ function createMockQaRuntime(params?: {
const sessionUpdatedAt = new Map<string, number>();
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<string, unknown> | 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<string, unknown> | 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();
}
});
});

View File

@@ -64,8 +64,8 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
targetResolver: {
looksLikeId: (raw) =>
/^((dm|channel):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
hint: "<dm:user|channel:room|thread:room/thread>",
/^((dm|channel|group):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
hint: "<dm:user|channel:room|group:room|thread:room/thread>",
},
resolveOutboundSessionRoute: ({
cfg,
@@ -83,7 +83,12 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = 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<ResolvedQaChannelAccount> = 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: {

View File

@@ -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(),
})

View File

@@ -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,

View File

@@ -13,6 +13,16 @@ export type QaChannelAccountConfig = {
botDisplayName?: string;
pollTimeoutMs?: number;
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groupAllowFrom?: Array<string | number>;
groups?: Record<
string,
{
requireMention?: boolean;
tools?: Record<string, unknown>;
toolsBySender?: Record<string, Record<string, unknown>>;
}
>;
defaultTo?: string;
actions?: QaChannelActionConfig;
};

View File

@@ -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" },

View File

@@ -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);
}

View File

@@ -24,6 +24,7 @@ export {
export type {
QaBusAttachment,
QaBusConversation,
QaBusConversationKind,
QaBusCreateThreadInput,
QaBusDeleteMessageInput,
QaBusEditMessageInput,

View File

@@ -704,6 +704,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
const ownsLab = !params?.lab;
const startLab = params?.startLab;
writeQaSuiteProgress(progressEnabled, "lab start");
const lab =
params?.lab ??
(await requireQaSuiteStartLab(startLab)({
@@ -712,11 +713,18 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
port: 0,
embeddedGateway: "disabled",
}));
writeQaSuiteProgress(progressEnabled, `lab ready: ${sanitizeQaSuiteProgressValue(lab.baseUrl)}`);
const transport = createQaTransportAdapter({
id: transportId,
state: lab.state,
});
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
const mock = await startQaProviderServer(providerMode);
writeQaSuiteProgress(
progressEnabled,
`provider ready: ${sanitizeQaSuiteProgressValue(mock?.baseUrl ?? "live")}`,
);
writeQaSuiteProgress(progressEnabled, "gateway start");
const gateway = await startQaGatewayChild({
repoRoot,
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
@@ -736,6 +744,10 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
? (cfg) => applyQaMergePatch(cfg, gatewayConfigPatch) as OpenClawConfig
: undefined,
});
writeQaSuiteProgress(
progressEnabled,
`gateway ready: ${sanitizeQaSuiteProgressValue(gateway.baseUrl)}`,
);
lab.setControlUi({
controlUiProxyTarget: gateway.baseUrl,
controlUiToken: gateway.token,

View File

@@ -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}`"
```

View File

@@ -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}`"
```

View File

@@ -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([

View File

@@ -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(

View File

@@ -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();

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,

View File

@@ -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",
},

View File

@@ -1,4 +1,4 @@
export type QaBusConversationKind = "direct" | "channel";
export type QaBusConversationKind = "direct" | "channel" | "group";
export type QaBusConversation = {
id: string;