From a25366038503adcc31a5fa64b3d2f295bd5dbfd2 Mon Sep 17 00:00:00 2001 From: Ke Wang <30745273+KeWang0622@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:32:23 -0500 Subject: [PATCH] fix(gateway): accept heartbeat/cron/webhook channel hints in agent params (#73237) (#73282) * fix(gateway): accept heartbeat/cron/webhook channel hints in agent params (#73237) * test(gateway): cover internal reply channel hints * test(openai): include codex mini catalog expectation * test(openai): follow codex catalog fixture split --------- Co-authored-by: Ke Wang Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/gateway/server-methods/agent.test.ts | 54 ++++++++++++++++++++++++ src/gateway/server-methods/agent.ts | 6 ++- src/utils/message-channel-constants.ts | 13 ++++++ src/utils/message-channel.test.ts | 12 ++++++ src/utils/message-channel.ts | 3 ++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ddeec8180..6e53a8d6716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Channels/Telegram: keep Bot API network fallbacks sticky after failed attempts and retry timed-out startup control calls once on the fallback route, so `deleteWebhook` IPv6 stalls no longer trigger slow multi-account retry storms. Fixes #73255. Thanks @ttomiczek and @sktbrd. +- Gateway/agents: accept heartbeat, cron, and webhook as internal channel hints for agent runs so `sessions_spawn` works from non-delivery parent sessions while unknown channel hints still fail closed. Fixes #73237. Thanks @KeWang0622. - Gateway/models: merge explicit `models.providers.*.models` rows into the Gateway model catalog with normalized provider/model dedupe, and use normalized image-capability lookup so custom vision models keep native image attachments even when Pi discovery omits them or model ID casing differs. Fixes #64213 and #65165. Thanks @billonese and @202233a. - Gateway/reload: publish canonical post-write source config to in-process reloaders so simple config saves no longer create phantom plugin diffs or trigger unnecessary Gateway restarts. (#73267) Thanks @szsip239. - Gateway/Docker: keep config-triggered restarts in-process inside containers instead of spawning a detached child and exiting PID 1 cleanly, so Docker Swarm and other on-failure supervisors do not leave the service stuck at 0/1 replicas. Fixes #73178. Thanks @du-nguyen-IT007. diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 0d4e901efd1..4d131fde4ca 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -966,6 +966,60 @@ describe("gateway agent handler", () => { ); }); + it.each( + (["channel", "replyChannel"] as const).flatMap((field) => + (["heartbeat", "cron", "webhook"] as const).map((channel) => [field, channel] as const), + ), + )("accepts internal non-delivery %s hint %s", async (field, channel) => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "spawn from internal source", + agentId: "main", + sessionKey: "agent:main:main", + [field]: channel, + idempotencyKey: `internal-channel-${field}-${channel}`, + } as AgentParams, + { reqId: `internal-channel-${field}-${channel}-1`, respond }, + ); + + const rejection = respond.mock.calls.find( + (call: unknown[]) => + call[0] === false && + typeof (call[2] as { message?: string } | undefined)?.message === "string" && + (call[2] as { message: string }).message.includes("unknown channel"), + ); + expect(rejection).toBeUndefined(); + }); + + it.each(["channel", "replyChannel"] as const)("rejects unknown %s hints", async (field) => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "bogus channel", + agentId: "main", + sessionKey: "agent:main:main", + [field]: "not-a-real-channel", + idempotencyKey: `unknown-${field}`, + } as AgentParams, + { reqId: `unknown-${field}-1`, respond }, + ); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("unknown channel: not-a-real-channel"), + }), + ); + }); + it("accepts music generation internal events", async () => { primeMainAgentRun(); mocks.agentCommand.mockClear(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 91085512d64..d140ea99166 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -75,6 +75,7 @@ import { INTERNAL_MESSAGE_CHANNEL, isDeliverableMessageChannel, isGatewayMessageChannel, + isInternalNonDeliveryChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; import { resolveAssistantIdentity } from "../assistant-identity.js"; @@ -540,7 +541,10 @@ export const agentHandlers: GatewayRequestHandlers = { } } - const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value); + // Accept internal non-delivery sources (heartbeat, cron, webhook) as valid + // channel hints so subagent spawns from those parent runs are not rejected. + const isKnownGatewayChannel = (value: string): boolean => + isGatewayMessageChannel(value) || isInternalNonDeliveryChannel(value); const channelHints = [request.channel, request.replyChannel] .filter((value): value is string => typeof value === "string") .map((value) => value.trim()) diff --git a/src/utils/message-channel-constants.ts b/src/utils/message-channel-constants.ts index 7c3e8af3e82..073fe3d817e 100644 --- a/src/utils/message-channel-constants.ts +++ b/src/utils/message-channel-constants.ts @@ -1,2 +1,15 @@ export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; + +// Internal, non-delivery sources that may surface as a `channel` hint when an +// agent run is triggered by something other than a chat message — heartbeat +// ticks, cron jobs, or webhook receivers. They are not deliverable on their +// own, but they should still pass agent-param channel validation so internal +// callers (e.g. sessions_spawn from a heartbeat-driven parent run) are not +// rejected as "unknown channel". +export const INTERNAL_NON_DELIVERY_CHANNELS = ["heartbeat", "cron", "webhook"] as const; +export type InternalNonDeliveryChannel = (typeof INTERNAL_NON_DELIVERY_CHANNELS)[number]; + +export function isInternalNonDeliveryChannel(value: string): value is InternalNonDeliveryChannel { + return (INTERNAL_NON_DELIVERY_CHANNELS as readonly string[]).includes(value); +} diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 823b3e0e7dd..30b7d97766b 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -3,6 +3,8 @@ import type { ChannelPlugin } from "../channels/plugins/types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { + INTERNAL_NON_DELIVERY_CHANNELS, + isInternalNonDeliveryChannel, isMarkdownCapableMessageChannel, resolveGatewayMessageChannel, } from "./message-channel.js"; @@ -58,6 +60,16 @@ describe("message-channel", () => { expect(resolveGatewayMessageChannel("workspace-chat")).toBe("demo-alias-channel"); }); + it("recognises internal non-delivery channel sources", () => { + for (const channel of INTERNAL_NON_DELIVERY_CHANNELS) { + expect(isInternalNonDeliveryChannel(channel)).toBe(true); + } + expect(isInternalNonDeliveryChannel("telegram")).toBe(false); + expect(isInternalNonDeliveryChannel("webchat")).toBe(false); + expect(isInternalNonDeliveryChannel("")).toBe(false); + expect(isInternalNonDeliveryChannel("HEARTBEAT")).toBe(false); + }); + it("reads markdown capability from channel metadata", () => { expect(isMarkdownCapableMessageChannel("telegram")).toBe(true); expect(isMarkdownCapableMessageChannel("whatsapp")).toBe(false); diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index 43061de241c..99516d878b9 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -24,7 +24,10 @@ export { } from "./message-channel-normalize.js"; export { INTERNAL_MESSAGE_CHANNEL, + INTERNAL_NON_DELIVERY_CHANNELS, + isInternalNonDeliveryChannel, type InternalMessageChannel, + type InternalNonDeliveryChannel, } from "./message-channel-constants.js"; import { INTERNAL_MESSAGE_CHANNEL,