Plugin SDK: require unified message discovery

This commit is contained in:
Gustavo Madeira Santana
2026-03-18 03:02:02 +00:00
parent 870f260772
commit 682f4d1ca3
31 changed files with 155 additions and 301 deletions

View File

@@ -1089,7 +1089,7 @@ describe("signalMessageActions", () => {
for (const testCase of cases) {
expect(
signalMessageActions.listActions?.({ cfg: testCase.cfg }) ?? [],
signalMessageActions.describeMessageTool?.({ cfg: testCase.cfg })?.actions ?? [],
testCase.name,
).toEqual(testCase.expected);
}

View File

@@ -74,14 +74,14 @@ async function mutateSignalReaction(params: {
}
export const signalMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
describeMessageTool: ({ cfg }) => {
const accounts = listEnabledSignalAccounts(cfg);
if (accounts.length === 0) {
return [];
return null;
}
const configuredAccounts = accounts.filter((account) => account.configured);
if (configuredAccounts.length === 0) {
return [];
return null;
}
const actions = new Set<ChannelMessageActionName>(["send"]);
@@ -93,7 +93,7 @@ export const signalMessageActions: ChannelMessageActionAdapter = {
actions.add("react");
}
return Array.from(actions);
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => action !== "send",

View File

@@ -43,16 +43,10 @@ function resolveContractMessageDiscovery(params: {
capabilities: [] as readonly ChannelMessageCapability[],
};
}
if (actions.describeMessageTool) {
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
return {
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
};
}
const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null;
return {
actions: actions.listActions?.({ cfg: params.cfg }) ?? [],
capabilities: actions.getCapabilities?.({ cfg: params.cfg }) ?? [],
actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [],
capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [],
};
}
@@ -156,10 +150,7 @@ export function installChannelActionsContractSuite(params: {
}) {
it("exposes the base message actions contract", () => {
expect(params.plugin.actions).toBeDefined();
expect(
typeof params.plugin.actions?.describeMessageTool === "function" ||
typeof params.plugin.actions?.listActions === "function",
).toBe(true);
expect(typeof params.plugin.actions?.describeMessageTool).toBe("function");
});
for (const testCase of params.cases) {
@@ -223,10 +214,7 @@ export function installChannelSurfaceContractSuite(params: {
it(`exposes the ${surface} surface contract`, () => {
if (surface === "actions") {
expect(plugin.actions).toBeDefined();
expect(
typeof plugin.actions?.describeMessageTool === "function" ||
typeof plugin.actions?.listActions === "function",
).toBe(true);
expect(typeof plugin.actions?.describeMessageTool).toBe("function");
return;
}

View File

@@ -60,7 +60,7 @@ export function createMessageActionDiscoveryContext(
function logMessageActionError(params: {
pluginId: string;
operation: "describeMessageTool" | "getCapabilities" | "getToolSchema" | "listActions";
operation: "describeMessageTool";
error: unknown;
}) {
const message = params.error instanceof Error ? params.error.message : String(params.error);
@@ -75,24 +75,6 @@ function logMessageActionError(params: {
);
}
function runListActionsSafely(params: {
pluginId: string;
context: ChannelMessageActionDiscoveryContext;
listActions: NonNullable<ChannelActions["listActions"]>;
}): ChannelMessageActionName[] {
try {
const listed = params.listActions(params.context);
return Array.isArray(listed) ? listed : [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "listActions",
error,
});
return [];
}
}
function describeMessageToolSafely(params: {
pluginId: string;
context: ChannelMessageActionDiscoveryContext;
@@ -110,44 +92,6 @@ function describeMessageToolSafely(params: {
}
}
function listCapabilitiesSafely(params: {
pluginId: string;
actions: ChannelActions;
context: ChannelMessageActionDiscoveryContext;
}): readonly ChannelMessageCapability[] {
try {
return params.actions.getCapabilities?.(params.context) ?? [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "getCapabilities",
error,
});
return [];
}
}
function runGetToolSchemaSafely(params: {
pluginId: string;
context: ChannelMessageActionDiscoveryContext;
getToolSchema: NonNullable<ChannelActions["getToolSchema"]>;
}):
| ChannelMessageToolSchemaContribution
| ChannelMessageToolSchemaContribution[]
| null
| undefined {
try {
return params.getToolSchema(params.context);
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
operation: "getToolSchema",
error,
});
return null;
}
}
function normalizeToolSchemaContributions(
value:
| ChannelMessageToolSchemaContribution
@@ -184,52 +128,21 @@ export function resolveMessageActionDiscoveryForPlugin(params: {
};
}
if (adapter.describeMessageTool) {
const described = describeMessageToolSafely({
pluginId: params.pluginId,
context: params.context,
describeMessageTool: adapter.describeMessageTool,
});
return {
actions:
params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [],
capabilities:
params.includeCapabilities && Array.isArray(described?.capabilities)
? described.capabilities
: [],
schemaContributions: params.includeSchema
? normalizeToolSchemaContributions(described?.schema)
: [],
};
}
const described = describeMessageToolSafely({
pluginId: params.pluginId,
context: params.context,
describeMessageTool: adapter.describeMessageTool,
});
return {
actions:
params.includeActions && adapter.listActions
? runListActionsSafely({
pluginId: params.pluginId,
context: params.context,
listActions: adapter.listActions,
})
: [],
params.includeActions && Array.isArray(described?.actions) ? [...described.actions] : [],
capabilities:
params.includeCapabilities && adapter.getCapabilities
? listCapabilitiesSafely({
pluginId: params.pluginId,
actions: adapter,
context: params.context,
})
: [],
schemaContributions:
params.includeSchema && adapter.getToolSchema
? normalizeToolSchemaContributions(
runGetToolSchemaSafely({
pluginId: params.pluginId,
context: params.context,
getToolSchema: adapter.getToolSchema,
}),
)
params.includeCapabilities && Array.isArray(described?.capabilities)
? described.capabilities
: [],
schemaContributions: params.includeSchema
? normalizeToolSchemaContributions(described?.schema)
: [],
};
}

View File

@@ -23,7 +23,7 @@ const discordPlugin: ChannelPlugin = {
},
}),
actions: {
listActions: () => ["kick"],
describeMessageTool: () => ({ actions: ["kick"] }),
supportsAction: ({ action }) => action === "kick",
requiresTrustedRequesterSender: ({ action, toolContext }) =>
Boolean(action === "kick" && toolContext),

View File

@@ -41,8 +41,10 @@ function createMessageActionsPlugin(params: {
...(params.aliases ? { aliases: params.aliases } : {}),
},
actions: {
listActions: () => ["send"],
getCapabilities: () => params.capabilities,
describeMessageTool: () => ({
actions: ["send"],
capabilities: params.capabilities,
}),
},
};
}
@@ -161,16 +163,7 @@ describe("message action capability checks", () => {
).toEqual(["cards"]);
});
it("prefers unified message tool discovery over legacy discovery methods", () => {
const legacyListActions = vi.fn(() => {
throw new Error("legacy listActions should not run");
});
const legacyCapabilities = vi.fn(() => {
throw new Error("legacy getCapabilities should not run");
});
const legacySchema = vi.fn(() => {
throw new Error("legacy getToolSchema should not run");
});
it("uses unified message tool discovery for actions, capabilities, and schema", () => {
const unifiedPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
@@ -190,9 +183,6 @@ describe("message action capability checks", () => {
},
},
}),
listActions: legacyListActions,
getCapabilities: legacyCapabilities,
getToolSchema: legacySchema,
},
};
setActivePluginRegistry(
@@ -207,9 +197,6 @@ describe("message action capability checks", () => {
channel: "discord",
}),
).toHaveProperty("components");
expect(legacyListActions).not.toHaveBeenCalled();
expect(legacyCapabilities).not.toHaveBeenCalled();
expect(legacySchema).not.toHaveBeenCalled();
});
it("skips crashing action/capability discovery paths and logs once", () => {
@@ -223,10 +210,7 @@ describe("message action capability checks", () => {
},
}),
actions: {
listActions: () => {
throw new Error("boom");
},
getCapabilities: () => {
describeMessageTool: () => {
throw new Error("boom");
},
},
@@ -237,10 +221,10 @@ describe("message action capability checks", () => {
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -489,38 +489,14 @@ export type ChannelToolSend = {
export type ChannelMessageActionAdapter = {
/**
* Preferred unified discovery surface for the shared `message` tool.
* When provided, this is authoritative and should return the scoped actions,
* Unified discovery surface for the shared `message` tool.
* This returns the scoped actions,
* capabilities, and schema fragments together so they cannot drift.
*/
describeMessageTool?: (
describeMessageTool: (
params: ChannelMessageActionDiscoveryContext,
) => ChannelMessageToolDiscovery | null | undefined;
/**
* Advertise agent-discoverable actions for this channel.
* Legacy fallback used when `describeMessageTool` is not implemented.
* Keep this aligned with any gated capability checks. Poll discovery is
* not inferred from `outbound.sendPoll`, so channels that want agents to
* create polls should include `"poll"` here when enabled.
*/
listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
getCapabilities?: (
params: ChannelMessageActionDiscoveryContext,
) => readonly ChannelMessageCapability[];
/**
* Extend the shared `message` tool schema with channel-owned fields.
* Legacy fallback used when `describeMessageTool` is not implemented.
* Keep this aligned with `listActions` and `getCapabilities` so the exposed
* schema matches what the channel can actually execute in the current scope.
*/
getToolSchema?: (
params: ChannelMessageActionDiscoveryContext,
) =>
| ChannelMessageToolSchemaContribution
| ChannelMessageToolSchemaContribution[]
| null
| undefined;
requiresTrustedRequesterSender?: (params: {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;