diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index e8995a0b9..e640a8d94 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -5,6 +5,7 @@ import { truncateUtf16Safe } from "../../utils.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch @@ -158,6 +159,15 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { throw new Error("job required"); } const job = normalizeCronJobCreate(params.job) ?? params.job; + if (job && typeof job === "object") { + const cfg = loadConfig(); + const agentId = opts?.agentSessionKey + ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) + : undefined; + if (agentId && !(job as { agentId?: unknown }).agentId) { + (job as { agentId?: string }).agentId = agentId; + } + } const contextMessages = typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages) ? params.contextMessages diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index f552f17fd..ae49d25bf 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -114,7 +114,8 @@ export async function handleDiscordMessagingAction( required: true, label: "stickerIds", }); - await sendStickerDiscord(to, stickerIds, { content }); + const accountId = readStringParam(params, "accountId"); + await sendStickerDiscord(to, stickerIds, { content, accountId: accountId ?? undefined }); return jsonResult({ ok: true }); } case "poll": { @@ -137,10 +138,11 @@ export async function handleDiscordMessagingAction( const durationHours = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined; const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1; + const accountId = readStringParam(params, "accountId"); await sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, - { content }, + { content, accountId: accountId ?? undefined }, ); return jsonResult({ ok: true }); } @@ -211,7 +213,10 @@ export async function handleDiscordMessagingAction( const replyTo = readStringParam(params, "replyTo"); const embeds = Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined; + const accountId = readStringParam(params, "accountId"); + const result = await sendMessageDiscord(to, content, { + accountId: accountId ?? undefined, mediaUrl, replyTo, embeds, @@ -298,7 +303,9 @@ export async function handleDiscordMessagingAction( }); const mediaUrl = readStringParam(params, "mediaUrl"); const replyTo = readStringParam(params, "replyTo"); + const accountId = readStringParam(params, "accountId"); const result = await sendMessageDiscord(`channel:${channelId}`, content, { + accountId: accountId ?? undefined, mediaUrl, replyTo, }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4ab3c7e18..21974f074 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -342,6 +342,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { }) as ChannelMessageActionName; const accountId = readStringParam(params, "accountId") ?? agentAccountId; + if (accountId) { + params.accountId = accountId; + } const gateway = { url: readStringParam(params, "gatewayUrl", { trim: false }), diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts index d68aba74b..8deda7dc6 100644 --- a/src/channels/plugins/actions/discord.test.ts +++ b/src/channels/plugins/actions/discord.test.ts @@ -1,11 +1,41 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../../../config/config.js"; -import { discordMessageActions } from "./discord.js"; +type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord; +type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord; + +const sendMessageDiscord = vi.fn, ReturnType>( + async () => ({ ok: true }) as Awaited>, +); +const sendPollDiscord = vi.fn, ReturnType>( + async () => ({ ok: true }) as Awaited>, +); + +vi.mock("../../../discord/send.js", async () => { + const actual = await vi.importActual( + "../../../discord/send.js", + ); + return { + ...actual, + sendMessageDiscord: (...args: Parameters) => sendMessageDiscord(...args), + sendPollDiscord: (...args: Parameters) => sendPollDiscord(...args), + }; +}); + +const loadHandleDiscordMessageAction = async () => { + const mod = await import("./discord/handle-action.js"); + return mod.handleDiscordMessageAction; +}; + +const loadDiscordMessageActions = async () => { + const mod = await import("./discord.js"); + return mod.discordMessageActions; +}; describe("discord message actions", () => { - it("lists channel and upload actions by default", () => { + it("lists channel and upload actions by default", async () => { const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig; + const discordMessageActions = await loadDiscordMessageActions(); const actions = discordMessageActions.listActions?.({ cfg }) ?? []; expect(actions).toContain("emoji-upload"); @@ -13,12 +43,65 @@ describe("discord message actions", () => { expect(actions).toContain("channel-create"); }); - it("respects disabled channel actions", () => { + it("respects disabled channel actions", async () => { const cfg = { channels: { discord: { token: "d0", actions: { channels: false } } }, } as ClawdbotConfig; + const discordMessageActions = await loadDiscordMessageActions(); const actions = discordMessageActions.listActions?.({ cfg }) ?? []; expect(actions).not.toContain("channel-create"); }); }); + +describe("handleDiscordMessageAction", () => { + it("forwards context accountId for send", async () => { + sendMessageDiscord.mockClear(); + const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); + + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + }, + cfg: {} as ClawdbotConfig, + accountId: "ops", + }); + + expect(sendMessageDiscord).toHaveBeenCalledWith( + "channel:123", + "hi", + expect.objectContaining({ + accountId: "ops", + }), + ); + }); + + it("falls back to params accountId when context missing", async () => { + sendPollDiscord.mockClear(); + const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); + + await handleDiscordMessageAction({ + action: "poll", + params: { + to: "channel:123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + accountId: "marve", + }, + cfg: {} as ClawdbotConfig, + }); + + expect(sendPollDiscord).toHaveBeenCalledWith( + "channel:123", + expect.objectContaining({ + question: "Ready?", + options: ["Yes", "No"], + }), + expect.objectContaining({ + accountId: "marve", + }), + ); + }); +}); diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index fcae08633..ebed5eb0d 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -80,7 +80,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = { } return null; }, - handleAction: async ({ action, params, cfg }) => { - return await handleDiscordMessageAction({ action, params, cfg }); + handleAction: async ({ action, params, cfg, accountId }) => { + return await handleDiscordMessageAction({ action, params, cfg, accountId }); }, }; diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 82f08e686..031ca9f5b 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -18,9 +18,10 @@ function readParentIdParam(params: Record): string | null | und } export async function handleDiscordMessageAction( - ctx: Pick, + ctx: Pick, ): Promise> { const { action, params, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(params, "accountId"); const resolveChannelId = () => resolveDiscordChannelId( @@ -39,6 +40,7 @@ export async function handleDiscordMessageAction( return await handleDiscordAction( { action: "sendMessage", + accountId: accountId ?? undefined, to, content, mediaUrl: mediaUrl ?? undefined, @@ -62,6 +64,7 @@ export async function handleDiscordMessageAction( return await handleDiscordAction( { action: "poll", + accountId: accountId ?? undefined, to, question, answers, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 296cf6aad..4f8f4deb3 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -299,6 +299,7 @@ export async function runCronIsolatedAgentTurn(params: { sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, messageChannel, + agentAccountId: resolvedDelivery.accountId, sessionFile, workspaceDir, config: cfgWithAgentDefaults, diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 0fd10eb3b..9b592d9d2 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -410,3 +410,65 @@ describe("runMessageAction sendAttachment hydration", () => { ); }); }); + +describe("runMessageAction accountId defaults", () => { + const handleAction = vi.fn(async () => jsonResult({ ok: true })); + const accountPlugin: ChannelPlugin = { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + actions: { + listActions: () => ["send"], + handleAction, + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: accountPlugin, + }, + ]), + ); + handleAction.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + it("propagates defaultAccountId into params", async () => { + await runMessageAction({ + cfg: {} as ClawdbotConfig, + action: "send", + params: { + channel: "discord", + target: "channel:123", + message: "hi", + }, + defaultAccountId: "ops", + }); + + expect(handleAction).toHaveBeenCalled(); + const ctx = handleAction.mock.calls[0]?.[0] as { + accountId?: string | null; + params: Record; + }; + expect(ctx.accountId).toBe("ops"); + expect(ctx.params.accountId).toBe("ops"); + }); +}); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index dc8aeddf3..051098f34 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -803,6 +803,9 @@ export async function runMessageAction( const channel = await resolveChannel(cfg, params); const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + if (accountId) { + params.accountId = accountId; + } const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); await hydrateSendAttachmentParams({