/** * `chat.assistant_reply` producer: an APNs push for a finished reply the user * is no longer looking at. * * Called at the end of a user-initiated turn (see * `daemon/conversation-turn-finalize.ts`) and best-effort throughout: every * failure is logged and swallowed, because a notification hiccup must never * escalate a reply the client already received into a turn-level throw. */ import type pino from "pino"; import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js"; import { getAttachmentMetadataForMessage } from "../persistence/attachments-store.js"; import { getAttentionStateByConversationIds, hasUnseenLatestAssistantMessage, } from "../persistence/conversation-attention-store.js"; import { type ConversationRow, getConversation, getMessageById, parseMessageMetadata, } from "../persistence/conversation-crud.js"; import { isMacOriginatedUserMessage, isReplyPushIneligibleUserMessage, resolveConversationKind, } from "../persistence/conversation-types.js"; import { stringifyMessageContent } from "../persistence/message-content.js"; import { isDesktopAttended } from "../runtime/desktop-presence.js"; import { isWebConversationFocused } from "../runtime/web-presence.js"; import { safeParseRecord } from "../util/json.js"; import { emitNotificationSignal } from "./emit-signal.js"; import { describeMedia, mediaEmbeds, sanitizeMessagePreview, sanitizeNotificationTitle, stripMarkdownForPreview, } from "./notification-utils.js"; /** Kill switch for this producer, on by default. */ const ASSISTANT_REPLY_PUSH_FLAG = "assistant-reply-push" as const; /** Gates the desktop-attended suppression below, on by default. */ const DESKTOP_PRESENCE_FLAG = "desktop-presence-suppression" as const; /** Gates the web-focused suppression below, on by default. */ const WEB_PRESENCE_FLAG = "web-presence-suppression" as const; /** * Collapse whitespace runs ahead of the sanitizers' truncation: blank lines and * list indentation would otherwise eat into the copy's length budget. */ function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } /** * Body naming every piece of media a reply produced, for a reply whose text * flattened away. * * Two sources, counted together so a reply mixing them is not undercounted. * Attachment rows cover generated files, `` directives, * and `vellum://` embeds alike; they are linked to the assistant row by * `resolveAssistantAttachments` before the turn's terminal SSE and exposed * separately from its content blocks. Remote embeds leave no row, so their alt * text is the only label they have. * * Tracked embeds are dropped from the second source rather than added twice: * a `vellum://` embed is already one of the attachment rows. * * Reads attachment metadata only (no base64), and the caller invokes this only * once the text preview has come up empty. */ function describeReplyMedia(text: string, assistantMessageId: string): string { const filenames = getAttachmentMetadataForMessage(assistantMessageId).map( (attachment) => attachment.originalFilename, ); const embeds = mediaEmbeds(text); const tracked = embeds.filter((embed) => embed.tracked); // Resolution is allowed to fail: `resolveAssistantAttachments` skips a file // that is missing, oversized, unreadable, or denied at the host-read // approval, leaving a tracked embed with no row to stand for it. Rows at // least matching the tracked embeds means each one resolved (any surplus // being generated files); a shortfall is that many embeds falling back to // their own alt rather than vanishing from the count. const unresolved = Math.max(0, tracked.length - filenames.length); return describeMedia([ ...filenames, ...tracked.slice(tracked.length - unresolved).map((embed) => embed.alt), ...embeds.filter((embed) => !embed.tracked).map((embed) => embed.alt), ]); } /** * Read the markers the eligibility gate below consults off a persisted * message's metadata column. * * `parseMessageMetadata` validates the whole column and yields nothing when * any single field fails, so one unrecognized value would otherwise present * here as "no metadata" and open every gate at once. The gate is a plain-record * predicate, so a permissive read of the same JSON keeps it answering over * whichever fields are intact. */ function readSuppressionMarkers( metadataJson: string | null, ): Record | undefined { const validated = parseMessageMetadata(metadataJson); if (validated) { return validated; } return metadataJson ? safeParseRecord(metadataJson) : undefined; } /** * Desktop attendance, kept fail-open here: a presence read that fails has to * send the push, not reach the producer's catch and silence it. * * No `actorPrincipalId`: the platform delivers this push to the assistant * owner's device tokens, and a pod has exactly one owner, so any attended * macOS client is that owner's. */ function readDesktopAttended(rlog: pino.Logger): boolean { try { return isDesktopAttended(); } catch (err) { rlog.warn({ err }, "Desktop presence read failed; treating as unattended"); return false; } } /** * Web presence, kept fail-open here for the same reason as * {@link readDesktopAttended}: a presence read that fails has to send the * push, not reach the producer's catch and silence it. * * No `actorPrincipalId`: the platform delivers this push to the assistant * owner's device tokens, and a pod has exactly one owner, so any focused web * tab is that owner's. */ function readWebConversationFocused( conversationId: string, rlog: pino.Logger, ): boolean { try { return isWebConversationFocused(conversationId); } catch (err) { rlog.warn({ err }, "Web presence read failed; treating as unfocused"); return false; } } export async function emitAssistantReplyNotification(params: { conversationId: string; assistantMessageId: string; /** * The row that opened the turn, threaded from the agent loop. Reading it by * id rather than scanning back from the assistant row keeps a hidden or * queued user message that landed mid-turn from being mistaken for the * prompt this reply answers. */ userMessageId: string | undefined; /** * True when this turn's reply streams to the app and nowhere else, so the * initiating row's channel and voice markers no longer describe where the * reply lands. See {@link isReplyPushIneligibleUserMessage}. */ replyDeliveredInAppOnly?: boolean; rlog: pino.Logger; /** Row the caller already holds; re-read when omitted. */ conversation?: ConversationRow | null; }): Promise { const { conversationId, assistantMessageId, userMessageId, rlog } = params; try { if (!isAssistantFeatureFlagEnabled(ASSISTANT_REPLY_PUSH_FLAG)) { return; } if (!userMessageId) { return; } const conversation = params.conversation ?? getConversation(conversationId); if (!conversation) { return; } // The other three kinds (memory consolidation, background, scheduled) each // already have their own notification producer. const kind = resolveConversationKind( conversation.source, conversation.conversationType, ); if (kind !== "user") { return; } const attention = getAttentionStateByConversationIds([conversationId]).get( conversationId, ); if (!hasUnseenLatestAssistantMessage(attention)) { return; } const assistantRow = getMessageById(assistantMessageId, conversationId); if (!assistantRow) { return; } const initiatingMessage = getMessageById(userMessageId, conversationId); if (!initiatingMessage) { return; } const initiatingMetadata = readSuppressionMarkers( initiatingMessage.metadata, ); if ( isReplyPushIneligibleUserMessage(initiatingMetadata, { replyDeliveredInAppOnly: params.replyDeliveredInAppOnly, }) ) { return; } // A reply whose output is entirely media has no text to preview, so name // the media rather than suppressing a real reply. Markdown is flattened // first: a lock screen renders none of it, and an embed-only reply has to // reduce to empty for the fallback to be reachable. A reply with neither // text nor media stays silent. const text = stringifyMessageContent(assistantRow.content); const preview = sanitizeMessagePreview( collapseWhitespace(stripMarkdownForPreview(text)), ) || sanitizeMessagePreview(describeReplyMedia(text, assistantMessageId)); if (!preview) { return; } // Conversation titles are user-controlled and unbounded (renames, imports), // so the title gets the same treatment as the body before it reaches the // lock screen. Absent `requestedTitle` lets the decision branch derive a // title from the body, which reads better than an empty or placeholder // conversation title. const requestedTitle = sanitizeNotificationTitle( collapseWhitespace(conversation.title ?? ""), ); // Read as close to the emit as possible: nothing short-circuits on it. // Presence only speaks for a turn the Mac itself opened, on that row's own // OS evidence. A turn sent from the phone still needs its push while the // Mac sits idle within the desktop client's attendance window. const desktopAttended = isAssistantFeatureFlagEnabled(DESKTOP_PRESENCE_FLAG) && isMacOriginatedUserMessage(initiatingMetadata) && readDesktopAttended(rlog); // Conversation-scoped web presence applies regardless of which device // initiated the turn: a visible matching tab proves where this reply is // currently being displayed, while the conversation id prevents an // unrelated tab from suppressing the push. const webFocused = isAssistantFeatureFlagEnabled(WEB_PRESENCE_FLAG) && readWebConversationFocused(conversationId, rlog); await emitNotificationSignal({ sourceEventName: "chat.assistant_reply", sourceChannel: "vellum", // Deep-link target; the broadcaster validates it and merges it into // `deepLinkTarget.conversationId`. sourceContextId: conversationId, attentionHints: { requiresAction: false, // Load-bearing for the iOS-only scope: `emit-signal.ts` force-adds the // vellum channel for high/critical signals, which would widen this into // an in-app banner on every client. Raising the urgency is the same as // opting into v2. urgency: "medium", isAsyncBackground: false, // Read weakly, as "at the surface this landed on": the attended Mac // or focused web tab that opened the turn renders the reply in-app, // so a redundant push would only duplicate what's already on screen. visibleInSourceNow: desktopAttended || webFocused, }, contextPayload: { ...(requestedTitle ? { requestedTitle } : {}), requestedMessage: preview, }, // The pipeline dedupe window is a flat hour keyed on this alone, so it // has to carry the message id or a second reply within the hour is // silently dropped. dedupeKey: `chat.assistant_reply:${conversationId}:${assistantMessageId}`, }); } catch (err) { rlog.warn( { err, conversationId, messageId: assistantMessageId }, "Failed to emit assistant reply notification (non-fatal)", ); } }