fix: pass message routing context to send actions

This commit is contained in:
Peter Steinberger
2026-04-17 07:50:19 +01:00
parent 4ac8b08265
commit 2e3ef1b9e1
5 changed files with 76 additions and 178 deletions

View File

@@ -1,171 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/outbound-send-deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { messageCommand } from "./message.js";
let testConfig: Record<string, unknown> = {};
const resolveCommandConfigWithSecrets = vi.hoisted(() =>
vi.fn(async ({ config }: { config: unknown }) => ({
resolvedConfig: config,
effectiveConfig: config,
diagnostics: [] as string[],
})),
);
const runMessageAction = vi.hoisted(() =>
vi.fn(async () => ({
kind: "send" as const,
channel: "telegram" as const,
action: "send" as const,
to: "123456",
handledBy: "core" as const,
payload: { ok: true },
dryRun: false,
})),
);
vi.mock("../config/config.js", () => ({
loadConfig: () => testConfig,
}));
vi.mock("../cli/command-config-resolution.js", () => ({
resolveCommandConfigWithSecrets,
}));
vi.mock("../infra/outbound/message-action-runner.js", () => ({
runMessageAction,
}));
describe("messageCommand agent routing", () => {
beforeEach(() => {
testConfig = {};
resolveCommandConfigWithSecrets.mockClear();
runMessageAction.mockClear();
});
it("passes resolved command config and scoped secret targets to the outbound runner", async () => {
const rawConfig = {
channels: {
telegram: {
token: { $secret: "vault://telegram/token" },
},
},
};
const resolvedConfig = {
channels: {
telegram: {
token: "12345:resolved-token",
},
},
};
testConfig = rawConfig;
resolveCommandConfigWithSecrets.mockResolvedValueOnce({
resolvedConfig,
effectiveConfig: resolvedConfig,
diagnostics: [],
});
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await messageCommand(
{
action: "send",
channel: "telegram",
target: "123456",
message: "hi",
json: true,
},
{} as CliDeps,
runtime,
);
expect(resolveCommandConfigWithSecrets).toHaveBeenCalledWith(
expect.objectContaining({
config: rawConfig,
commandName: "message",
}),
);
const call = resolveCommandConfigWithSecrets.mock.calls[0]?.[0] as {
targetIds?: Set<string>;
};
expect(call.targetIds).toBeInstanceOf(Set);
expect([...(call.targetIds ?? [])].every((id) => id.startsWith("channels.telegram."))).toBe(
true,
);
expect(runMessageAction).toHaveBeenCalledWith(
expect.objectContaining({
cfg: resolvedConfig,
}),
);
});
it("passes the resolved default agent id to the outbound runner", async () => {
testConfig = {
agents: {
list: [{ id: "alpha" }, { id: "ops", default: true }],
},
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await messageCommand(
{
action: "send",
channel: "telegram",
target: "123456",
message: "hi",
json: true,
},
{} as CliDeps,
runtime,
);
expect(runMessageAction).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "ops",
}),
);
});
it.each([
{
name: "defaults senderIsOwner to true for local message runs",
opts: {},
expected: true,
},
{
name: "honors explicit senderIsOwner override",
opts: { senderIsOwner: false },
expected: false,
},
])("$name", async ({ opts, expected }) => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await messageCommand(
{
action: "send",
channel: "telegram",
target: "123456",
message: "hi",
json: true,
...opts,
},
{} as CliDeps,
runtime,
);
expect(runMessageAction).toHaveBeenCalledWith(
expect.objectContaining({
senderIsOwner: expected,
}),
);
});
});

View File

