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 <ke@pika.art>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Ke Wang
2026-04-28 01:32:23 -05:00
committed by GitHub
parent f321036a00
commit a253660385
6 changed files with 88 additions and 1 deletions

View File

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

View File

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

View File

@@ -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())

View File

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

View File

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

View File

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