diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index 32335c902..bc3ac2985 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -10,8 +10,10 @@ import { normalizeOutboundPayloads, normalizeOutboundPayloadsForJson, } from "../../infra/outbound/payloads.js"; -import { resolveAgentDeliveryPlan } from "../../infra/outbound/agent-delivery.js"; -import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { + resolveAgentDeliveryPlan, + resolveAgentOutboundTarget, +} from "../../infra/outbound/agent-delivery.js"; import type { RuntimeEnv } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import type { AgentCommandOpts } from "./types.js"; @@ -51,17 +53,21 @@ export async function deliverAgentCommandResult(params: { const targetMode = opts.deliveryTargetMode ?? deliveryPlan.deliveryTargetMode ?? (opts.to ? "explicit" : "implicit"); const resolvedAccountId = deliveryPlan.resolvedAccountId; - const resolvedTarget = + const resolved = deliver && isDeliveryChannelKnown && deliveryChannel - ? resolveOutboundTarget({ - channel: deliveryChannel, - to: deliveryPlan.resolvedTo, + ? resolveAgentOutboundTarget({ cfg, - accountId: resolvedAccountId, - mode: targetMode, + plan: deliveryPlan, + targetMode, + validateExplicitTarget: true, }) - : null; - const deliveryTarget = resolvedTarget?.ok ? resolvedTarget.to : undefined; + : { + resolvedTarget: null, + resolvedTo: deliveryPlan.resolvedTo, + targetMode, + }; + const resolvedTarget = resolved.resolvedTarget; + const deliveryTarget = resolved.resolvedTo; const logDeliveryError = (err: unknown) => { const message = `Delivery failed (${deliveryChannel}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 33c2d7b10..51f8efe95 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -8,8 +8,10 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; -import { resolveAgentDeliveryPlan } from "../../infra/outbound/agent-delivery.js"; -import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { + resolveAgentDeliveryPlan, + resolveAgentOutboundTarget, +} from "../../infra/outbound/agent-delivery.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js"; @@ -218,14 +220,14 @@ export const agentHandlers: GatewayRequestHandlers = { if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) { const cfg = cfgForAgent ?? loadConfig(); - const fallback = resolveOutboundTarget({ - channel: resolvedChannel, + const fallback = resolveAgentOutboundTarget({ cfg, - accountId: resolvedAccountId, - mode: "implicit", + plan: deliveryPlan, + targetMode: "implicit", + validateExplicitTarget: false, }); - if (fallback.ok) { - resolvedTo = fallback.to; + if (fallback.resolvedTarget?.ok) { + resolvedTo = fallback.resolvedTo; } } diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts new file mode 100644 index 000000000..c1e752cf1 --- /dev/null +++ b/src/infra/outbound/agent-delivery.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+1999" })), +})); + +vi.mock("./targets.js", async () => { + const actual = await vi.importActual("./targets.js"); + return { + ...actual, + resolveOutboundTarget: mocks.resolveOutboundTarget, + }; +}); + +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveAgentDeliveryPlan, resolveAgentOutboundTarget } from "./agent-delivery.js"; + +describe("agent delivery helpers", () => { + it("builds a delivery plan from session delivery context", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + deliveryContext: { channel: "whatsapp", to: "+1555", accountId: "work" }, + }, + requestedChannel: "last", + explicitTo: undefined, + accountId: undefined, + wantsDelivery: true, + }); + + expect(plan.resolvedChannel).toBe("whatsapp"); + expect(plan.resolvedTo).toBe("+1555"); + expect(plan.resolvedAccountId).toBe("work"); + expect(plan.deliveryTargetMode).toBe("implicit"); + }); + + it("resolves fallback targets when no explicit destination is provided", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + deliveryContext: { channel: "whatsapp" }, + }, + requestedChannel: "last", + explicitTo: undefined, + accountId: undefined, + wantsDelivery: true, + }); + + const resolved = resolveAgentOutboundTarget({ + cfg: {} as ClawdbotConfig, + plan, + targetMode: "implicit", + }); + + expect(mocks.resolveOutboundTarget).toHaveBeenCalledTimes(1); + expect(resolved.resolvedTarget?.ok).toBe(true); + expect(resolved.resolvedTo).toBe("+1999"); + }); + + it("skips outbound target resolution when explicit target validation is disabled", () => { + const plan = resolveAgentDeliveryPlan({ + sessionEntry: { + deliveryContext: { channel: "whatsapp", to: "+1555" }, + }, + requestedChannel: "last", + explicitTo: "+1555", + accountId: undefined, + wantsDelivery: true, + }); + + mocks.resolveOutboundTarget.mockClear(); + const resolved = resolveAgentOutboundTarget({ + cfg: {} as ClawdbotConfig, + plan, + targetMode: "explicit", + validateExplicitTarget: false, + }); + + expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled(); + expect(resolved.resolvedTo).toBe("+1555"); + }); +}); diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index adde8ee24..34db4042d 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -9,7 +9,9 @@ import { normalizeMessageChannel, type GatewayMessageChannel, } from "../../utils/message-channel.js"; -import { resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets.js"; +import { resolveOutboundTarget, resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { OutboundTargetResolution } from "./targets.js"; export type AgentDeliveryPlan = { baseDelivery: SessionDeliveryTarget; @@ -86,3 +88,45 @@ export function resolveAgentDeliveryPlan(params: { deliveryTargetMode, }; } + +export function resolveAgentOutboundTarget(params: { + cfg: ClawdbotConfig; + plan: AgentDeliveryPlan; + targetMode?: ChannelOutboundTargetMode; + validateExplicitTarget?: boolean; +}): { + resolvedTarget: OutboundTargetResolution | null; + resolvedTo?: string; + targetMode: ChannelOutboundTargetMode; +} { + const targetMode = + params.targetMode ?? + params.plan.deliveryTargetMode ?? + (params.plan.resolvedTo ? "explicit" : "implicit"); + if (!isDeliverableMessageChannel(params.plan.resolvedChannel)) { + return { + resolvedTarget: null, + resolvedTo: params.plan.resolvedTo, + targetMode, + }; + } + if (params.validateExplicitTarget !== true && params.plan.resolvedTo) { + return { + resolvedTarget: null, + resolvedTo: params.plan.resolvedTo, + targetMode, + }; + } + const resolvedTarget = resolveOutboundTarget({ + channel: params.plan.resolvedChannel, + to: params.plan.resolvedTo, + cfg: params.cfg, + accountId: params.plan.resolvedAccountId, + mode: targetMode, + }); + return { + resolvedTarget, + resolvedTo: resolvedTarget.ok ? resolvedTarget.to : params.plan.resolvedTo, + targetMode, + }; +}