fix(agents): land #20840 cross-channel message-tool actions from @altaywtf
Include scoped cross-channel action/description behavior, regression tests, changelog note, and make Ollama discovery tests URL-scoped to avoid env-dependent fetch interference. Co-authored-by: Altay <altay@hey.com>
This commit is contained in:
@@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/Failure alerts: add configurable repeated-failure alerting with per-job overrides and Web UI cron editor support (`inherit|disabled|custom` with threshold/cooldown/channel/target fields). (#24789) Thanks xbrak.
|
||||
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.
|
||||
- Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf.
|
||||
- Agents/Message tool scoping: include other configured channels in scoped `message` tool action enum + description so isolated/cron runs can discover and invoke cross-channel actions without schema validation failures. Landed from contributor PR #20840 by @altaywtf. Thanks @altaywtf.
|
||||
- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington.
|
||||
- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11.
|
||||
- Web UI/Control UI WebSocket defaults: include normalized `gateway.controlUi.basePath` (or inferred nested route base path) in the default `gatewayUrl` so first-load dashboard connections work behind path-based reverse proxies. (#30228) Thanks @gittb.
|
||||
|
||||
@@ -82,25 +82,42 @@ describe("Ollama provider", () => {
|
||||
process.env.OLLAMA_API_KEY = "test-key";
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ name: "qwen3:32b", modified_at: "", size: 1, digest: "" },
|
||||
{ name: "llama3.3:70b", modified_at: "", size: 1, digest: "" },
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ model_info: { "qwen3.context_length": 131072 } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ model_info: { "llama.context_length": 65536 } }),
|
||||
});
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ name: "qwen3:32b", modified_at: "", size: 1, digest: "" },
|
||||
{ name: "llama3.3:70b", modified_at: "", size: 1, digest: "" },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
const rawBody = init?.body;
|
||||
const bodyText = typeof rawBody === "string" ? rawBody : "{}";
|
||||
const parsed = JSON.parse(bodyText) as { name?: string };
|
||||
if (parsed.name === "qwen3:32b") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ model_info: { "qwen3.context_length": 131072 } }),
|
||||
};
|
||||
}
|
||||
if (parsed.name === "llama3.3:70b") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ model_info: { "llama.context_length": 65536 } }),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
@@ -110,7 +127,9 @@ describe("Ollama provider", () => {
|
||||
const llama = models.find((model) => model.id === "llama3.3:70b");
|
||||
expect(qwen?.contextWindow).toBe(131072);
|
||||
expect(llama?.contextWindow).toBe(65536);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
const urls = fetchMock.mock.calls.map(([input]) => String(input));
|
||||
expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(1);
|
||||
expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(2);
|
||||
} finally {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
}
|
||||
@@ -121,25 +140,37 @@ describe("Ollama provider", () => {
|
||||
process.env.OLLAMA_API_KEY = "test-key";
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [{ name: "qwen3:32b", modified_at: "", size: 1, digest: "" }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [{ name: "qwen3:32b", modified_at: "", size: 1, digest: "" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const model = providers?.ollama?.models?.find((entry) => entry.id === "qwen3:32b");
|
||||
expect(model?.contextWindow).toBe(128000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const urls = fetchMock.mock.calls.map(([input]) => String(input));
|
||||
expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(1);
|
||||
expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(1);
|
||||
} finally {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
}
|
||||
@@ -156,7 +187,8 @@ describe("Ollama provider", () => {
|
||||
size: 1,
|
||||
digest: "",
|
||||
}));
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -173,8 +205,10 @@ describe("Ollama provider", () => {
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const models = providers?.ollama?.models ?? [];
|
||||
const urls = fetchMock.mock.calls.map(([input]) => String(input));
|
||||
// 1 call for /api/tags + 200 capped /api/show calls.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(201);
|
||||
expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(1);
|
||||
expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(200);
|
||||
expect(models).toHaveLength(200);
|
||||
} finally {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
@@ -225,7 +259,11 @@ describe("Ollama provider", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
const ollamaCalls = fetchMock.mock.calls.filter(([input]) => {
|
||||
const url = String(input);
|
||||
return url.endsWith("/api/tags") || url.endsWith("/api/show");
|
||||
});
|
||||
expect(ollamaCalls).toHaveLength(0);
|
||||
expect(providers?.ollama?.models).toEqual(explicitModels);
|
||||
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(providers?.ollama?.api).toBe("ollama");
|
||||
|
||||
@@ -171,7 +171,8 @@ describe("message tool schema scoping", () => {
|
||||
expect(buttonItemProps.style).toBeDefined();
|
||||
expect(actionEnum).toContain("send");
|
||||
expect(actionEnum).toContain("react");
|
||||
expect(actionEnum).not.toContain("poll");
|
||||
// Other channels' actions are included so isolated/cron agents can use them
|
||||
expect(actionEnum).toContain("poll");
|
||||
});
|
||||
|
||||
it("shows discord components when scoped to discord", () => {
|
||||
@@ -193,11 +194,16 @@ describe("message tool schema scoping", () => {
|
||||
expect(properties.buttons).toBeUndefined();
|
||||
expect(actionEnum).toContain("send");
|
||||
expect(actionEnum).toContain("poll");
|
||||
expect(actionEnum).not.toContain("react");
|
||||
// Other channels' actions are included so isolated/cron agents can use them
|
||||
expect(actionEnum).toContain("react");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool description", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
const bluebubblesPlugin: ChannelPlugin = {
|
||||
id: "bluebubbles",
|
||||
meta: {
|
||||
@@ -248,8 +254,78 @@ describe("message tool description", () => {
|
||||
expect(tool.description).not.toContain("addParticipant");
|
||||
expect(tool.description).not.toContain("removeParticipant");
|
||||
expect(tool.description).not.toContain("leaveGroup");
|
||||
});
|
||||
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
it("includes other configured channels when currentChannel is set", () => {
|
||||
const signalPlugin: ChannelPlugin = {
|
||||
id: "signal",
|
||||
meta: {
|
||||
id: "signal",
|
||||
label: "Signal",
|
||||
selectionLabel: "Signal",
|
||||
docsPath: "/channels/signal",
|
||||
blurb: "Signal test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send", "react"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
const telegramPluginFull: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send", "react", "delete", "edit", "topic-create"] as const,
|
||||
},
|
||||
};
|
||||
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "signal", source: "test", plugin: signalPlugin },
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPluginFull },
|
||||
]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "signal",
|
||||
});
|
||||
|
||||
// Current channel actions are listed
|
||||
expect(tool.description).toContain("Current channel (signal) supports: react, send.");
|
||||
// Other configured channels are also listed
|
||||
expect(tool.description).toContain("Other configured channels:");
|
||||
expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)");
|
||||
});
|
||||
|
||||
it("does not include 'Other configured channels' when only one channel is configured", () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]),
|
||||
);
|
||||
|
||||
const tool = createMessageTool({
|
||||
config: {} as never,
|
||||
currentChannelProvider: "bluebubbles",
|
||||
});
|
||||
|
||||
expect(tool.description).toContain("Current channel (bluebubbles) supports:");
|
||||
expect(tool.description).not.toContain("Other configured channels");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import {
|
||||
listChannelMessageActions,
|
||||
supportsChannelMessageButtons,
|
||||
@@ -460,8 +461,18 @@ function resolveMessageToolSchemaActions(params: {
|
||||
channel: currentChannel,
|
||||
currentChannelId: params.currentChannelId,
|
||||
});
|
||||
const withSend = new Set<string>(["send", ...scopedActions]);
|
||||
return Array.from(withSend);
|
||||
const allActions = new Set<string>(["send", ...scopedActions]);
|
||||
// Include actions from other configured channels so isolated/cron agents
|
||||
// can invoke cross-channel actions without validation errors.
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
if (plugin.id === currentChannel) {
|
||||
continue;
|
||||
}
|
||||
for (const action of listChannelSupportedActions({ cfg: params.cfg, channel: plugin.id })) {
|
||||
allActions.add(action);
|
||||
}
|
||||
}
|
||||
return Array.from(allActions);
|
||||
}
|
||||
const actions = listChannelMessageActions(params.cfg);
|
||||
return actions.length > 0 ? actions : ["send"];
|
||||
@@ -542,7 +553,7 @@ function buildMessageToolDescription(options?: {
|
||||
}): string {
|
||||
const baseDescription = "Send, delete, and manage messages via channel plugins.";
|
||||
|
||||
// If we have a current channel, show only its supported actions
|
||||
// If we have a current channel, show its actions and list other configured channels
|
||||
if (options?.currentChannel) {
|
||||
const channelActions = filterActionsForContext({
|
||||
actions: listChannelSupportedActions({
|
||||
@@ -556,7 +567,25 @@ function buildMessageToolDescription(options?: {
|
||||
// Always include "send" as a base action
|
||||
const allActions = new Set(["send", ...channelActions]);
|
||||
const actionList = Array.from(allActions).toSorted().join(", ");
|
||||
return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`;
|
||||
let desc = `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`;
|
||||
|
||||
// Include other configured channels so cron/isolated agents can discover them
|
||||
const otherChannels: string[] = [];
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
if (plugin.id === options.currentChannel) {
|
||||
continue;
|
||||
}
|
||||
const actions = listChannelSupportedActions({ cfg: options.config, channel: plugin.id });
|
||||
if (actions.length > 0) {
|
||||
const all = new Set(["send", ...actions]);
|
||||
otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`);
|
||||
}
|
||||
}
|
||||
if (otherChannels.length > 0) {
|
||||
desc += ` Other configured channels: ${otherChannels.join(", ")}.`;
|
||||
}
|
||||
|
||||
return desc;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user