/** * Generic notification conversation pairing. * * Materializes a conversation + message for each notification delivery * before the adapter sends it. This ensures every delivery has an * auditable conversation trail and enables the macOS/iOS client to * deep-link directly into the notification conversation. * * Resolution order: * 1. Explicit `reuse_existing` conversation action — highest precedence. * 2. Binding-key reuse — for `continue_existing_conversation` channels: * a. Inbound conversation lookup — checks the un-prefixed binding * (sourceChannel, externalChatId) for a conversation created by * the inbound message handler. Preferred for reply continuity. * b. Notification-scoped binding — checks the `notification:`-prefixed * binding for a prior notification conversation. * 3. Default — creates a fresh conversation and, when binding context is * present, upserts it into the external-conversation store for future reuse. */ import type { ConversationStrategy } from "../channels/config.js"; import { getConversationStrategy } from "../channels/config.js"; import type { ChannelId } from "../channels/types.js"; import { addMessage, createConversation, getConversation, } from "../persistence/conversation-crud.js"; import { getBindingByChannelChat, upsertOutboundBinding, } from "../persistence/external-conversation-store.js"; import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js"; import { getLogger } from "../util/logger.js"; import { withSqliteRetry } from "../util/sqlite-retry.js"; import { composeConversationSeed, isConversationSeedSane, } from "./conversation-seed-composer.js"; import type { NotificationSignal } from "./signal.js"; import type { ConversationAction, DestinationBindingContext, NotificationChannel, } from "./types.js"; import type { RenderedChannelCopy } from "./types.js"; const log = getLogger("notification-conversation-pairing"); /** * Prefix applied to sourceChannel values in notification bindings so they * occupy a separate namespace from messaging adapter bindings in the * external_conversation_bindings table. Without this, notification pairing * and messaging adapters (Telegram, Slack, etc.) would destructively overwrite * each other's bindings since both use (sourceChannel, externalChatId) as key. */ const NOTIFICATION_CHANNEL_PREFIX = "notification:"; function notificationChannel(sourceChannel: string): string { return `${NOTIFICATION_CHANNEL_PREFIX}${sourceChannel}`; } export interface PairingResult { conversationId: string | null; messageId: string | null; strategy: ConversationStrategy; /** True when a brand-new conversation was created; false when an existing one was reused. */ createdNewConversation: boolean; /** When the model requested reuse_existing but the target was invalid, this is true. */ conversationFallbackUsed: boolean; } export interface PairingOptions { /** Per-channel conversation action from the decision engine. */ conversationAction?: ConversationAction; /** Destination binding data for channel-scoped conversation continuation. */ bindingContext?: DestinationBindingContext; } /** * Pair a notification delivery with a conversation and seed message. * * Looks up the channel's conversation strategy from the policy registry * and materializes a conversation + assistant message accordingly. * * Resolution precedence: * 1. `options.conversationAction === "reuse_existing"` — reuse the explicit target. * 2. `continue_existing_conversation` strategy with binding context: * a. Un-prefixed (inbound) binding — preferred for reply continuity so * the user's replies include the notification in their history. * b. `notification:`-prefixed binding — used when no inbound conversation * exists yet (e.g. first notification before the user has messaged). * 3. Create a new conversation (and upsert the binding when context is present). * * Invalid/stale targets at any level fall through to the next. * * Passive vellum notifications (those without `requiresConversation`) never * reach that order. They create nothing, and instead append their body to the * producing conversation named by `sourceContextId` when it resolves, since * that is the row the home feed's "Go to Conversation" button already targets. * The returned `conversationId` is that producing conversation, with * `createdNewConversation` false. * * Errors are caught and logged — this function never throws so the * notification pipeline is not disrupted by pairing failures. */ export async function pairDeliveryWithConversation( signal: NotificationSignal, channel: NotificationChannel, copy: RenderedChannelCopy, options?: PairingOptions, ): Promise { try { const strategy = getConversationStrategy(channel as ChannelId); if (strategy === "not_deliverable" || strategy === "push_only") { return { conversationId: null, messageId: null, strategy, createdNewConversation: false, conversationFallbackUsed: false, }; } const conversationAction = options?.conversationAction; const bindingContext = options?.bindingContext; // Structured content blocks take precedence. They enable Surface-based // rendering in the web/macOS/iOS apps (e.g. a card widget instead of // plain text). Falls back to model-provided seed or runtime composer. const messageContent = copy.seedContentBlocks ? JSON.stringify(copy.seedContentBlocks) : isConversationSeedSane(copy.conversationSeedMessage) ? copy.conversationSeedMessage : composeConversationSeed(signal, channel, copy); // Passive vellum notifications link back to the originating conversation // via `signal.sourceContextId` rather than materializing one of their own. // A fresh per-notification conversation just to host the seed message // leaves a graveyard entry in the sidebar, so nothing is created here when // the producer did not opt in via `requiresConversation`. The decision // engine's `reuse_existing` hint is ignored for the same reason: a failed // reuse (stale target / source mismatch) falls through to // `createConversation`, producing exactly the graveyard entry we want to // avoid. // // The body is still appended to the producing conversation when // `sourceContextId` resolves, because every route the user has into this // notification ends at that conversation. Tapping the banner deep-links // there, and resolves there with or without this append, since the // broadcaster falls back to `sourceContextId` for the vellum deep link. // The client suppresses the banner outright when that conversation is // already on screen, leaving the transcript as the only place the // notification can appear. The home feed aims its "Go to Conversation" // button at the same row whenever it mirrors the signal. // // So this is deliberately not gated on home-feed eligibility: a signal the // feed declines to mirror still reaches the user through the banner, and // this row is what makes that landing honest. if (strategy === "start_new_conversation" && !signal.requiresConversation) { const appended = await appendBodyToSourceConversation( signal, channel, messageContent, ); return { conversationId: appended?.conversationId ?? null, messageId: appended?.messageId ?? null, strategy, createdNewConversation: false, conversationFallbackUsed: false, }; } const title = copy.conversationTitle ?? copy.title ?? signal.sourceEventName; // Only start_new_conversation conversations should be user-visible in the sidebar. // Channels with continue_existing_conversation reuse bound external conversations // and mark them as background so they don't clutter the sidebar UI. const conversationType = signal.conversationMetadata?.conversationType ?? (strategy === "start_new_conversation" ? "standard" : "background"); // Attempt to reuse an existing conversation when the model requests it if (conversationAction?.action === "reuse_existing") { const targetId = conversationAction.conversationId; const existing = getConversation(targetId); const effectiveSource = signal.conversationMetadata?.source ?? "notification"; if (existing && existing.source === effectiveSource) { // Append the seed message to the existing conversation const message = await addMessage( existing.id, "assistant", messageContent, { skipIndexing: true }, ); // Rebind the destination so subsequent deliveries to the same // (sourceChannel, externalChatId) resolve to this conversation. if (bindingContext?.sourceChannel && bindingContext?.externalChatId) { upsertOutboundBinding({ conversationId: existing.id, sourceChannel: notificationChannel(bindingContext.sourceChannel), externalChatId: bindingContext.externalChatId, }); } log.info( { signalId: signal.signalId, channel, strategy, conversationId: existing.id, messageId: message.id, conversationAction: "reuse_existing", }, "Reused existing notification conversation for delivery", ); return { conversationId: existing.id, messageId: message.id, strategy, createdNewConversation: false, conversationFallbackUsed: false, }; } // Target is invalid/stale — fall back to creating a new conversation log.warn( { signalId: signal.signalId, channel, targetConversationId: targetId, targetExists: !!existing, targetSource: existing?.source, }, "Conversation reuse target invalid — falling back to new conversation", ); const conversation = await withSqliteRetry( () => createConversation({ title, conversationType, source: signal.conversationMetadata?.source ?? "notification", groupId: signal.conversationMetadata?.groupId, scheduleJobId: signal.conversationMetadata?.scheduleJobId, }), { op: "conversationPairing.reuseFallback" }, ); const message = await addMessage( conversation.id, "assistant", messageContent, { skipIndexing: true }, ); // Bind the new conversation to the destination so subsequent // deliveries reuse it instead of creating yet another conversation. if (bindingContext?.sourceChannel && bindingContext?.externalChatId) { upsertOutboundBinding({ conversationId: conversation.id, sourceChannel: notificationChannel(bindingContext.sourceChannel), externalChatId: bindingContext.externalChatId, }); } return { conversationId: conversation.id, messageId: message.id, strategy, createdNewConversation: true, conversationFallbackUsed: true, }; } // For channels with continue_existing_conversation strategy, try to // reuse a previously bound conversation keyed by (sourceChannel, externalChatId) // before falling through to create a new one. if ( strategy === "continue_existing_conversation" && bindingContext?.sourceChannel && bindingContext?.externalChatId ) { // ── Step 1: Prefer the inbound conversation for reply continuity ── // // When the user has previously messaged in this channel, the inbound // pipeline created a binding at the un-prefixed (sourceChannel, // externalChatId) key. Posting to that conversation means the // user's subsequent replies will include the notification in their // conversation history — avoiding "split brain" where proactive // messages live in one conversation and replies route to another. // // The source check is intentionally skipped here: the inbound // conversation will have a different source (typically null) from // notifications, but it is the correct target for reply continuity. const inboundBinding = getBindingByChannelChat( bindingContext.sourceChannel, bindingContext.externalChatId, ); if (inboundBinding) { const inboundConversation = getConversation( inboundBinding.conversationId, ); if (inboundConversation) { const message = await addMessage( inboundConversation.id, "assistant", messageContent, { skipIndexing: true }, ); log.info( { signalId: signal.signalId, channel, strategy, conversationId: inboundConversation.id, messageId: message.id, bindingKey: `${bindingContext.sourceChannel}:${bindingContext.externalChatId}`, }, "Appended notification to inbound conversation for reply continuity", ); return { conversationId: inboundConversation.id, messageId: message.id, strategy, createdNewConversation: false, conversationFallbackUsed: false, }; } } // ── Step 2: Fall back to notification-scoped binding ── // // Before the user has ever messaged in this channel, there is no // inbound binding. Check the notification-prefixed namespace for a // prior notification conversation so successive deliveries still // accumulate in the same thread. const notificationBinding = getBindingByChannelChat( notificationChannel(bindingContext.sourceChannel), bindingContext.externalChatId, ); if (notificationBinding) { const boundConversation = getConversation( notificationBinding.conversationId, ); const effectiveSource = signal.conversationMetadata?.source ?? "notification"; if (boundConversation && boundConversation.source === effectiveSource) { const message = await addMessage( boundConversation.id, "assistant", messageContent, { skipIndexing: true }, ); // Touch the outbound timestamp so the binding stays fresh. upsertOutboundBinding({ conversationId: boundConversation.id, sourceChannel: notificationChannel(bindingContext.sourceChannel), externalChatId: bindingContext.externalChatId, }); log.info( { signalId: signal.signalId, channel, strategy, conversationId: boundConversation.id, messageId: message.id, bindingKey: `${bindingContext.sourceChannel}:${bindingContext.externalChatId}`, }, "Reused bound notification conversation for channel destination", ); return { conversationId: boundConversation.id, messageId: message.id, strategy, createdNewConversation: false, conversationFallbackUsed: false, }; } // Binding exists but conversation is stale or wrong source — fall through // to create a new one and re-bind below. log.warn( { signalId: signal.signalId, channel, boundConversationId: notificationBinding.conversationId, boundConversationExists: !!boundConversation, boundConversationSource: boundConversation?.source, }, "Bound notification conversation stale or invalid — creating fresh conversation", ); } } // Default path: create a new conversation // Memory indexing is skipped on the seed message below to prevent // notification copy from polluting conversational recall. const conversation = await withSqliteRetry( () => createConversation({ title, conversationType, source: signal.conversationMetadata?.source ?? "notification", groupId: signal.conversationMetadata?.groupId, scheduleJobId: signal.conversationMetadata?.scheduleJobId, }), { op: "conversationPairing.default" }, ); // Skip memory indexing — notification audit messages are not conversational // memory and should not pollute recall or incur embedding/extraction overhead. const message = await addMessage( conversation.id, "assistant", messageContent, { skipIndexing: true }, ); // When binding context is available, record the new conversation so // subsequent deliveries to the same destination reuse it. if (bindingContext?.sourceChannel && bindingContext?.externalChatId) { upsertOutboundBinding({ conversationId: conversation.id, sourceChannel: notificationChannel(bindingContext.sourceChannel), externalChatId: bindingContext.externalChatId, }); } log.info( { signalId: signal.signalId, channel, strategy, conversationId: conversation.id, messageId: message.id, conversationAction: conversationAction?.action ?? "start_new", }, "Paired notification delivery with conversation", ); return { conversationId: conversation.id, messageId: message.id, strategy, createdNewConversation: true, conversationFallbackUsed: false, }; } catch (err) { log.error( { err, signalId: signal.signalId, channel }, "Failed to pair notification delivery with conversation — continuing without pairing", ); const fallbackStrategy = (() => { try { return getConversationStrategy(channel as ChannelId); } catch { return "not_deliverable" as const; } })(); return { conversationId: null, messageId: null, strategy: fallbackStrategy, createdNewConversation: false, conversationFallbackUsed: false, }; } } /** * Append a delivered notification body to the conversation that produced it. * * Passive notifications never materialize a conversation of their own, so the * home feed points "Go to Conversation" at the producing conversation * (`resolveHomeFeedMirror` prefers `sourceContextId`). Writing the body there * makes that button truthful: whenever it renders, the conversation it opens * contains the notification the user tapped. * * Reuse only. Producers may pass sentinels (job ids, call session ids, * `access-req-*` strings) as `sourceContextId`; those resolve to nothing and * append nothing, which matches the button staying hidden for them. * * This covers vellum deliveries only, since no other channel takes the * passive branch. A signal routed away from vellum still gets a card, so * `writeHomeFeedItemForSignal` writes the body itself when no vellum * conversation was paired. Between them the button holds whatever the * routing was, and only one of the two ever writes. * * Indexing is skipped for parity with the other notification write paths: * notification copy is delivery audit, not conversational memory. */ async function appendBodyToSourceConversation( signal: NotificationSignal, channel: NotificationChannel, messageContent: string, ): Promise<{ conversationId: string; messageId: string } | null> { const sourceContextId = signal.sourceContextId; if (!sourceContextId) { return null; } let existing: ReturnType; try { existing = getConversation(sourceContextId); } catch { return null; } if (!existing) { return null; } const message = await addMessage(existing.id, "assistant", messageContent, { skipIndexing: true, }); // `addMessage` projects attention metadata alone, so a client with this // conversation open needs the messages tag to refetch the transcript. A // notification the user taps through to has every chance of landing on an // already-open conversation. publishConversationMessagesChanged(existing.id); log.info( { signalId: signal.signalId, channel, conversationId: existing.id, messageId: message.id, }, "Appended notification body to producing conversation", ); return { conversationId: existing.id, messageId: message.id }; }