fix: backwards compatibility for delivery context and reason detection

- isDeliveryContextExpired: contexts without timestamp are now valid (not expired)
- mergeDeliveryContext: only add updatedAt if at least one input has it
- resolveHeartbeatDeliveryTarget: fix allowFrom-fallback reason when explicit mode rejects
- Update test for requireExplicitTarget default (now false)
This commit is contained in:
Diogo Ortega 2026-01-26 14:25:30 +00:00
parent e260f38a69
commit db386bef7e
3 changed files with 24 additions and 17 deletions

View File

@ -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", () => {

View File

@ -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";
}
}

View File

@ -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,