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:
Peter Steinberger
2026-03-01 23:37:27 +00:00
parent 912ddba81e
commit 13bb80df9d
4 changed files with 186 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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