From 13bb80df9d202285aeec94ea30f70e34d4131dce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 1 Mar 2026 23:37:27 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../models-config.providers.ollama.test.ts | 108 ++++++++++++------ src/agents/tools/message-tool.test.ts | 82 ++++++++++++- src/agents/tools/message-tool.ts | 37 +++++- 4 files changed, 186 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ba36fa3..0ab5b828e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index a9a884ea7..353819cb3 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -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"); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index b7d5fe299..86636dced 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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"); }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 31b231cf1..6b7ddf2b7 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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(["send", ...scopedActions]); - return Array.from(withSend); + const allActions = new Set(["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; } }