diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index bd7e542d6..437ae89b4 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -408,26 +408,26 @@ describe("resolveHeartbeatDeliveryTarget", () => { ).toThrow(/requireExplicitTarget is enabled but no explicit 'to' target was provided/); }); - it("throws error when requireExplicitTarget defaults to true and no to is set", () => { + it("allows implicit routing when requireExplicitTarget defaults to false", () => { const cfg: ClawdbotConfig = { agents: { defaults: { heartbeat: { target: "whatsapp", - // requireExplicitTarget defaults to true now - // no `to` set + // requireExplicitTarget defaults to false (backwards compatible) + // no `to` set - should use implicit routing }, }, }, channels: { whatsapp: { allowFrom: ["+1555000001"] } }, }; - // FIX-1.4: Default is now strict mode - throws if no explicit target - expect(() => - resolveHeartbeatDeliveryTarget({ - cfg, - entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" }, - }), - ).toThrow(/requireExplicitTarget is enabled but no explicit 'to' target was provided/); + // Default is backwards compatible - allows implicit routing + const result = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" }, + }); + expect(result.channel).toBe("whatsapp"); + expect(result.to).toBe("+1555000001"); }); it("allows heartbeat delivery when requireExplicitTarget is true and to is set", () => { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index e33f7dbb0..32b3e6d24 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -257,7 +257,10 @@ export function resolveHeartbeatDeliveryTarget(params: { accountId: resolvedTarget.accountId, mode: "explicit", }); - if (explicit.ok && explicit.to !== resolved.to) { + // Set reason to "allowFrom-fallback" if: + // 1. Explicit mode failed (target not in allowlist) and heartbeat succeeded, OR + // 2. Explicit mode succeeded but returned a different target than heartbeat mode + if (!explicit.ok || (explicit.ok && explicit.to !== resolved.to)) { reason = "allowFrom-fallback"; } } diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index da688b2e7..c91329cff 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -64,7 +64,7 @@ export type DeliveryContextSessionSource = { * Check if a delivery context has expired based on its updatedAt timestamp. * @param context The delivery context to check * @param ttlMs TTL in milliseconds (default: 24 hours) - * @returns true if the context has expired or has no timestamp + * @returns true if the context has expired, false if valid or no timestamp (backwards compatible) */ export function isDeliveryContextExpired( context?: DeliveryContext, @@ -72,7 +72,8 @@ export function isDeliveryContextExpired( ): boolean { if (!context) return true; const updatedAt = context.updatedAt; - if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return true; + // Backwards compatibility: contexts without timestamps are considered valid (not expired) + if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return false; const now = Date.now(); return now - updatedAt > ttlMs; } @@ -185,10 +186,13 @@ export function mergeDeliveryContext( const normalizedPrimary = normalizeDeliveryContext(primary); const normalizedFallback = normalizeDeliveryContext(fallback); if (!normalizedPrimary && !normalizedFallback) return undefined; - // Use the most recent timestamp from either context - const primaryUpdatedAt = normalizedPrimary?.updatedAt ?? 0; - const fallbackUpdatedAt = normalizedFallback?.updatedAt ?? 0; - const updatedAt = Math.max(primaryUpdatedAt, fallbackUpdatedAt) || Date.now(); + // Use the most recent timestamp from either context (only if at least one has it) + const primaryUpdatedAt = normalizedPrimary?.updatedAt; + const fallbackUpdatedAt = normalizedFallback?.updatedAt; + const hasTimestamp = primaryUpdatedAt != null || fallbackUpdatedAt != null; + const updatedAt = hasTimestamp + ? Math.max(primaryUpdatedAt ?? 0, fallbackUpdatedAt ?? 0) + : undefined; return normalizeDeliveryContext({ channel: normalizedPrimary?.channel ?? normalizedFallback?.channel, to: normalizedPrimary?.to ?? normalizedFallback?.to,