fix: hide unsupported best effort message option

This commit is contained in:
Peter Steinberger
2026-05-26 00:27:52 +01:00
parent bef0ba8f5a
commit 5dc704361f
10 changed files with 112 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -668,9 +668,6 @@
},
"type": "array"
},
"bestEffort": {
"type": "boolean"
},
"buffer": {
"description": "Base64 attachment payload; data URL ok.",
"type": "string"

View File

@@ -668,9 +668,6 @@
},
"type": "array"
},
"bestEffort": {
"type": "boolean"
},
"buffer": {
"description": "Base64 attachment payload; data URL ok.",
"type": "string"

View File

@@ -668,9 +668,6 @@
},
"type": "array"
},
"bestEffort": {
"type": "boolean"
},
"buffer": {
"description": "Base64 attachment payload; data URL ok.",
"type": "string"

View File

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

View File

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

View File

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