fix(channels): preserve gateway context for inbound turns (#127962)

* fix(channels): preserve gateway context for inbound turns

* fix(plugin-sdk): type bound channel reply dispatcher

* fix(channels): carry bound reply dispatchers

* fix(channels): keep reply carrier internal

* fix(auto-reply): consolidate dispatcher type imports

* fix(channels): keep reply dispatch typing internal

* fix(channels): derive inbound reply dispatcher types from turn plan

Keep ChannelRuntimeSurface identical to main: adapters read the bound
dispatchReplyFromConfig through the existing PluginRuntime["channel"]
wiring type and derive its type from the public ChannelInboundTurnPlan
contract, so the compatibility surface no longer grows a Gateway-bound
reply member.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(channels): hoist bound channel runtime casts for assertion ratchet

The assertion-safety ratchet collects SAFETY comments with a plain
ts.Scanner pass, which desyncs at the first template literal with a
substitution — comments after that point in a file are invisible, so
deep casts in monitor files cannot be SAFETY-covered. Hoist one shared
cast per call-site scope (absorbing the pre-existing buildContext
casts) and retype Discord's internal channelRuntime chain as
PluginRuntime["channel"] so dispatch reads need no assertion.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(channels): verify gateway dispatcher ownership

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>

* test(codex): assert sequenced node process notifications

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>

* docs(changelog): preserve release-owned root changelog

Channel Gateway tools preserve the owning dispatcher for Telegram, Discord, iMessage, Signal, and WhatsApp so terminal tools remain available. Thanks @VACInc.

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>

* test(codex): avoid shadowing process notification bindings

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vito Cappello
2026-08-22 16:44:47 -04:00
committed by GitHub
parent 53aa9414fe
commit 5ddf381eed
26 changed files with 149 additions and 56 deletions
+1 -1
View File
@@ -1297,7 +1297,7 @@ extensions/telegram/src/message-cache.ts 10
extensions/telegram/src/miniapp/command.ts 1
extensions/telegram/src/miniapp/init-data.ts 1
extensions/telegram/src/miniapp/routes.ts 1
extensions/telegram/src/monitor.ts 4
extensions/telegram/src/monitor.ts 3
extensions/telegram/src/network-config.ts 1
extensions/telegram/src/network-errors.ts 8
extensions/telegram/src/outbound-adapter.ts 5
+26 -25
View File
@@ -115,6 +115,23 @@ async function readNodeResponse(
return response.result as JsonRpcRecord;
}
async function readNodeProcessNotifications(
frames: ReturnType<typeof createNodeFrames>,
processId: string,
count: number,
): Promise<JsonRpcRecord[]> {
const matching = () =>
frames.outbound.filter(
(message) =>
String(message.method).startsWith("process/") &&
(message.params as { processId?: string }).processId === processId,
);
await vi.waitFor(() => expect(matching()).toHaveLength(count));
return matching().toSorted(
(left, right) => (left.params as { seq: number }).seq - (right.params as { seq: number }).seq,
);
}
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -364,14 +381,7 @@ describe("Codex paired-node exec-server", () => {
},
});
await readNodeResponse(frames, 9);
await vi.waitFor(() =>
expect(frames.outbound.some((message) => message.method === "process/closed")).toBe(
true,
),
);
const notifications = frames.outbound.filter((message) =>
String(message.method).startsWith("process/"),
);
const notifications = await readNodeProcessNotifications(frames, "node-proof", 3);
expect(notifications.map((message) => message.method)).toEqual([
"process/output",
"process/exited",
@@ -492,24 +502,15 @@ describe("Codex paired-node exec-server", () => {
},
});
expect(await readNodeResponse(frames, control.id + 1)).toEqual(control.result);
await vi.waitFor(() =>
expect(
frames.outbound.some(
(message) =>
message.method === "process/closed" &&
(message.params as { processId?: string }).processId === control.processId,
),
).toBe(true),
const controlNotifications = await readNodeProcessNotifications(
frames,
control.processId,
2,
);
expect(
frames.outbound
.filter(
(message) =>
String(message.method).startsWith("process/") &&
(message.params as { processId?: string }).processId === control.processId,
)
.map((message) => message.method),
).toEqual(["process/exited", "process/closed"]);
expect(controlNotifications.map((message) => message.method)).toEqual([
"process/exited",
"process/closed",
]);
}
const httpServer = createServer((_request, response) => {
@@ -282,6 +282,8 @@ export async function dispatchDiscordComponentEvent(params: {
accountId,
route: { agentId, sessionKey },
ctxPayload,
// Forward the owning runtime's bound dispatcher into the turn plan; never invoked here.
dispatchReplyFromConfig: ctx.channelRuntime?.reply?.dispatchReplyFromConfig,
record: {
updateLastRoute: interactionCtx.isDirectMessage
? {
@@ -1,4 +1,5 @@
// Discord type declarations define plugin contracts.
import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
ButtonInteraction,
@@ -40,6 +41,7 @@ export type AgentComponentContext = {
accountId: string;
discordConfig?: DiscordAccountConfig;
runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
channelRuntime?: PluginRuntime["channel"];
token?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
allowFrom?: string[];
@@ -1,6 +1,6 @@
// Discord provider module implements model/runtime integration.
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@@ -55,7 +55,7 @@ export function createDiscordProviderInteractionSurface(params: {
allowFrom: DiscordAccountConfig["allowFrom"];
dmPolicy: NonNullable<DiscordAccountConfig["dmPolicy"]>;
runtime: RuntimeEnv;
channelRuntime?: ChannelRuntimeSurface;
channelRuntime?: PluginRuntime["channel"];
abortSignal?: AbortSignal;
createNativeCommand?: typeof createDiscordNativeCommand;
}): {
@@ -163,6 +163,7 @@ export function createDiscordProviderInteractionSurface(params: {
allowFrom: params.allowFrom,
dmPolicy: params.dmPolicy,
runtime: params.runtime,
channelRuntime: params.channelRuntime,
token: params.token,
},
params.applicationId,
+4 -3
View File
@@ -309,6 +309,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
try {
// SAFETY: Gateway startup supplies the full plugin channel runtime; the surface type is the minimal external view.
const pluginChannelRuntime = opts.channelRuntime as PluginRuntime["channel"] | undefined;
const { commands, components, modals } = createDiscordProviderInteractionSurface({
cfg,
discordConfig: discordCfg,
@@ -328,7 +330,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
allowFrom,
dmPolicy,
runtime,
channelRuntime: opts.channelRuntime,
channelRuntime: pluginChannelRuntime,
abortSignal: opts.abortSignal,
createNativeCommand: discordProviderRuntime.createDiscordNativeCommand,
});
@@ -442,8 +444,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId,
token,
runtime,
buildContext: (opts.channelRuntime as PluginRuntime["channel"] | undefined)?.inbound
.buildContext,
buildContext: pluginChannelRuntime?.inbound.buildContext,
setStatus: opts.setStatus,
abortSignal: opts.abortSignal,
botUserId,
@@ -1059,6 +1059,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
logVerbose,
})
: undefined;
// SAFETY: Gateway startup supplies the full plugin channel runtime; the surface type is the minimal external view.
const pluginChannelRuntime = opts.channelRuntime as PluginRuntime["channel"] | undefined;
const { ctxPayload, chatTarget, imessageTo } = await buildIMessageInboundContext({
cfg,
accountService: imessageCfg.service,
@@ -1069,8 +1071,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
historyLimit,
groupHistories,
dmHistory,
buildContext: (opts.channelRuntime as PluginRuntime["channel"] | undefined)?.inbound
.buildContext,
buildContext: pluginChannelRuntime?.inbound.buildContext,
media: {
facts: mediaAttachments,
},
@@ -1269,6 +1270,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
sessionKey: decision.route.sessionKey,
},
ctxPayload,
// Forward the owning runtime's bound dispatcher into the turn plan; never invoked here.
dispatchReplyFromConfig: pluginChannelRuntime?.reply?.dispatchReplyFromConfig,
record: {
updateLastRoute:
!decision.isGroup && updateTarget
@@ -523,6 +523,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
accountId: route.accountId,
route: { agentId: route.agentId, sessionKey: route.sessionKey },
ctxPayload,
// Forward the owning runtime's bound dispatcher into the turn plan; never invoked here.
dispatchReplyFromConfig: deps.channelRuntime?.reply?.dispatchReplyFromConfig,
record: {
updateLastRoute: !entry.isGroup
? {
@@ -123,6 +123,7 @@ export async function runTelegramDispatchTurn(turn: Turn) {
},
ctxPayload: context.ctxPayload,
record: context.turn.record,
dispatchReplyFromConfig: turn.opts.dispatchReplyFromConfig,
delivery: {
deliverWithProviderMessageSending: async (payload, info) =>
await deliverReply(turn, payload, info),
@@ -4,6 +4,7 @@ import {
createContext,
createRuntime,
createStatusReactionController,
dispatchReplyWithBufferedBlockDispatcher,
describeTelegramDispatch,
dispatchWithContext,
} from "./bot-message-dispatch.test-harness.js";
@@ -11,6 +12,22 @@ import type { TelegramMessageContext } from "./bot-message-dispatch.test-harness
import { telegramInboundEventDelivery } from "./inbound-event-delivery.js";
describeTelegramDispatch("dispatchTelegramMessage pipeline-init", () => {
it("keeps the owning Gateway reply dispatcher on the assembled inbound turn", async () => {
const dispatchReplyFromConfig = vi.fn();
await dispatchWithContext({
context: createContext(),
opts: {
token: "token",
dispatchReplyFromConfig,
} as Parameters<typeof dispatchWithContext>[0]["opts"],
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({ dispatchReplyFromConfig }),
);
});
it("does not enter the reply pipeline after the durable owner aborts", async () => {
const abortController = new AbortController();
abortController.abort(new Error("handler-timeout"));
@@ -241,6 +241,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
toolsAllow: resolved.toolsAllow,
replyOptions: resolved.replyOptions,
replyResolver: resolved.replyResolver,
dispatchReplyFromConfig: resolved.dispatchReplyFromConfig,
});
return withTelegramTestSettledReceipt(dispatchResult);
},
@@ -673,6 +674,7 @@ export async function dispatchWithContext(params: {
textLimit?: number;
turnAdoptionLifecycle?: Parameters<typeof dispatchTelegramMessage>[0]["turnAdoptionLifecycle"];
runtime?: Parameters<typeof dispatchTelegramMessage>[0]["runtime"];
opts?: Parameters<typeof dispatchTelegramMessage>[0]["opts"];
}) {
const bot = params.bot ?? createBot();
return await dispatchTelegramMessage({
@@ -685,7 +687,7 @@ export async function dispatchWithContext(params: {
textLimit: params.textLimit ?? 4096,
telegramCfg: params.telegramCfg ?? {},
telegramDeps: params.telegramDeps ?? telegramDepsForTest,
opts: { token: "token" },
opts: params.opts ?? { token: "token" },
retryDispatchErrors: params.retryDispatchErrors,
suppressFailureFallback: params.suppressFailureFallback,
turnAdoptionLifecycle: params.turnAdoptionLifecycle,
@@ -35,7 +35,10 @@ export type DispatchTelegramMessageParams = {
textLimit: number;
telegramCfg: TelegramAccountConfig;
telegramDeps?: TelegramBotDeps;
opts: Pick<TelegramBotOptions, "token" | "mediaMaxMb" | "ownerAgentId">;
opts: Pick<
TelegramBotOptions,
"token" | "mediaMaxMb" | "ownerAgentId" | "dispatchReplyFromConfig"
>;
retryDispatchErrors?: boolean;
suppressFailureFallback?: boolean;
/**
+6 -1
View File
@@ -72,7 +72,12 @@ type TelegramMessageProcessorDeps = Omit<
buildContext?: typeof import("openclaw/plugin-sdk/channel-inbound").buildChannelInboundEventContext;
opts: Pick<
TelegramBotOptions,
"token" | "ownerAgentId" | "allowFrom" | "groupAllowFrom" | "replyToMode"
| "token"
| "ownerAgentId"
| "allowFrom"
| "groupAllowFrom"
| "replyToMode"
| "dispatchReplyFromConfig"
>;
};
+5
View File
@@ -1,10 +1,13 @@
// Telegram type declarations define plugin contracts.
import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import type { TelegramBotInfo } from "./bot-info.js";
import type { TelegramTransport } from "./fetch.js";
type DispatchReplyFromConfig = NonNullable<ChannelInboundTurnPlan["dispatchReplyFromConfig"]>;
export type TelegramBotOptions = {
token: string;
accountId?: string;
@@ -12,6 +15,8 @@ export type TelegramBotOptions = {
ownerAgentId?: string;
runtime?: RuntimeEnv;
buildContext?: typeof import("openclaw/plugin-sdk/channel-inbound").buildChannelInboundEventContext;
/** Instance-bound reply dispatcher prepared by the owning plugin runtime. */
dispatchReplyFromConfig?: DispatchReplyFromConfig;
requireMention?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
+9 -4
View File
@@ -154,6 +154,9 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const proxyFetch =
opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined);
// SAFETY: Gateway startup supplies the full plugin channel runtime; the surface type is the minimal external view.
const pluginChannelRuntime = opts.channelRuntime as PluginRuntime["channel"] | undefined;
if (opts.useWebhook) {
const { startTelegramWebhook } = await loadTelegramMonitorWebhookRuntime();
if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
@@ -176,8 +179,9 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
secret: opts.webhookSecret ?? account.config.webhookSecret,
host: opts.webhookHost ?? account.config.webhookHost,
runtime: opts.runtime as RuntimeEnv,
buildContext: (opts.channelRuntime as PluginRuntime["channel"] | undefined)?.inbound
.buildContext,
buildContext: pluginChannelRuntime?.inbound.buildContext,
// Forward the owning runtime's bound dispatcher into the turn plan; never invoked here.
dispatchReplyFromConfig: pluginChannelRuntime?.reply?.dispatchReplyFromConfig,
fetch: proxyFetch,
abortSignal: opts.abortSignal,
publicUrl: opts.webhookUrl,
@@ -278,8 +282,9 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
accountId: account.accountId,
ownerAgentId,
runtime: opts.runtime,
buildContext: (opts.channelRuntime as PluginRuntime["channel"] | undefined)?.inbound
.buildContext,
buildContext: pluginChannelRuntime?.inbound.buildContext,
// Forward the owning runtime's bound dispatcher into the turn plan; never invoked here.
dispatchReplyFromConfig: pluginChannelRuntime?.reply?.dispatchReplyFromConfig,
proxyFetch,
botInfo: opts.botInfo,
abortSignal: opts.abortSignal,
@@ -89,6 +89,7 @@ type TelegramPollingSessionOpts = {
ownerAgentId?: string;
runtime: Parameters<typeof createTelegramBot>[0]["runtime"];
buildContext?: Parameters<typeof createTelegramBot>[0]["buildContext"];
dispatchReplyFromConfig?: Parameters<typeof createTelegramBot>[0]["dispatchReplyFromConfig"];
proxyFetch: Parameters<typeof createTelegramBot>[0]["proxyFetch"];
botInfo?: Parameters<typeof createTelegramBot>[0]["botInfo"];
abortSignal?: AbortSignal;
@@ -310,6 +311,7 @@ export class TelegramPollingSession {
token: this.opts.token,
runtime: this.opts.runtime,
buildContext: this.opts.buildContext,
dispatchReplyFromConfig: this.opts.dispatchReplyFromConfig,
proxyFetch: this.opts.proxyFetch,
config: this.opts.config,
accountId: this.opts.accountId,
+2
View File
@@ -304,6 +304,7 @@ export async function startTelegramWebhook(opts: {
secret?: string;
runtime?: RuntimeEnv;
buildContext?: Parameters<typeof createTelegramBot>[0]["buildContext"];
dispatchReplyFromConfig?: Parameters<typeof createTelegramBot>[0]["dispatchReplyFromConfig"];
fetch?: typeof fetch;
abortSignal?: AbortSignal;
healthPath?: string;
@@ -361,6 +362,7 @@ export async function startTelegramWebhook(opts: {
token: opts.token,
runtime,
buildContext: opts.buildContext,
dispatchReplyFromConfig: opts.dispatchReplyFromConfig,
proxyFetch: opts.fetch,
fetchAbortSignal: botFetchAbortSignal,
accountAbortSignal,
@@ -248,6 +248,10 @@ export async function monitorWebChannel(
return meta?.participants?.length ? meta : undefined;
},
createListener: async ({ sock, connection: connectionLocal }) => {
// SAFETY: Gateway startup supplies the full plugin channel runtime; the surface type is the minimal external view.
const pluginChannelRuntime = tuning.channelRuntime as
| PluginRuntime["channel"]
| undefined;
const onMessage = createWebOnMessageHandler({
cfg,
loadConfig: loadCurrentMonitorConfig,
@@ -262,8 +266,9 @@ export async function monitorWebChannel(
replyLogger,
baseMentionConfig,
account,
buildContext: (tuning.channelRuntime as PluginRuntime["channel"] | undefined)?.inbound
.buildContext,
buildContext: pluginChannelRuntime?.inbound.buildContext,
// Forward the owning runtime's bound dispatcher into the turn plan; never invoked here.
dispatchReplyFromConfig: pluginChannelRuntime?.reply?.dispatchReplyFromConfig,
});
return (await (listenerFactory ?? attachWebInboxToSocket)({
cfg,
@@ -1,5 +1,6 @@
// Whatsapp plugin module implements on message behavior.
import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback";
import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
ensureConfiguredBindingRouteReady,
@@ -43,6 +44,7 @@ export function createWebOnMessageHandler(params: {
baseMentionConfig: MentionConfig;
account: { authDir?: string; accountId?: string; selfChatMode?: boolean };
buildContext?: typeof import("openclaw/plugin-sdk/channel-inbound").buildChannelInboundEventContext;
dispatchReplyFromConfig?: NonNullable<ChannelInboundTurnPlan["dispatchReplyFromConfig"]>;
}) {
const hasExplicitlyPassedInboundAccess = (msg: AdmittedWebInboundMessage): boolean =>
msg.admission.ingress.decision === "allow";
@@ -103,6 +105,7 @@ export function createWebOnMessageHandler(params: {
replyLogger: params.replyLogger,
backgroundTasks: params.backgroundTasks,
buildContext: params.buildContext,
dispatchReplyFromConfig: params.dispatchReplyFromConfig,
};
if (opts?.groupHistory !== undefined) {
processParams.groupHistory = opts.groupHistory;
@@ -261,6 +261,7 @@ const baseRoute = {
function callProcessMessage(
overrides: {
cfg?: unknown;
dispatchReplyFromConfig?: Parameters<typeof processMessage>[0]["dispatchReplyFromConfig"];
groupHistories?: Map<string, unknown[]>;
msg?: unknown;
} = {},
@@ -275,6 +276,7 @@ function callProcessMessage(
connectionId: "conn-1",
verbose: false,
maxMediaBytes: 1024,
dispatchReplyFromConfig: overrides.dispatchReplyFromConfig,
replyResolver: (async () => undefined) as never,
replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never,
backgroundTasks: new Set(),
@@ -576,7 +578,7 @@ describe("processMessage group system prompt wiring", () => {
);
});
it("passes one lifecycle identity through the portable boundary and reply plan", async () => {
it("passes one lifecycle and owning dispatcher through the portable turn boundary", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({
Body: "hi",
@@ -592,9 +594,13 @@ describe("processMessage group system prompt wiring", () => {
onDeferred: vi.fn(),
onAbandoned: vi.fn(async () => undefined),
};
const dispatchReplyFromConfig = vi.fn(async () => ({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
}));
const msg = attachWhatsAppIngressLifecycle(makeBaseMsg(), lifecycle as never);
await callProcessMessage({ msg });
await callProcessMessage({ msg, dispatchReplyFromConfig });
const runParams = mockCallArg(runChannelInboundEventParamsMock, "runChannelInboundEvent") as {
raw?: unknown;
@@ -604,6 +610,7 @@ describe("processMessage group system prompt wiring", () => {
turnAdoptionLifecycle?: unknown;
};
expect(runParams.turnAdoptionLifecycle).toBe(replyPlanParams.turnAdoptionLifecycle);
expect(dispatchReplyFromConfig).toHaveBeenCalledOnce();
expect(runParams.raw).not.toHaveProperty("platform");
expect(runParams.raw).not.toHaveProperty("admission");
});
@@ -6,6 +6,7 @@ import {
} from "openclaw/plugin-sdk/channel-feedback";
import {
type buildChannelInboundEventContext,
type ChannelInboundTurnPlan,
formatMediaPlaceholderText,
runChannelInboundEvent,
} from "openclaw/plugin-sdk/channel-inbound";
@@ -214,6 +215,7 @@ export async function processMessage(params: {
* - undefined (omitted) → caller did not attempt preflight; run internal STT as normal */
preflightAudioTranscript?: string | null;
buildContext?: typeof buildChannelInboundEventContext;
dispatchReplyFromConfig?: NonNullable<ChannelInboundTurnPlan["dispatchReplyFromConfig"]>;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
if (admission.ingress.admission !== "dispatch" && admission.ingress.admission !== "observe") {
@@ -586,6 +588,7 @@ export async function processMessage(params: {
accountId: params.route.accountId,
route: { agentId: params.route.agentId, sessionKey: params.route.sessionKey },
ctxPayload,
dispatchReplyFromConfig: params.dispatchReplyFromConfig,
record: {
onRecordError: (err) => {
params.replyLogger.warn(
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { DispatchReplyFromConfig } from "./dispatch-from-config.types.js";
import type {
ReplyDispatcherOptions,
ReplyDispatcherWithTypingOptions,
@@ -36,16 +37,18 @@ describe("provider dispatcher wrappers", () => {
hoisted.plainDispatchMock.mockResolvedValue(dispatchResult);
});
it("forwards runtime toolsAllow through the buffered wrapper", async () => {
it("forwards allowed tools and the owning dispatcher through the buffered wrapper", async () => {
const dispatcherOptions = {
deliver: async () => ({ visibleReplySent: false }),
} satisfies ReplyDispatcherWithTypingOptions;
const dispatchReplyFromConfig = vi.fn<DispatchReplyFromConfig>();
await dispatchReplyWithBufferedBlockDispatcherCore({
ctx: { Body: "hello" },
cfg: {} as OpenClawConfig,
dispatcherOptions,
toolsAllow: ["message"],
dispatchReplyFromConfig,
});
expect(hoisted.bufferedDispatchMock).toHaveBeenCalledTimes(1);
@@ -53,6 +56,7 @@ describe("provider dispatcher wrappers", () => {
expect.objectContaining({
dispatcherOptions,
toolsAllow: ["message"],
dispatchReplyFromConfig,
}),
);
});
@@ -23,6 +23,7 @@ export const dispatchReplyWithBufferedBlockDispatcherCore: DispatchReplyWithBuff
toolsAllow: params.toolsAllow,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
dispatchReplyFromConfig: params.dispatchReplyFromConfig,
});
};
@@ -3,7 +3,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginCommandReplyOptions } from "../../plugins/plugin-command-dispatch-contract.js";
import type { GetReplyOptions } from "../get-reply-options.types.js";
import type { FinalizedMsgContext, MsgContext } from "../templating.js";
import type { DispatchFromConfigResult } from "./dispatch-from-config.types.js";
import type {
DispatchFromConfigResult,
DispatchReplyFromConfig,
} from "./dispatch-from-config.types.js";
import type { GetReplyFromConfig } from "./get-reply.types.js";
import type {
ReplyDispatcherOptions,
@@ -21,6 +24,7 @@ export type DispatchReplyWithBufferedBlockDispatcher = (params: {
toolsAllow?: string[];
replyOptions?: DispatchReplyOptions;
replyResolver?: GetReplyFromConfig;
dispatchReplyFromConfig?: DispatchReplyFromConfig;
}) => Promise<DispatchFromConfigResult>;
/** Plain dispatcher entry point used when block buffering is not needed. */
@@ -153,12 +153,12 @@ const SCOPED_BUILDER_HANDOFFS = [
[
"discord",
"extensions/discord/src/monitor/provider.ts",
'opts.channelRuntime as PluginRuntime["channel"] | undefined',
"buildContext: pluginChannelRuntime?.inbound.buildContext",
],
[
"imessage",
"extensions/imessage/src/monitor/monitor-provider.ts",
'opts.channelRuntime as PluginRuntime["channel"] | undefined',
"buildContext: pluginChannelRuntime?.inbound.buildContext",
],
[
"line",
@@ -181,7 +181,7 @@ const SCOPED_BUILDER_HANDOFFS = [
[
"telegram-webhook",
"extensions/telegram/src/monitor.ts",
'opts.channelRuntime as PluginRuntime["channel"] | undefined',
"buildContext: pluginChannelRuntime?.inbound.buildContext",
],
["telegram-webhook", "extensions/telegram/src/webhook.ts", "buildContext: opts.buildContext"],
[
@@ -198,7 +198,7 @@ const SCOPED_BUILDER_HANDOFFS = [
[
"whatsapp",
"extensions/whatsapp/src/auto-reply/monitor.ts",
'tuning.channelRuntime as PluginRuntime["channel"] | undefined',
"buildContext: pluginChannelRuntime?.inbound.buildContext",
],
[
"whatsapp",
@@ -236,12 +236,6 @@ describe("channel context builder caller inventory", () => {
expect(source("extensions/signal/src/monitor.ts")).toContain(
"channelRuntime: opts.channelRuntime",
);
expect(source("extensions/telegram/src/monitor.ts")).toContain(
'opts.channelRuntime as PluginRuntime["channel"] | undefined',
);
expect(source("extensions/whatsapp/src/auto-reply/monitor.ts")).toContain(
'tuning.channelRuntime as PluginRuntime["channel"] | undefined',
);
});
it("does not rediscover host context builders through process-global runtime stores", () => {
+18
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { createTerminalTool } from "../agents/tools/terminal-tool.js";
import {
getGlobalPluginRegistry,
initializeGlobalHookRunner,
@@ -596,12 +597,29 @@ describe("loadGatewayPlugins", () => {
});
test("binds channel reply dispatch to the owning Gateway context", async () => {
const terminalSessions = { listAgent: vi.fn(() => []) };
const context = {
label: "channel-reply-owner",
workerSessionPlacementService: { getMany: vi.fn(() => new Map()) },
terminalSessions,
} as unknown as GatewayRequestContext;
serverPluginsModule.setFallbackGatewayContext(context);
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
dispatchReplyFromConfig.mockImplementationOnce(async () => {
const result = await createTerminalTool({
agentId: "main",
agentSessionKey: "agent:main:telegram:direct:123",
sessionId: "session-123",
}).execute("terminal-list", { action: "list" });
expect(result.details).toEqual({ sessions: [] });
expect(terminalSessions.listAgent).toHaveBeenCalledWith({
kind: "agent",
agentId: "main",
agentSessionKey: "agent:main:telegram:direct:123",
agentSessionId: "session-123",
});
return { counts: {}, queuedFinal: false };
});
loadGatewayPluginsForTest();
const runtimeOptions = getLastPluginLoadOption("runtimeOptions") as