Skip to content

Commit a253660

Browse files
KeWang0622kewang-pikasteipete
authored
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>
1 parent f321036 commit a253660

6 files changed

Lines changed: 88 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
7070
### Fixes
7171

7272
- 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.
73+
- 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.
7374
- 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.
7475
- 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.
7576
- 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.

src/gateway/server-methods/agent.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,60 @@ describe("gateway agent handler", () => {
966966
);
967967
});
968968

969+
it.each(
970+
(["channel", "replyChannel"] as const).flatMap((field) =>
971+
(["heartbeat", "cron", "webhook"] as const).map((channel) => [field, channel] as const),
972+
),
973+
)("accepts internal non-delivery %s hint %s", async (field, channel) => {
974+
primeMainAgentRun();
975+
mocks.agentCommand.mockClear();
976+
const respond = vi.fn();
977+
978+
await invokeAgent(
979+
{
980+
message: "spawn from internal source",
981+
agentId: "main",
982+
sessionKey: "agent:main:main",
983+
[field]: channel,
984+
idempotencyKey: `internal-channel-${field}-${channel}`,
985+
} as AgentParams,
986+
{ reqId: `internal-channel-${field}-${channel}-1`, respond },
987+
);
988+
989+
const rejection = respond.mock.calls.find(
990+
(call: unknown[]) =>
991+
call[0] === false &&
992+
typeof (call[2] as { message?: string } | undefined)?.message === "string" &&
993+
(call[2] as { message: string }).message.includes("unknown channel"),
994+
);
995+
expect(rejection).toBeUndefined();
996+
});
997+
998+
it.each(["channel", "replyChannel"] as const)("rejects unknown %s hints", async (field) => {
999+
primeMainAgentRun();
1000+
mocks.agentCommand.mockClear();
1001+
const respond = vi.fn();
1002+
1003+
await invokeAgent(
1004+
{
1005+
message: "bogus channel",
1006+
agentId: "main",
1007+
sessionKey: "agent:main:main",
1008+
[field]: "not-a-real-channel",
1009+
idempotencyKey: `unknown-${field}`,
1010+
} as AgentParams,
1011+
{ reqId: `unknown-${field}-1`, respond },
1012+
);
1013+
1014+
expect(respond).toHaveBeenCalledWith(
1015+
false,
1016+
undefined,
1017+
expect.objectContaining({
1018+
message: expect.stringContaining("unknown channel: not-a-real-channel"),
1019+
}),
1020+
);
1021+
});
1022+
9691023
it("accepts music generation internal events", async () => {
9701024
primeMainAgentRun();
9711025
mocks.agentCommand.mockClear();

src/gateway/server-methods/agent.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
INTERNAL_MESSAGE_CHANNEL,
7676
isDeliverableMessageChannel,
7777
isGatewayMessageChannel,
78+
isInternalNonDeliveryChannel,
7879
normalizeMessageChannel,
7980
} from "../../utils/message-channel.js";
8081
import { resolveAssistantIdentity } from "../assistant-identity.js";
@@ -540,7 +541,10 @@ export const agentHandlers: GatewayRequestHandlers = {
540541
}
541542
}
542543

543-
const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
544+
// Accept internal non-delivery sources (heartbeat, cron, webhook) as valid
545+
// channel hints so subagent spawns from those parent runs are not rejected.
546+
const isKnownGatewayChannel = (value: string): boolean =>
547+
isGatewayMessageChannel(value) || isInternalNonDeliveryChannel(value);
544548
const channelHints = [request.channel, request.replyChannel]
545549
.filter((value): value is string => typeof value === "string")
546550
.map((value) => value.trim())
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,15 @@
11
export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const;
22
export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL;
3+
4+
// Internal, non-delivery sources that may surface as a `channel` hint when an
5+
// agent run is triggered by something other than a chat message — heartbeat
6+
// ticks, cron jobs, or webhook receivers. They are not deliverable on their
7+
// own, but they should still pass agent-param channel validation so internal
8+
// callers (e.g. sessions_spawn from a heartbeat-driven parent run) are not
9+
// rejected as "unknown channel".
10+
export const INTERNAL_NON_DELIVERY_CHANNELS = ["heartbeat", "cron", "webhook"] as const;
11+
export type InternalNonDeliveryChannel = (typeof INTERNAL_NON_DELIVERY_CHANNELS)[number];
12+
13+
export function isInternalNonDeliveryChannel(value: string): value is InternalNonDeliveryChannel {
14+
return (INTERNAL_NON_DELIVERY_CHANNELS as readonly string[]).includes(value);
15+
}

src/utils/message-channel.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { ChannelPlugin } from "../channels/plugins/types.js";
33
import { setActivePluginRegistry } from "../plugins/runtime.js";
44
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
55
import {
6+
INTERNAL_NON_DELIVERY_CHANNELS,
7+
isInternalNonDeliveryChannel,
68
isMarkdownCapableMessageChannel,
79
resolveGatewayMessageChannel,
810
} from "./message-channel.js";
@@ -58,6 +60,16 @@ describe("message-channel", () => {
5860
expect(resolveGatewayMessageChannel("workspace-chat")).toBe("demo-alias-channel");
5961
});
6062

63+
it("recognises internal non-delivery channel sources", () => {
64+
for (const channel of INTERNAL_NON_DELIVERY_CHANNELS) {
65+
expect(isInternalNonDeliveryChannel(channel)).toBe(true);
66+
}
67+
expect(isInternalNonDeliveryChannel("telegram")).toBe(false);
68+
expect(isInternalNonDeliveryChannel("webchat")).toBe(false);
69+
expect(isInternalNonDeliveryChannel("")).toBe(false);
70+
expect(isInternalNonDeliveryChannel("HEARTBEAT")).toBe(false);
71+
});
72+
6173
it("reads markdown capability from channel metadata", () => {
6274
expect(isMarkdownCapableMessageChannel("telegram")).toBe(true);
6375
expect(isMarkdownCapableMessageChannel("whatsapp")).toBe(false);

src/utils/message-channel.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export {
2424
} from "./message-channel-normalize.js";
2525
export {
2626
INTERNAL_MESSAGE_CHANNEL,
27+
INTERNAL_NON_DELIVERY_CHANNELS,
28+
isInternalNonDeliveryChannel,
2729
type InternalMessageChannel,
30+
type InternalNonDeliveryChannel,
2831
} from "./message-channel-constants.js";
2932
import {
3033
INTERNAL_MESSAGE_CHANNEL,

0 commit comments

Comments
 (0)