@@ -167,9 +167,22 @@ const createTelegramSendPluginRegistration = () => ({
label: "Telegram",
actions: {
describeMessageTool: () => ({ actions: ["send"] }),
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
handleAction: (async ({
action,
params,
cfg,
accountId,
agentId,
senderIsOwner,
}: ChannelActionParams) => {
return await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
{
action,
to: params.to,
accountId: accountId ?? undefined,
agentId,
senderIsOwner,
},
cfg,
);
}) as unknown as NonNullable<ChannelPlugin["actions"]>["handleAction"],
@@ -185,9 +198,22 @@ const createTelegramPollPluginRegistration = () => ({
label: "Telegram",
actions: {
describeMessageTool: () => ({ actions: ["poll"] }),
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
handleAction: (async ({
action,
params,
cfg,
accountId,
agentId,
senderIsOwner,
}: ChannelActionParams) => {
return await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
{
action,
to: params.to,
accountId: accountId ?? undefined,
agentId,
senderIsOwner,
},
cfg,
);
}) as unknown as NonNullable<ChannelPlugin["actions"]>["handleAction"],
@@ -294,6 +320,19 @@ describe("messageCommand", () => {
}),
);
expect(sendText.mock.calls[0]?.[0]?.cfg).not.toBe(rawConfig);
expect(resolveCommandConfigWithSecrets).toHaveBeenCalledWith(
expect.objectContaining({
config: rawConfig,
commandName: "message",
}),
);
const call = resolveCommandConfigWithSecrets.mock.calls[0]?.[0] as {
targetIds?: Set<string>;
};
expect(call.targetIds).toBeInstanceOf(Set);
expect([...(call.targetIds ?? [])].every((id) => id.startsWith("channels.telegram."))).toBe(
true,
);
});
it("keeps local-fallback resolved cfg in outbound adapter sends", async () => {
@@ -330,6 +369,11 @@ describe("messageCommand", () => {
it("defaults channel when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
testConfig = {
agents: {
list: [{ id: "alpha" }, { id: "ops", default: true }],
},
};
setActivePluginRegistry(
createTestRegistry([
{
@@ -346,7 +390,13 @@ describe("messageCommand", () => {
deps,
runtime,
);
expect(handleTelegramAction).toHaveBeenCalled();
expect(handleTelegramAction).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "ops",
senderIsOwner: true,
}),
expect.any(Object),
);
});
it("defaults channel from the auto-enabled config snapshot when only one channel becomes configured", async () => {
@@ -500,6 +550,7 @@ describe("messageCommand", () => {
pollQuestion: "Ship it?",
pollOption: ["Yes", "No"],
pollDurationSeconds: 120,
senderIsOwner: false,
},
deps,
runtime,
@@ -508,6 +559,7 @@ describe("messageCommand", () => {
expect.objectContaining({
action: "poll",
to: "123456789",
senderIsOwner: false,
}),
expect.any(Object),
);

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js";
import { applyNonInteractiveAuthChoice } from "./auth-choice.js";
const applyNonInteractivePluginProviderChoice = vi.hoisted(() => vi.fn(async () => undefined));
@@ -99,7 +100,9 @@ describe("applyNonInteractiveAuthChoice", () => {
provider: "default",
id: "CUSTOM_API_KEY",
});
expect(result?.agents?.defaults?.model?.primary).toBe("custom-models-custom-local/local-large");
expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe(
"custom-models-custom-local/local-large",
);
expect(resolveNonInteractiveApiKey).toHaveBeenCalledWith(
expect.objectContaining({
provider: "custom-models-custom-local",

View File

@@ -598,6 +598,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
requesterSenderE164: input.requesterSenderE164 ?? undefined,
mediaAccess: ctx.mediaAccess,
accountId: accountId ?? undefined,
senderIsOwner: input.senderIsOwner,
sessionId: input.sessionId,
gateway,
toolContext: input.toolContext,
deps: input.deps,
@@ -639,7 +641,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
}
async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx;
const { cfg, params, channel, accountId, dryRun, gateway, input, agentId, abortSignal } = ctx;
throwIfAborted(abortSignal);
const action: ChannelMessageActionName = "poll";
const to = readStringParam(params, "to", { required: true });
@@ -672,6 +674,11 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
channel,
params,
accountId: accountId ?? undefined,
agentId,
requesterSenderId: input.requesterSenderId ?? undefined,
senderIsOwner: input.senderIsOwner,
sessionKey: input.sessionKey,
sessionId: input.sessionId,
gateway,
toolContext: input.toolContext,
dryRun,

View File

@@ -40,6 +40,8 @@ export type OutboundSendContext = {
mediaAccess?: OutboundMediaAccess;
mediaReadFile?: OutboundMediaReadFile;
accountId?: string | null;
senderIsOwner?: boolean;
sessionId?: string;
gateway?: OutboundGatewayContext;
toolContext?: ChannelThreadingToolContext;
deps?: OutboundSendDeps;
@@ -100,6 +102,11 @@ async function tryHandleWithPluginAction(params: {
mediaLocalRoots: mediaAccess.localRoots,
mediaReadFile: mediaAccess.readFile,
accountId: params.ctx.accountId ?? undefined,
requesterSenderId: params.ctx.requesterSenderId,
senderIsOwner: params.ctx.senderIsOwner,
sessionKey: params.ctx.sessionKey,
sessionId: params.ctx.sessionId,
agentId: params.ctx.agentId,
gateway: params.ctx.gateway,
toolContext: params.ctx.toolContext,
dryRun: params.ctx.dryRun,