mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 23:45:37 +00:00
fix: hide unsupported best effort message option
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelMessageAdapterShape } from "../../channels/message/types.js";
|
||||
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
@@ -326,6 +327,7 @@ function createChannelPlugin(params: {
|
||||
capabilities?: readonly ChannelMessageCapability[];
|
||||
toolSchema?: MessageToolSchema | ((params: MessageToolDiscoveryContext) => MessageToolSchema);
|
||||
describeMessageTool?: DescribeMessageTool;
|
||||
message?: ChannelMessageAdapterShape;
|
||||
messaging?: ChannelPlugin["messaging"];
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
@@ -343,6 +345,7 @@ function createChannelPlugin(params: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
...(params.message ? { message: params.message } : {}),
|
||||
...(params.messaging ? { messaging: params.messaging } : {}),
|
||||
actions: {
|
||||
describeMessageTool:
|
||||
@@ -788,6 +791,47 @@ describe("message tool secret scoping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool delivery mode schema", () => {
|
||||
it("hides bestEffort when required durable delivery is not available", () => {
|
||||
const defaultTool = createMessageTool();
|
||||
const scopedTool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "discord",
|
||||
});
|
||||
|
||||
expect(getToolProperties(defaultTool).bestEffort).toBeUndefined();
|
||||
expect(getToolProperties(scopedTool).bestEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes bestEffort only for channels that can reconcile unknown sends", () => {
|
||||
const plugin = createChannelPlugin({
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test",
|
||||
actions: ["send"],
|
||||
message: {
|
||||
durableFinal: {
|
||||
capabilities: { reconcileUnknownSend: true },
|
||||
reconcileUnknownSend: async () => ({ status: "not_sent" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "discord",
|
||||
});
|
||||
const bestEffort = getToolProperties(tool).bestEffort as
|
||||
| { description?: string; type?: string }
|
||||
| undefined;
|
||||
|
||||
expect(bestEffort?.type).toBe("boolean");
|
||||
expect(bestEffort?.description).toContain("required durable delivery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool agent routing", () => {
|
||||
it("derives agentId from the session key", async () => {
|
||||
mockSendResult();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
|
||||
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
getLoadedChannelPlugin,
|
||||
listChannelPlugins,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import {
|
||||
channelSupportsMessageCapability,
|
||||
channelSupportsMessageCapabilityForChannel,
|
||||
@@ -158,7 +162,11 @@ const presentationMessageSchema = Type.Object(
|
||||
},
|
||||
);
|
||||
|
||||
function buildSendSchema(options: { includePresentation: boolean; includeDeliveryPin: boolean }) {
|
||||
function buildSendSchema(options: {
|
||||
includePresentation: boolean;
|
||||
includeDeliveryPin: boolean;
|
||||
includeBestEffort: boolean;
|
||||
}) {
|
||||
const props: Record<string, TSchema> = {
|
||||
message: Type.Optional(Type.String()),
|
||||
effectId: Type.Optional(
|
||||
@@ -207,7 +215,6 @@ function buildSendSchema(options: { includePresentation: boolean; includeDeliver
|
||||
asVoice: Type.Optional(Type.Boolean()),
|
||||
silent: Type.Optional(Type.Boolean()),
|
||||
quoteText: Type.Optional(Type.String({ description: "Telegram reply quote text." })),
|
||||
bestEffort: Type.Optional(Type.Boolean()),
|
||||
gifPlayback: Type.Optional(Type.Boolean()),
|
||||
forceDocument: Type.Optional(
|
||||
Type.Boolean({
|
||||
@@ -223,6 +230,14 @@ function buildSendSchema(options: { includePresentation: boolean; includeDeliver
|
||||
if (options.includePresentation) {
|
||||
props.presentation = Type.Optional(presentationMessageSchema);
|
||||
}
|
||||
if (options.includeBestEffort) {
|
||||
props.bestEffort = Type.Optional(
|
||||
Type.Boolean({
|
||||
description:
|
||||
"Optional delivery mode. Omit or set true for ordinary replies. Set false only when required durable delivery is necessary.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options.includeDeliveryPin) {
|
||||
props.delivery = Type.Optional(
|
||||
Type.Object(
|
||||
@@ -466,6 +481,7 @@ function buildChannelManagementSchema() {
|
||||
function buildMessageToolSchemaProps(options: {
|
||||
includePresentation: boolean;
|
||||
includeDeliveryPin: boolean;
|
||||
includeBestEffort: boolean;
|
||||
extraProperties?: Record<string, TSchema>;
|
||||
}) {
|
||||
return {
|
||||
@@ -494,6 +510,7 @@ function isSendOnlyActions(actions: readonly string[]): boolean {
|
||||
function buildSendOnlyMessageToolSchemaProps(options: {
|
||||
includePresentation: boolean;
|
||||
includeDeliveryPin: boolean;
|
||||
includeBestEffort: boolean;
|
||||
extraProperties?: Record<string, TSchema>;
|
||||
}) {
|
||||
return {
|
||||
@@ -509,6 +526,7 @@ function buildMessageToolSchemaFromActions(
|
||||
options: {
|
||||
includePresentation: boolean;
|
||||
includeDeliveryPin: boolean;
|
||||
includeBestEffort: boolean;
|
||||
extraProperties?: Record<string, TSchema>;
|
||||
},
|
||||
) {
|
||||
@@ -524,6 +542,7 @@ function buildMessageToolSchemaFromActions(
|
||||
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
includePresentation: true,
|
||||
includeDeliveryPin: true,
|
||||
includeBestEffort: false,
|
||||
});
|
||||
|
||||
type MessageToolOptions = {
|
||||
@@ -743,10 +762,27 @@ function resolveIncludeDeliveryPin(params: MessageToolDiscoveryParams): boolean
|
||||
return resolveIncludeCapability(params, "delivery-pin");
|
||||
}
|
||||
|
||||
function resolveIncludeBestEffort(params: MessageToolDiscoveryParams): boolean {
|
||||
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
|
||||
if (!currentChannel) {
|
||||
return false;
|
||||
}
|
||||
const adapter =
|
||||
listChannelPlugins().find((plugin) => plugin.id === currentChannel)?.message ??
|
||||
getLoadedChannelPlugin(currentChannel as Parameters<typeof getLoadedChannelPlugin>[0])
|
||||
?.message ??
|
||||
getChannelPlugin(currentChannel as Parameters<typeof getChannelPlugin>[0])?.message;
|
||||
return (
|
||||
adapter?.durableFinal?.capabilities?.reconcileUnknownSend === true &&
|
||||
typeof adapter.durableFinal.reconcileUnknownSend === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
|
||||
const actions = resolveMessageToolActionSchemaActions(params);
|
||||
const includePresentation = resolveIncludePresentation(params);
|
||||
const includeDeliveryPin = resolveIncludeDeliveryPin(params);
|
||||
const includeBestEffort = resolveIncludeBestEffort(params);
|
||||
const extraProperties = resolveChannelMessageToolSchemaProperties(
|
||||
buildMessageActionDiscoveryInput(
|
||||
params,
|
||||
@@ -756,6 +792,7 @@ function buildMessageToolSchema(params: MessageToolDiscoveryParams) {
|
||||
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
|
||||
includePresentation,
|
||||
includeDeliveryPin,
|
||||
includeBestEffort,
|
||||
extraProperties,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,16 +341,18 @@ describe("sendMessage", () => {
|
||||
capability: "reconcileUnknownSend",
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessage({
|
||||
cfg: {},
|
||||
channel: "forum",
|
||||
to: "123456",
|
||||
content: "fallback text",
|
||||
payloads: [{ text: "prepared", channelData: { forum: { card: true } } }],
|
||||
queuePolicy: "required",
|
||||
}),
|
||||
).rejects.toThrow("missing reconcileUnknownSend");
|
||||
const send = sendMessage({
|
||||
cfg: {},
|
||||
channel: "forum",
|
||||
to: "123456",
|
||||
content: "fallback text",
|
||||
payloads: [{ text: "prepared", channelData: { forum: { card: true } } }],
|
||||
queuePolicy: "required",
|
||||
});
|
||||
|
||||
await expect(send).rejects.toThrow(
|
||||
/missing reconcileUnknownSend[\s\S]*queuePolicy:"best_effort"[\s\S]*omit bestEffort:false/,
|
||||
);
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -254,7 +254,10 @@ async function assertRequiredMessageSendDurability(params: {
|
||||
support.reason === "capability_mismatch" && support.capability
|
||||
? `missing ${support.capability}`
|
||||
: support.reason;
|
||||
throw new Error(`Required durable message send is unsupported for ${params.channel}: ${suffix}`);
|
||||
throw new Error(
|
||||
`Required durable message send is unsupported for ${params.channel}: ${suffix}. ` +
|
||||
'Use queuePolicy:"best_effort" for best-effort delivery, omit bestEffort:false in message-tool calls, or use a channel with required durable delivery support.',
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGatewayOptions(opts?: MessageGatewayOptions) {
|
||||
|
||||
@@ -668,9 +668,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"bestEffort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"buffer": {
|
||||
"description": "Base64 attachment payload; data URL ok.",
|
||||
"type": "string"
|
||||
|
||||
@@ -668,9 +668,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"bestEffort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"buffer": {
|
||||
"description": "Base64 attachment payload; data URL ok.",
|
||||
"type": "string"
|
||||
|
||||
@@ -668,9 +668,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"bestEffort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"buffer": {
|
||||
"description": "Base64 attachment payload; data URL ok.",
|
||||
"type": "string"
|
||||
|
||||
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 40340,
|
||||
"roughTokens": 10085
|
||||
"chars": 40277,
|
||||
"roughTokens": 10070
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 2846,
|
||||
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6890
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 67900,
|
||||
"roughTokens": 16975
|
||||
"chars": 67837,
|
||||
"roughTokens": 16960
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1629,
|
||||
@@ -637,9 +637,6 @@ Full JSON: `codex-dynamic-tools.discord-group.json`
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"bestEffort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"buffer": {
|
||||
"description": "Base64 attachment payload; data URL ok.",
|
||||
"type": "string"
|
||||
|
||||
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 40061,
|
||||
"roughTokens": 10016
|
||||
"chars": 39998,
|
||||
"roughTokens": 10000
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 1822,
|
||||
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6509
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 66097,
|
||||
"roughTokens": 16525
|
||||
"chars": 66034,
|
||||
"roughTokens": 16509
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1129,
|
||||
@@ -614,9 +614,6 @@ Full JSON: `codex-dynamic-tools.telegram-direct.json`
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"bestEffort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"buffer": {
|
||||
"description": "Base64 attachment payload; data URL ok.",
|
||||
"type": "string"
|
||||
|
||||
@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 0
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 41156,
|
||||
"roughTokens": 10289
|
||||
"chars": 41093,
|
||||
"roughTokens": 10274
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 1841,
|
||||
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6745
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 68135,
|
||||
"roughTokens": 17034
|
||||
"chars": 68072,
|
||||
"roughTokens": 17018
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 1367,
|
||||
@@ -625,9 +625,6 @@ Full JSON: `codex-dynamic-tools.heartbeat-turn.json`
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"bestEffort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"buffer": {
|
||||
"description": "Base64 attachment payload; data URL ok.",
|
||||
"type": "string"
|
||||
|
||||
Reference in New Issue
Block a user