Files
openclaw/src/utils/delivery-context.shared.ts
Peter Steinberger 73bba03e4c refactor: canonicalize session delivery state (#113225)
* refactor: canonicalize session delivery state

* test: canonicalize reply persistence fixtures

* test: canonicalize talk delivery fixtures

* test: canonicalize voice session routes

* test: canonicalize attachment delivery fixtures

* test: migrate gateway delivery fixtures

* fix: skip invalid session delivery rows

* test: align delivery SDK surface gates

* fix: preserve legacy delivery precedence

* test: canonicalize heartbeat delivery fixtures

* fix: preserve delivery route prompt identity

* test: canonicalize session delivery fixtures

* fix: preserve recoverable legacy delivery routes

* fix: canonicalize remaining session state

* fix: preserve canonical session classification

* style: format delivery state changes

* test: refresh plugin SDK delivery baseline

* test: avoid mutating session fixture input

* style: simplify delivery identity check

* style: simplify delivery origin spread

* fix: preserve fresh delivery route metadata

* test: assert canonical surface route switch

* fix: canonicalize doctor file-store imports

* fix: preserve transitional delivery migration state

* fix: satisfy canonical delivery CI gates

* ci: scope GitHub App token permissions

* test: infer canonical delivery projections

* test: canonicalize ACP requester delivery fixtures

* test: canonicalize harness rollback fixture

* style: apply pinned formatter
2026-07-24 01:01:19 -07:00

309 lines
11 KiB
TypeScript

import type {
SessionDeliveryState,
SessionEntry,
SessionOrigin,
} from "../config/sessions/types.js";
// Shared delivery context helpers expose route normalization shared by modules.
import {
channelRouteCompactKey,
channelRouteThreadId,
channelRouteTarget,
normalizeChannelRouteRef,
normalizeChannelRouteTarget,
type ChannelRouteRef,
} from "../plugin-sdk/channel-route.js";
import { normalizeAccountId } from "./account-id.js";
import type { DeliveryContext } from "./delivery-context.types.js";
import {
INTERNAL_MESSAGE_CHANNEL,
isInternalNonDeliveryChannel,
} from "./message-channel-constants.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "./message-channel-core.js";
export type { DeliveryContext } from "./delivery-context.types.js";
/**
* Delivery-context normalization and projection helpers.
*
* Persisted sessions expose one closed delivery state. Compatibility
* projections are derived from that state at public boundaries.
*/
/** Normalizes a delivery context into canonical channel route fields, dropping invalid routes. */
export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryContext | undefined {
if (!context) {
return undefined;
}
const route = normalizeChannelRouteTarget({
channel:
typeof context.channel === "string"
? (normalizeMessageChannel(context.channel) ?? context.channel.trim())
: undefined,
to: context.to,
accountId: context.accountId,
threadId: context.threadId,
});
if (!route) {
return undefined;
}
const normalized: DeliveryContext = {
channel: route.channel,
to: channelRouteTarget(route),
accountId: normalizeAccountId(route.accountId),
};
const threadId = channelRouteThreadId(route);
if (threadId != null) {
normalized.threadId = threadId;
}
return normalized;
}
/** Normalizes an unknown channel route payload from persisted session/plugin metadata. */
export function normalizeDeliveryChannelRoute(route?: unknown): ChannelRouteRef | undefined {
if (!route || typeof route !== "object" || Array.isArray(route)) {
return undefined;
}
const candidate = route as ChannelRouteRef;
return normalizeChannelRouteRef({
channel: candidate.channel,
to: candidate.target?.to,
rawTo: candidate.target?.rawTo,
chatType: candidate.target?.chatType,
accountId: candidate.accountId,
threadId: candidate.thread?.id,
threadKind: candidate.thread?.kind,
threadSource: candidate.thread?.source,
});
}
/** Converts a normalized channel route reference into a delivery context. */
export function deliveryContextFromChannelRoute(
route?: ChannelRouteRef,
): DeliveryContext | undefined {
const normalized = normalizeDeliveryChannelRoute(route);
return normalizeDeliveryContext({
channel: normalized?.channel,
to: channelRouteTarget(normalized),
accountId: normalized?.accountId,
threadId: channelRouteThreadId(normalized),
});
}
/** Converts delivery context fields into the SDK channel route reference shape. */
function channelRouteFromDeliveryContext(context?: DeliveryContext): ChannelRouteRef | undefined {
return normalizeChannelRouteTarget(normalizeDeliveryContext(context));
}
function mergeRouteMetadataWithDeliveryContext(
route: ChannelRouteRef | undefined,
context: DeliveryContext,
): ChannelRouteRef | undefined {
if (!route) {
return channelRouteFromDeliveryContext(context);
}
return normalizeChannelRouteRef({
channel: route.channel ?? context.channel,
to: route.target?.to ?? context.to,
rawTo: route.target?.rawTo,
chatType: route.target?.chatType,
accountId: route.accountId ?? context.accountId,
threadId: route.thread?.id ?? context.threadId,
threadKind: route.thread?.kind,
threadSource: route.thread?.source,
});
}
function isInternalRouteContext(context?: DeliveryContext): boolean {
const channel = context?.channel;
return Boolean(
channel && (channel === INTERNAL_MESSAGE_CHANNEL || isInternalNonDeliveryChannel(channel)),
);
}
function hasExternalDeliveryTarget(context?: DeliveryContext): boolean {
const channel = normalizeMessageChannel(context?.channel);
return Boolean(
channel &&
!isInternalNonDeliveryChannel(channel) &&
isDeliverableMessageChannel(channel) &&
context?.to,
);
}
function mergeExternalDeliveryContextOverInternalRoute(
deliveryContext?: DeliveryContext,
internalContext?: DeliveryContext,
): DeliveryContext | undefined {
// Internal webchat/heartbeat routes are session plumbing. When a real channel
// target is also present, preserve internal account/thread hints but let the
// external channel/to pair own delivery.
return normalizeDeliveryContext({
channel: deliveryContext?.channel,
to: deliveryContext?.to,
accountId: deliveryContext?.accountId ?? internalContext?.accountId,
threadId: deliveryContext?.threadId ?? internalContext?.threadId,
});
}
type SessionDeliveryProjection = {
route?: ChannelRouteRef;
deliveryContext?: DeliveryContext;
origin?: SessionOrigin;
channel?: string;
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
lastThreadId?: string | number;
};
export function isCanonicalSessionDeliveryState(value: unknown): value is SessionDeliveryState {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const candidate = value as Record<string, unknown>;
if (candidate.kind === "none" || candidate.kind === "internal") {
return true;
}
return (
candidate.kind === "external" &&
Boolean(candidate.route && typeof candidate.route === "object") &&
Boolean(candidate.context && typeof candidate.context === "object") &&
Boolean(candidate.origin && typeof candidate.origin === "object")
);
}
/** Builds one canonical delivery state from current turn routing facts. */
export function normalizeSessionDeliveryState(params?: {
route?: ChannelRouteRef;
context?: DeliveryContext;
origin?: SessionOrigin;
}): SessionDeliveryState {
if (!params) {
return { kind: "none" };
}
const normalizedRoute = normalizeDeliveryChannelRoute(params.route);
const routeContext = deliveryContextFromChannelRoute(normalizedRoute);
const originContext = normalizeDeliveryContext({
channel: params.origin?.provider,
to: params.origin?.to,
accountId: params.origin?.accountId,
threadId: params.origin?.threadId,
});
const context = normalizeDeliveryContext(params.context);
const fallbackContext = mergeDeliveryContext(context, originContext);
const routeIsInternalFallback =
isInternalRouteContext(routeContext) && hasExternalDeliveryTarget(context);
const merged = routeIsInternalFallback
? mergeExternalDeliveryContextOverInternalRoute(
context,
mergeDeliveryContext(routeContext, originContext),
)
: mergeDeliveryContext(routeContext, fallbackContext);
if (!merged) {
return { kind: "none" };
}
if (isInternalRouteContext(merged)) {
return { kind: "internal" };
}
const route = mergeRouteMetadataWithDeliveryContext(
routeIsInternalFallback ? undefined : normalizedRoute,
merged,
);
if (!route) {
return { kind: "none" };
}
const origin: SessionOrigin = { ...params.origin };
origin.provider ??= merged.channel;
origin.to ??= merged.to;
origin.accountId ??= merged.accountId;
origin.threadId ??= merged.threadId;
origin.chatType ??= route.target?.chatType;
return { kind: "external", route, context: merged, origin };
}
/** Projects compatibility fields without persisting duplicate delivery state. */
export function projectSessionDeliveryFields(
delivery?: SessionDeliveryState,
): SessionDeliveryProjection {
if (delivery?.kind !== "external") {
return {};
}
return {
route: delivery.route,
deliveryContext: delivery.context,
origin: delivery.origin,
channel: delivery.context.channel ?? delivery.origin.provider,
lastChannel: delivery.context.channel,
lastTo: delivery.context.to,
lastAccountId: delivery.context.accountId,
lastThreadId: delivery.context.threadId,
};
}
/** Reads only the canonical persisted delivery record. */
export function deliveryContextFromSession(
entry?: Pick<SessionEntry, "delivery">,
): DeliveryContext | undefined {
return entry?.delivery?.kind === "external" ? entry.delivery.context : undefined;
}
export function sessionDeliveryRoute(
entry?: Pick<SessionEntry, "delivery">,
): ChannelRouteRef | undefined {
return entry?.delivery?.kind === "external" ? entry.delivery.route : undefined;
}
export function sessionDeliveryOrigin(
entry?: Pick<SessionEntry, "delivery">,
): SessionOrigin | undefined {
return entry?.delivery?.kind === "external" ? entry.delivery.origin : undefined;
}
export function sessionDeliveryChannel(entry?: Pick<SessionEntry, "delivery">): string | undefined {
const delivery = entry?.delivery;
return delivery?.kind === "external"
? (delivery.context.channel ?? delivery.origin.provider)
: undefined;
}
/** Merges delivery contexts without mixing target/account/thread fields across route owners. */
export function mergeDeliveryContext(
primary?: DeliveryContext,
fallback?: DeliveryContext,
): DeliveryContext | undefined {
const normalizedPrimary = normalizeDeliveryContext(primary);
const normalizedFallback = normalizeDeliveryContext(fallback);
if (!normalizedPrimary && !normalizedFallback) {
return undefined;
}
const channelsConflict =
normalizedPrimary?.channel &&
normalizedFallback?.channel &&
normalizedPrimary.channel !== normalizedFallback.channel;
const accountsConflict =
normalizedPrimary?.accountId &&
normalizedFallback?.accountId &&
normalizedPrimary.accountId !== normalizedFallback.accountId;
const routesConflict = channelsConflict || accountsConflict;
return normalizeDeliveryContext({
channel: accountsConflict
? normalizedPrimary?.channel
: (normalizedPrimary?.channel ?? normalizedFallback?.channel),
// Keep route fields paired to their channel account; crossing either owner
// can address one account's target through another account's credentials.
to: routesConflict ? normalizedPrimary?.to : (normalizedPrimary?.to ?? normalizedFallback?.to),
accountId: routesConflict
? normalizedPrimary?.accountId
: (normalizedPrimary?.accountId ?? normalizedFallback?.accountId),
threadId: routesConflict
? normalizedPrimary?.threadId
: (normalizedPrimary?.threadId ?? normalizedFallback?.threadId),
});
}
/** Builds a compact stable key for a routable delivery context. */
export function deliveryContextKey(context?: DeliveryContext): string | undefined {
return channelRouteCompactKey(normalizeDeliveryContext(context));
}