/** * Route handlers for conversation messages and suggestions. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { CLIENT_METADATA_HEADERS, type ClientMetadataField, sanitizeClientMetadataValue, } from "@vellumai/service-contracts/client-metadata"; import { v7 as uuidv7 } from "uuid"; import { z } from "zod"; import { enrichMessageWithSourcePaths } from "../../agent/attachments.js"; import { createAssistantMessage, createUserMessage, } from "../../agent/message-types.js"; import type { AssistantEvent } from "../../api/index.js"; import { BackgroundToolCompletionSchema, type ConversationContentBlock, type ConversationMessage, ConversationMessageSchema, } from "../../api/responses/conversation-message.js"; import { decideGuardianRequest, expireGuardianRequest, listGuardianRequestsOrEmpty, listPendingRequestsByScopeOrEmpty, } from "../../channels/gateway-guardian-requests.js"; import { CHANNEL_IDS, INTERFACE_IDS, isInteractiveInterface, parseChannelId, parseClientOs, parseInterfaceId, supportsHostProxy, } from "../../channels/types.js"; import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js"; import { getEffectiveProfilesForProvider } from "../../config/default-profile-catalog.js"; import { isHttpAuthDisabled } from "../../config/env.js"; import { getConfig } from "../../config/loader.js"; import { mergeConsecutiveAssistantMessages, mergeToolResultsIntoAssistantMessages, } from "../../conversations/message-consolidation.js"; import { createApprovalConversationGenerator } from "../../daemon/approval-generators.js"; import type { Conversation } from "../../daemon/conversation.js"; import { persistQueuedMessageBody } from "../../daemon/conversation-messaging.js"; import { buildModelInfoEvent, formatCleanResult, formatCompactResult, isModelSlashCommand, } from "../../daemon/conversation-process.js"; import { findConversation } from "../../daemon/conversation-registry.js"; import { buildSlashContextForContent, resolveSlash, } from "../../daemon/conversation-slash.js"; import { getOrCreateConversation as getOrCreateConversationInstance } from "../../daemon/conversation-store.js"; import { canonicalizeTimeZone } from "../../daemon/date-context.js"; import { buildScanFirstMessage, buildSelfIntroMessage, getCannedFirstGreeting, isWakeUpGreeting, } from "../../daemon/first-greeting.js"; import { supersedePendingInteractionsOnEnqueue } from "../../daemon/handlers/conversations.js"; import { collectAttachmentRefs, type HistoryAttachmentRef, renderHistoryContent, } from "../../daemon/handlers/shared.js"; import { HostAppControlProxy } from "../../daemon/host-app-control-proxy.js"; import { HostCuProxy } from "../../daemon/host-cu-proxy.js"; import { preactivateHostProxySkills, shouldAttachHostProxyForCapability, } from "../../daemon/host-proxy-preactivation.js"; import { getAssistantName } from "../../daemon/identity-helpers.js"; import type { HostProxyTransportMetadata, NonHostProxyTransportMetadata, } from "../../daemon/message-types/conversations.js"; import type { TrustContext } from "../../daemon/trust-context-types.js"; import { HeartbeatService } from "../../heartbeat/heartbeat-service.js"; import { writeOnboardingSidecar, writeRelationshipState, } from "../../home/relationship-state-writer.js"; import { ipcCall } from "../../ipc/gateway-client.js"; import { buildSlackMessageDeepLinks } from "../../messaging/providers/slack/deep-link.js"; import { readSlackMetadataFromMessageMetadata, type SlackMessageMetadata, } from "../../messaging/providers/slack/message-metadata.js"; import { recordOnboardingEvent } from "../../onboarding/onboarding-events-store.js"; import { classifyKind, getAttachmentById, getAttachmentMetadataForMessage, getAttachmentsByIds, getSourcePathsForAttachments, } from "../../persistence/attachments-store.js"; import { addMessage, getConversation, getConversationPersistedSeq, getMessages, getMessagesPaginated, hasMessages, isBackgroundEventMetadata, isConversationProcessing, isHiddenMessageMetadata, isProviderErrorMetadata, isSuppressedQueuedMessage, isSystemCardMetadata, type MessageRow, recordConversationPersistedSeq, setConversationInferenceProfile, } from "../../persistence/conversation-crud.js"; import { getConversationByKey, getOrCreateConversation, } from "../../persistence/conversation-key-store.js"; import { searchConversations } from "../../persistence/conversation-queries.js"; import { linkRequestLogsToMessage } from "../../persistence/llm-request-log-store.js"; import { MEMORY_RETROSPECTIVE_FORK_SOURCE } from "../../plugins/defaults/memory/memory-retrospective-constants.js"; import { normalizeOnboardingContext } from "../../prompts/normalize-onboarding.js"; import { writeOnboardingSection } from "../../prompts/persona-resolver.js"; import { getConfiguredProvider } from "../../providers/provider-send-message.js"; import type { Provider } from "../../providers/types.js"; import { checkIngressForSecrets } from "../../security/secret-ingress.js"; import { getSubagentManager } from "../../subagent/index.js"; import { isHeicFilename, normalizeImageBase64, } from "../../util/image-conversion.js"; import { getLogger } from "../../util/logger.js"; import { getWorkspaceDir, getWorkspacePromptPath, } from "../../util/platform.js"; import { assistantEventHub, broadcastMessage } from "../assistant-event-hub.js"; import { getCurrentSeq } from "../assistant-stream-state.js"; import { ACTOR_PRINCIPALS } from "../auth/route-policy.js"; import { type GuardianPendingScope, routeGuardianReply, } from "../guardian-reply-router.js"; import { reResolveTrustOnResetDrift } from "../guardian-vellum-migration.js"; import type { ApprovalConversationGenerator, RuntimeAttachmentMetadata, RuntimeMessagePayload, SendMessageDeps, } from "../http-types.js"; import { findLocalGuardianPrincipalId, resolveActorPrincipalIdForLocalGuardian, } from "../local-actor-identity.js"; import { resolveLocalPrincipalTrustContext } from "../local-principal-trust.js"; import * as pendingInteractions from "../pending-interactions.js"; import { publishConversationListAndMetadataChanged, publishConversationMessagesChanged, } from "../sync/resource-sync-events.js"; import { withSourceChannel } from "../trust-context-resolver.js"; import { emitCannedMessageComplete, persistCannedAssistantCard, } from "./canned-message-complete.js"; import { scheduleCannedReplyRelease } from "./canned-reply-release.js"; import { buildChannelMetadata } from "./channel-metadata.js"; import { BadRequestError, InternalError, NotFoundError, RouteError, } from "./errors.js"; import { collectPendingConfirmations, enrichToolCallsWithConfirmation, } from "./tool-call-confirmation-enrichment.js"; import { collectPendingQuestions, enrichToolCallsWithQuestion, } from "./tool-call-question-enrichment.js"; import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; import { RouteResponse } from "./types.js"; const log = getLogger("conversation-routes"); /** Matches the `` sentinel used by channel delivery suppression. */ const NO_RESPONSE_INLINE_RE = //g; const ATTACHMENT_ENTRY_RE = /^attachment:(\d+)$/; /** Rewrites a rendered `contentOrder` to reflect attachment alignment. */ type ContentOrderRewrite = (contentOrder: string[]) => string[]; interface AlignedAttachments { /** Hydrated rows, reordered to match the inline file-block order. */ attachments: RuntimeAttachmentMetadata[]; /** * Resolves a content-walk attachment ref index to its hydrated DB row, * mirroring the inline placement `rewriteContentOrder` encodes. Refs with no * inline placement (unmatched ids, count mismatch, no DB rows) are absent, so * `renderHistoryContent` emits no `attachment` block for them — the row still * ships via the flat `attachments` array. */ refIndexToAttachment: Map; rewriteContentOrder: ContentOrderRewrite; } /** * Metadata-only projection of an attachment for inline `contentBlocks` * placement. The flat `attachments` array is the payload carrier: it keeps * `data`/`thumbnailData`, and `/v1/assistants/:id/attachments/:id/content` * serves stored bytes on demand. Attachment blocks are positional references * the renderer resolves against that array by id, so inlining the base64 here * would ship every image twice in the same response. */ function toAttachmentBlockRef( a: RuntimeAttachmentMetadata, ): RuntimeAttachmentMetadata { const { data: _data, thumbnailData: _thumbnailData, ...meta } = a; return meta; } /** * Align DB-hydrated attachment rows with the file-block refs `renderHistoryContent` * captured. When a file block carries an attachment id (user-message uploads — * on `source.attachmentId` for reference blocks, or the legacy top-level * `_attachmentId`) we join on that id to position the chip inline; DB rows * without a matching ref go to the tail as orphan chips, and unmatched refs drop * their `attachment:N` entry. Assistant-authored file blocks carry no id, so * when no ids match we fall back to positional alignment if the ref and row * counts agree; otherwise we strip the markers and let chips fall to the tail. */ function alignAttachments( attachmentRefs: HistoryAttachmentRef[], attachments: RuntimeAttachmentMetadata[], ): AlignedAttachments { const refIndexToAttachment = new Map(); const identity: ContentOrderRewrite = (contentOrder) => contentOrder; const stripAttachmentEntries: ContentOrderRewrite = (contentOrder) => contentOrder.filter((entry) => !ATTACHMENT_ENTRY_RE.test(entry)); if (attachmentRefs.length === 0) { return { attachments, refIndexToAttachment, rewriteContentOrder: identity }; } if (attachments.length === 0) { // Refs were captured but no DB rows came back — drop the contentOrder // entries to avoid out-of-bounds renders. return { attachments, refIndexToAttachment, rewriteContentOrder: stripAttachmentEntries, }; } const byId = new Map(); attachments.forEach((att, idx) => { if (att.id) { byId.set(att.id, idx); } }); const consumed = new Set(); const orderedRowIdx: Array = attachmentRefs.map((ref) => { if (!ref.attachmentId) { return null; } const idx = byId.get(ref.attachmentId); if (idx === undefined || consumed.has(idx)) { return null; } consumed.add(idx); return idx; }); const matchedRows = orderedRowIdx.filter( (idx): idx is number => idx !== null, ); if (matchedRows.length > 0) { const orphanRows: number[] = []; for (let i = 0; i < attachments.length; i++) { if (!consumed.has(i)) { orphanRows.push(i); } } const reordered = [ ...matchedRows.map((i) => attachments[i]), ...orphanRows.map((i) => attachments[i]), ]; const refToNewIdx = new Map(); let nextIdx = 0; orderedRowIdx.forEach((rowIdx, refIdx) => { if (rowIdx !== null) { refToNewIdx.set(refIdx, nextIdx); refIndexToAttachment.set(refIdx, reordered[nextIdx]); nextIdx++; } }); const rewriteContentOrder: ContentOrderRewrite = (contentOrder) => contentOrder .map((entry) => { const match = entry.match(ATTACHMENT_ENTRY_RE); if (!match) { return entry; } const remapped = refToNewIdx.get(Number(match[1])); return remapped !== undefined ? `attachment:${remapped}` : undefined; }) .filter((e): e is string => e !== undefined); return { attachments: reordered, refIndexToAttachment, rewriteContentOrder, }; } if (attachmentRefs.length !== attachments.length) { // No ref carried an attachmentId we could match and the counts disagree, so // positional mapping can't be trusted — strip any attachment:N entries so // the client doesn't position attachments inline against a misaligned array // (they fall to the tail instead). return { attachments, refIndexToAttachment, rewriteContentOrder: stripAttachmentEntries, }; } // No ref matched an id but the counts agree (the assistant-authored case): // the Nth marker maps to the Nth row positionally, so the original // contentOrder is left untouched. attachmentRefs.forEach((_ref, refIdx) => { refIndexToAttachment.set(refIdx, attachments[refIdx]); }); return { attachments, refIndexToAttachment, rewriteContentOrder: identity }; } /** Feature flag gating the self-intro first message (see first-greeting.ts). */ const SELF_INTRO_GREETING_FLAG = "self-intro-greeting" as const; const SUGGESTION_CACHE_MAX = 100; const VALID_RISK_THRESHOLDS = ["none", "low", "medium", "high"] as const; type RiskThreshold = (typeof VALID_RISK_THRESHOLDS)[number]; function isValidRiskThreshold(value: unknown): value is RiskThreshold { return ( typeof value === "string" && VALID_RISK_THRESHOLDS.includes(value as RiskThreshold) ); } /** * Upper bound on the reported visible-app id. Sized so it can never clip an id * the viewer can actually open: a plugin app id is `plugins~~`, * and each of those two segments is a filesystem directory name bounded at 255 * bytes, so the longest openable id runs to ~519 characters. The cap exists * only to bound what an arbitrary client can park on the conversation, not to * validate the id — `resolveAppSource` decides what resolves. */ const VISIBLE_APP_ID_MAX_LENGTH = 640; /** * True when the client-reported visible-app id is safe to carry as view state: * non-empty, trimmed, bounded, and free of path separators or traversal. * Mirrors the app store's own id validation so a malformed id is dropped at * ingress instead of reaching a filesystem lookup. */ function isSafeVisibleAppId(value: string): boolean { return ( value.length > 0 && value.length <= VISIBLE_APP_ID_MAX_LENGTH && value === value.trim() && !value.includes("/") && !value.includes("\\") && !value.includes("..") ); } /** * True when a message's persisted metadata explicitly flags it as hidden. * Used to suppress internal scaffolding messages from UI history while * leaving them in the LLM-side context. */ function isHiddenMessage(metadata: string | null): boolean { if (!metadata) { return false; } try { return isHiddenMessageMetadata( JSON.parse(metadata) as Record, ); } catch { return false; } } function buildSlackHistoryMessage( slackMeta: SlackMessageMetadata | null, opts?: { role?: string; assistantDisplayName?: string }, ): RuntimeMessagePayload["slackMessage"] | undefined { if (!slackMeta) { return undefined; } const slackConfig = getConfig().slack; const replyThreadTs = slackMeta.threadTs && slackMeta.threadTs !== slackMeta.channelTs ? slackMeta.threadTs : undefined; const messageLink = buildSlackMessageDeepLinks({ teamId: slackConfig?.teamId, teamUrl: slackConfig?.teamUrl, channelId: slackMeta.channelId, messageTs: slackMeta.channelTs, ...(replyThreadTs ? { threadTs: replyThreadTs } : {}), }); const threadLink = replyThreadTs ? buildSlackMessageDeepLinks({ teamId: slackConfig?.teamId, teamUrl: slackConfig?.teamUrl, channelId: slackMeta.channelId, messageTs: replyThreadTs, }) : undefined; const assistantDisplayName = opts?.role === "assistant" ? opts.assistantDisplayName : undefined; const senderDisplayName = slackMeta.displayName?.trim() || assistantDisplayName; return { channelId: slackMeta.channelId, ...(slackMeta.channelName ? { channelName: slackMeta.channelName } : {}), channelTs: slackMeta.channelTs, ...(slackMeta.threadTs ? { threadTs: slackMeta.threadTs } : {}), ...(senderDisplayName || slackMeta.actorExternalUserId ? { sender: { ...(senderDisplayName ? { displayName: senderDisplayName } : {}), ...(slackMeta.actorExternalUserId ? { externalUserId: slackMeta.actorExternalUserId } : {}), }, } : {}), ...(messageLink ? { messageLink } : {}), ...(threadLink ? { threadLink } : {}), ...(slackMeta.eventKind ? { eventKind: slackMeta.eventKind } : {}), ...(slackMeta.reaction ? { reaction: slackMeta.reaction } : {}), }; } async function collectGuardianRequestHintIds( conversationId: string, sourceChannel: string, conversation: Conversation, ): Promise { // Degrades to no hints on gateway failure: the reply scope then blocks // (see the caller) and the message falls through to the normal send path. const requests = await listPendingRequestsByScopeOrEmpty( conversationId, sourceChannel, ); return requests .filter( (req) => req.kind !== "tool_approval" || conversation.hasPendingConfirmation(req.id), ) .map((req) => req.id); } /** * Expire orphaned guardian requests for a conversation. * * After the in-memory auto-deny loop runs, there may still be "pending" * guardian requests in the gateway that have no corresponding in-memory * pending interaction (e.g. the prompter timed out and resolved the * confirmation directly without syncing the request status). This sweep * catches those stragglers so they don't get falsely matched by the * guardian reply router on subsequent messages. * * Only expires requests *sourced from* (not merely delivered to) this * conversation. Delivered requests may still have live pending interactions * in their source conversation. Additionally skips requests that still * have a live in-memory pending interaction. * * Uses the plain list (not `listPendingRequestsByScope`) so time-expired * requests (past their `expiresAt`) are also caught instead of being * silently filtered out. Sweep posture on gateway failure: log and skip — * the next send or the periodic sweep retries. */ async function expireOrphanedGuardianRequests( conversationId: string, ): Promise { const [toolApprovals, pendingQuestions] = await Promise.all([ listGuardianRequestsOrEmpty({ sourceConversationId: conversationId, status: "pending", kind: "tool_approval", }), listGuardianRequestsOrEmpty({ sourceConversationId: conversationId, status: "pending", kind: "pending_question", }), ]); // Voice-call questions (callSessionId present) track their lifecycle in the // calls domain, not `pendingInteractions` — never treat them as orphaned // here. Only ask_question rows are interaction-bound. const sourceScoped = [ ...toolApprovals, ...pendingQuestions.filter((req) => !req.callSessionId), ]; for (const req of sourceScoped) { // Skip requests that still have a live in-memory pending interaction — // they are not orphaned. if (pendingInteractions.get(req.id)) { continue; } try { await expireGuardianRequest(req.id); } catch (err) { log.warn( { err, requestId: req.id, conversationId }, "Orphaned guardian request expiry skipped — gateway unreachable", ); } } } async function tryConsumeGuardianReply(params: { conversationId: string; sourceChannel: string; sourceInterface: string; content: string; attachments: Array<{ id: string; filename: string; mimeType: string; data: string; filePath?: string; }>; conversation: Conversation; onEvent: (msg: AssistantEvent) => void; approvalConversationGenerator?: ApprovalConversationGenerator; /** Verified actor identity from actor-token middleware. */ verifiedActorExternalUserId?: string; /** Verified actor principal ID for principal-based authorization. */ verifiedActorPrincipalId?: string; /** Originating client identifier for sync_changed self-echo suppression. */ originClientId?: string; }): Promise<{ consumed: boolean; messageId?: string }> { const { conversationId, sourceChannel, sourceInterface, content, attachments, conversation, onEvent, approvalConversationGenerator, verifiedActorExternalUserId, verifiedActorPrincipalId, originClientId, } = params; const trimmedContent = content.trim(); if (trimmedContent.length === 0) { return { consumed: false }; } const pendingRequestHintIds = await collectGuardianRequestHintIds( conversationId, sourceChannel, conversation, ); // An empty hint set is `blocked`, not absence: the in-memory staleness // filter in collectGuardianRequestHintIds found no live requests, so the // router must not fall back to identity/DB lookup (which rediscovered // stale guardian requests). A non-empty set scopes resolution to it. const pendingScope: GuardianPendingScope = pendingRequestHintIds.length > 0 ? { mode: "scoped", requestIds: pendingRequestHintIds } : { mode: "blocked" }; const routerResult = await routeGuardianReply({ messageText: trimmedContent, channel: sourceChannel, actor: { actorPrincipalId: verifiedActorPrincipalId, actorExternalUserId: verifiedActorExternalUserId, channel: sourceChannel, guardianPrincipalId: verifiedActorPrincipalId, }, conversationId, pendingScope, approvalConversationGenerator, emissionContext: { source: "inline_nl", decisionText: trimmedContent, }, }); if (!routerResult.consumed || routerResult.type === "nl_keep_pending") { return { consumed: false }; } // Success-path emissions (approved/denied) are handled centrally // by handleConfirmationResponse (called via the resolver chain). // However, stale/failed paths never reach handleConfirmationResponse, // so we emit resolved_stale here for those cases. if (routerResult.requestId && !routerResult.decisionApplied) { conversation.emitConfirmationStateChanged({ conversationId: conversationId, requestId: routerResult.requestId, state: "resolved_stale", source: "inline_nl", decisionText: trimmedContent, }); } // Decision has been applied — transcript persistence is best-effort. // If DB writes fail, we still return consumed: true so the approval text // is not re-processed as a new user turn. let messageId: string | undefined; try { const channelMeta = buildChannelMetadata(sourceChannel, sourceInterface, { provenanceOverride: { provenanceTrustClass: "guardian" }, attachments, }); const cleanUserMessage = await createUserMessage(content, attachments); const llmUserMessage = enrichMessageWithSourcePaths( cleanUserMessage, attachments, ); const persistedUser = await addMessage( conversationId, "user", JSON.stringify(cleanUserMessage.content), { metadata: channelMeta }, ); messageId = persistedUser.id; const replyText = routerResult.replyText?.trim() || (routerResult.decisionApplied ? "Decision applied." : "Request already resolved."); const assistantMessage = createAssistantMessage(replyText); const persistedAssistant = await addMessage( conversationId, "assistant", JSON.stringify(assistantMessage.content), { metadata: channelMeta }, ); // Avoid mutating in-memory history / emitting stream deltas while a run is active. if (!conversation.isProcessing()) { conversation.getMessages().push(llmUserMessage, assistantMessage); onEvent({ type: "assistant_text_delta", text: replyText, conversationId: conversationId, }); emitCannedMessageComplete(onEvent, conversationId, persistedAssistant.id); // Both rows persisted above and no run is active (no unflushed stream // content), so advance the snapshot↔stream anchor past the events just // emitted. Otherwise `/messages` returns these rows while advertising // the previous anchor, under-claiming what the snapshot reflects. recordConversationPersistedSeq(conversationId, getCurrentSeq()); } publishConversationMessagesChanged(conversationId, originClientId); } catch (err) { log.warn( { err, conversationId }, "Failed to persist inline approval transcript entries", ); } return { consumed: true, messageId }; } /** * Read the notification discriminators a client uses to keep daemon-injected * rows out of the rendered transcript off a message's parsed metadata. * * One extraction shared by the persisted-history rows and the queued rows * synthesized from the in-memory queue, so a row carries the same flags * whichever side of the drain it is fetched from. */ function extractNotificationDiscriminators( meta: Record, ): Pick< ConversationMessage, "subagentNotification" | "acpNotification" | "backgroundEventNotification" > { const discriminators: Pick< ConversationMessage, "subagentNotification" | "acpNotification" | "backgroundEventNotification" > = {}; // Every wake persists a `` trigger row // (see `persistWakeTriggerMessage`) that the LLM reads. Flag any such // row so clients hide it from the transcript like a subagent/ACP // notification: the user-facing "Conversation Woke" card (or, for a // backgrounded bash run, the inline terminal card) carries the status. if (isBackgroundEventMetadata(meta)) { discriminators.backgroundEventNotification = true; } const subagent = meta.subagentNotification as | Record | undefined; if ( subagent && typeof subagent.subagentId === "string" && typeof subagent.label === "string" ) { discriminators.subagentNotification = { subagentId: subagent.subagentId, label: subagent.label, status: typeof subagent.status === "string" ? subagent.status : "completed", ...(typeof subagent.error === "string" ? { error: subagent.error } : {}), ...(typeof subagent.conversationId === "string" ? { conversationId: subagent.conversationId } : {}), ...(typeof subagent.objective === "string" ? { objective: subagent.objective } : {}), }; } const acp = meta.acpNotification as Record | undefined; if (acp && typeof acp.acpSessionId === "string") { discriminators.acpNotification = { acpSessionId: acp.acpSessionId, ...(typeof acp.agent === "string" ? { agent: acp.agent } : {}), }; } return discriminators; } /** * Render the live conversation's in-memory message queue into history rows. * * Messages enqueued while the agent is mid-turn live only in memory until the * queue drains and persists them, so they never reach the DB-sourced history * list. The live path surfaces them via `message_queued` SSE events; a cold * reload (no event replay) would otherwise drop them. Each queued row carries * `queueStatus: "queued"` with its 1-based `queuePosition` (mirroring the * client `DisplayMessage` queue fields) and is ordered FIFO so it appends to * the newest page in send order, mirroring how the agent will drain them. * * Returns an empty array when the conversation is not live in memory (cold, or * aged out of the registry) — there is no queue to read in that case. */ function buildQueuedMessagePayloads( conversationId: string, ): RuntimeMessagePayload[] { const conversation = findConversation(conversationId); if (!conversation) { return []; } // Rows that never render as a user bubble are suppressed at every stage — // echo, persisted row, and here the in-memory queue window: a latest-page // fetch while the item still awaits drain must not surface it as a queued // bubble. That covers hidden sends and the daemon's own injected // notifications (a subagent's completion summary enqueued into a busy // parent, an ACP run outcome, a wake trigger) — the user follows those // through their inline cards, never as a queued message they could steer or // cancel. return conversation .snapshotQueuedMessages() .filter((item) => !isSuppressedQueuedMessage(item.metadata)) .map((item, index) => { const text = item.displayContent ?? item.content; const attachments: RuntimeAttachmentMetadata[] = item.attachments.map( (a, idx) => ({ id: a.id ?? `${item.requestId}:attachment:${idx}`, filename: a.filename, mimeType: a.mimeType, sizeBytes: a.sizeBytes ?? (a.data ? Math.floor((a.data.length * 3) / 4) : 0), kind: classifyKind(a.mimeType), ...(a.mimeType.startsWith("image/") && a.data ? { data: a.data } : {}), ...(a.thumbnailData ? { thumbnailData: a.thumbnailData } : {}), }), ); const contentBlocks: ConversationContentBlock[] = []; if (text.length > 0) { contentBlocks.push({ type: "text", text }); } for (const attachment of attachments) { contentBlocks.push({ type: "attachment", attachment }); } return { // The queued message has no DB row yet; its requestId is the stable // identifier the queued-message delete/steer endpoints key on. id: item.requestId, role: "user" as const, timestamp: new Date(item.sentAt).toISOString(), attachments, ...(contentBlocks.length > 0 ? { contentBlocks } : {}), ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), // The filter above already drops every daemon-injected notification, // so these flags are redundant today. Carrying them keeps a queued row // self-describing on the wire, matching its persisted counterpart. ...extractNotificationDiscriminators(item.metadata ?? {}), queueStatus: "queued" as const, queuePosition: index + 1, }; }); } export async function handleListMessages({ queryParams, }: RouteHandlerArgs): Promise> { const conversationId = queryParams?.conversationId; const conversationKey = queryParams?.conversationKey; let resolvedConversationId: string | undefined; if (conversationId) { resolvedConversationId = conversationId; } else if (conversationKey) { // Dual lookup, key-first: prefer the `conversation_keys` table — the // canonical channel/external → internal-id mapping — so legacy or // externally-sourced keys keep their explicit mapping precedence and // never collide with an unrelated `conversations.id`. Fall back to a // direct id lookup only when no mapping exists, which covers // background/scheduled conversations bootstrapped without a // `conversation_keys` row (web clients use the conversation list's // `id` as `conversationKey` for those). const mapping = getConversationByKey(conversationKey); if (mapping) { resolvedConversationId = mapping.conversationId; } else if (getConversation(conversationKey)) { resolvedConversationId = conversationKey; } } else { throw new BadRequestError( "conversationKey or conversationId query parameter is required", ); } const beforeTimestampRaw = queryParams?.beforeTimestamp; const limitRaw = queryParams?.limit; const pageRaw = queryParams?.page; // Validate: reject NaN values with 400 if (beforeTimestampRaw != null && isNaN(Number(beforeTimestampRaw))) { throw new BadRequestError("beforeTimestamp must be a valid number"); } if (limitRaw != null && isNaN(Number(limitRaw))) { throw new BadRequestError("limit must be a valid number"); } if (pageRaw != null && pageRaw !== "latest") { throw new BadRequestError("page must be 'latest' when provided"); } const isLatestPage = pageRaw === "latest"; if (!resolvedConversationId) { // Unresolved conversation keys still need to advertise the stable // `page=latest` contract so the web client can rely on metadata fields // being present even before any message is persisted. if (isLatestPage && beforeTimestampRaw == null) { return { messages: [], hasMore: false, oldestTimestamp: null, oldestMessageId: null, seq: null, processing: false, }; } return { messages: [] }; } const beforeTimestamp = beforeTimestampRaw ? Number(beforeTimestampRaw) : undefined; // Clamp limit to 1-500 range const limit = limitRaw ? Math.min(Math.max(Math.floor(Number(limitRaw)), 1), 500) : undefined; // Paginate when either `beforeTimestamp` (older-page request) or // `page=latest` (initial newest-N request) is set. When both are sent, // `beforeTimestamp` wins because the caller is explicitly asking for an // older page; `getMessagesPaginated` ignores `beforeTimestamp === undefined` // and returns the newest `limit` messages in chronological order. const isPaginated = beforeTimestamp != null || isLatestPage; let rawMessages: MessageRow[]; let hasMore = false; // Resume cursor surfaced when the paginated scan stops on its row cap with a // (possibly empty) page — lets us still emit an oldest cursor so the client // can request the next window instead of stalling. let scanResumeCursor: { createdAt: number; id: string } | undefined; // Drop messages flagged as hidden in metadata (e.g. internal scaffolding // like retrospective instructions). The LLM-side history loader // (`getMessages` in memory/conversation-crud.ts) intentionally does not // filter — hidden messages remain in agent context but are suppressed from // the UI list. Filtering is pushed into the paginated query so `hasMore` // and the cursor reflect visible rows; otherwise a fully-hidden page would // return `hasMore: true` with no cursor and stall the web client. // Hidden tool_use/tool_result pairs must be hidden together — if a hidden // assistant message has tool_use blocks but its matching user tool_result // is left visible, the result will render as a standalone orphan because // `mergeToolResultsIntoAssistantMessages` has nothing to merge it into. // // Exception: memory-retrospective fork conversations show their hidden rows // (the retrospective instruction) so the run is readable as a distinct turn // and its LLM call is inspectable. The instruction row also separates the // copied source tail from the review turn, so `mergeConsecutiveAssistantMessages` // no longer folds the review into the source's last assistant message. This // is display-only and scoped to the fork source; the LLM-side `getMessages` // loader is unfiltered regardless. // // Only renderable roles reach this UI-facing transcript. `system` rows (a // permitted `MessageRole`, e.g. skill-authored context) are agent-context // scaffolding, never a displayed turn, so they are dropped here at the // source rather than narrowed away per-client. const isRetrospectiveFork = getConversation(resolvedConversationId)?.source === MEMORY_RETROSPECTIVE_FORK_SOURCE; const visibleFilter = (m: MessageRow) => (isRetrospectiveFork || !isHiddenMessage(m.metadata)) && (m.role === "user" || m.role === "assistant"); if (isPaginated) { const result = getMessagesPaginated( resolvedConversationId, limit, beforeTimestamp, visibleFilter, ); rawMessages = result.messages; hasMore = result.hasMore; scanResumeCursor = result.nextCursor; } else { rawMessages = getMessages(resolvedConversationId).filter(visibleFilter); } // During streaming, tool_use (assistant) and tool_result (user) events are // assembled client-side into a single assistant ChatMessage. On reload, they // are separate DB rows. Merge tool_result blocks from user messages into the // preceding assistant message so renderHistoryContent can pair them via its // pendingToolUses map — otherwise they render as "Unknown" tool calls. const mergedMessages = mergeToolResultsIntoAssistantMessages(rawMessages); // During streaming, all assistant turns within one agent loop accumulate // on a single client-side ChatMessage (via currentAssistantMessageId). // In the DB, each API turn is a separate assistant row because // consolidation is deferred to compaction for prefix-cache stability. // Merge consecutive assistant messages here at query time so // renderHistoryContent produces the same contentOrder shape as streaming // (consecutive tool refs grouped together). const { messages: consolidatedMessages, mergedIdMap } = mergeConsecutiveAssistantMessages(mergedMessages); const assistantSlackDisplayName = getAssistantName()?.trim() || undefined; // Parse each row's stored content and per-message metadata. Rendering is // deferred to the serializer pass below so it runs after attachment // alignment, letting renderHistoryContent inline `attachment` blocks during // its single content walk. const parsed = consolidatedMessages.map((msg) => { const content: unknown = msg.content; // Extract sentAt from metadata for display timestamps. When a message // was queued or its persistence was delayed (long assistant generation), // sentAt captures the actual event time. Falls back to createdAt. let sentAt: number | undefined; let notifications: ReturnType = {}; let backgroundToolCompletion: ConversationMessage["backgroundToolCompletion"]; let systemCard: boolean | undefined; let providerError: ConversationMessage["providerError"]; if (msg.metadata) { try { const meta = JSON.parse(msg.metadata) as Record; if (typeof meta.sentAt === "number") { sentAt = meta.sentAt; } // Daemon-authored status cards (compact/clean/summarize results) // render as standalone system notices, not persona speech. if (isSystemCardMetadata(meta)) { systemCard = true; } // Daemon-persisted provider-failure notices carry the classified // error code/category so clients can render a themed card instead // of a persona bubble. if (isProviderErrorMetadata(meta)) { providerError = { ...(typeof meta.providerErrorCode === "string" ? { code: meta.providerErrorCode } : {}), ...(typeof meta.providerErrorCategory === "string" ? { category: meta.providerErrorCategory } : {}), }; } notifications = extractNotificationDiscriminators(meta); // `persistWakeTriggerMessage` stamps the structured completion onto the // same wake row, letting the web rebuild a terminal inline card from // history after a restart (the in-memory completed ring does not survive). const completionParse = BackgroundToolCompletionSchema.safeParse( meta.backgroundToolCompletion, ); if (completionParse.success) { backgroundToolCompletion = completionParse.data; } } catch { // Ignore malformed metadata } } const slackMessage = buildSlackHistoryMessage( readSlackMetadataFromMessageMetadata(msg.metadata), { role: msg.role, assistantDisplayName: assistantSlackDisplayName, }, ); // `visibleFilter` has already dropped every non-renderable role, so the // only values reaching here are `user` and `assistant`; narrow the raw DB // `string` to the wire union. const role: "user" | "assistant" = msg.role === "assistant" ? "assistant" : "user"; return { id: msg.id, role, content, createdAt: msg.createdAt, sentAt, subagentNotification: notifications.subagentNotification, acpNotification: notifications.acpNotification, backgroundEventNotification: notifications.backgroundEventNotification, backgroundToolCompletion, systemCard, providerError, slackMessage, clientMessageId: msg.clientMessageId ?? undefined, }; }); // Confirmation context layered onto rendered tool calls at render time: the // derived scope ladder for scope-aware tools, and any in-flight prompt read // from the pending-interactions registry. Both are computed once per request // and applied per message below. const workspaceDir = getWorkspaceDir(); const pendingConfirmations = collectPendingConfirmations( resolvedConversationId, ); const pendingQuestions = collectPendingQuestions(resolvedConversationId); const messages: RuntimeMessagePayload[] = await Promise.all( parsed.map(async (m) => { const mergedMessageIds = m.id ? (mergedIdMap.get(m.id) ?? []) : []; // Hydrate the row's attachments from the DB. A metadata-only query avoids // loading large base64 blobs for non-image attachments (documents, audio); // full data is fetched only for images so the client can generate // thumbnails for inline display on history restore. Merged messages // (consecutive assistant merge) are queried too so their attachments // aren't lost before DB compaction relinks them. let msgAttachments: RuntimeAttachmentMetadata[] = []; if (m.id) { const idsToQuery = [m.id, ...mergedMessageIds]; const linked = idsToQuery.flatMap((id) => getAttachmentMetadataForMessage(id), ); if (linked.length > 0) { msgAttachments = await Promise.all( linked.map(async (a) => { // Hydrate image rows for inline thumbnails. Legacy HEIC can be // stored under application/octet-stream (empty File.type fallback), // so `.heic`/`.heif` rows are hydrated by filename too; // normalizeImageBase64 sniffs the bytes and rewrites only genuine // HEIF, which Chromium-based clients cannot decode. Filename and // sizeBytes keep describing the stored original, which // /attachments/:id/content serves verbatim for downloads. const isImage = a.mimeType.startsWith("image/"); const isLegacyHeic = !isImage && isHeicFilename(a.originalFilename); const full = isImage || isLegacyHeic ? getAttachmentById(a.id, { hydrateFileData: true }) : null; const display = full?.dataBase64 ? await normalizeImageBase64(a.mimeType, full.dataBase64) : null; // Image rows carry data even when unconverted (thumbnails); a // non-image row only becomes renderable once conversion yields a // JPEG, so it stays metadata-only when conversion is unavailable. const useDisplay = display && (isImage || display.converted) ? display : null; return { id: a.id, filename: a.originalFilename, mimeType: useDisplay?.mimeType ?? a.mimeType, sizeBytes: a.sizeBytes, kind: useDisplay?.converted ? classifyKind(useDisplay.mimeType) : a.kind, ...(useDisplay ? { data: useDisplay.dataBase64 } : {}), ...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}), fileBacked: true, }; }), ); } } // Align the hydrated rows with the file-block refs, then render. Rendering // after alignment lets renderHistoryContent inline each `attachment` block // during its single content walk, so `contentBlocks` comes back ready to // ship with no post-processing. The aligned reorder/rewrite keeps the // legacy `attachments` array and `contentOrder` positions consistent. const attachmentRefs = collectAttachmentRefs(m.content); const aligned = alignAttachments(attachmentRefs, msgAttachments); msgAttachments = aligned.attachments; const attachmentBlocks = attachmentRefs.map((_ref, refIdx) => { const att = aligned.refIndexToAttachment.get(refIdx); return att ? toAttachmentBlockRef(att) : null; }); const rendered = renderHistoryContent( m.content, attachmentBlocks, m.id ?? undefined, ); const toolCalls = enrichToolCallsWithQuestion( enrichToolCallsWithConfirmation(rendered.toolCalls, { workspaceDir, pendingConfirmations, }), { pendingQuestions }, ); // Strip markers from assistant messages so web/API clients // never see the raw sentinel. Only assistant messages produce it; user // messages are untouched. The filter is applied consistently to the // segments, the contentOrder text refs, and the text blocks of // contentBlocks. let textSegments = rendered.textSegments; let contentOrder = rendered.contentOrder; let contentBlocks = rendered.contentBlocks; if (m.role === "assistant") { const keepIndices: number[] = []; const filteredSegments: string[] = []; for (let i = 0; i < rendered.textSegments.length; i++) { const cleaned = rendered.textSegments[i] .replace(NO_RESPONSE_INLINE_RE, "") .trim(); if (cleaned.length > 0) { keepIndices.push(i); filteredSegments.push(cleaned); } } const indexMap = new Map(); keepIndices.forEach((oldIdx, newIdx) => indexMap.set(oldIdx, newIdx)); contentOrder = rendered.contentOrder .map((entry) => { const tm = entry.match(/^text:(\d+)$/); if (!tm) { return entry; } const newIdx = indexMap.get(Number(tm[1])); return newIdx !== undefined ? `text:${newIdx}` : undefined; }) .filter((e): e is string => e !== undefined); textSegments = filteredSegments; contentBlocks = rendered.contentBlocks .map((block) => block.type === "text" ? { type: "text" as const, text: block.text.replace(NO_RESPONSE_INLINE_RE, "").trim(), } : block, ) .filter((block) => block.type !== "text" || block.text.length > 0); } // Ensure every hydrated attachment has a corresponding content block. // renderHistoryContent inlines attachment blocks only when it has // file-block refs with matching DB rows; directives (assistant-authored // tags) don't leave a file block after stripping, // so their attachments end up in the flat `attachments` array but not in // `contentBlocks`. Append any that are missing so the canonical // projection is complete. const existingAttachmentIds = new Set( contentBlocks .filter( ( b, ): b is Extract => b.type === "attachment", ) .map((b) => b.attachment.id), ); for (const att of msgAttachments) { if (!existingAttachmentIds.has(att.id)) { contentBlocks.push({ type: "attachment", attachment: toAttachmentBlockRef(att), }); } } const alignedContentOrder = aligned.rewriteContentOrder(contentOrder); // Use sentAt (actual event time) for the display timestamp when // available, falling back to createdAt (persistence time). Pagination // is unaffected: the cursor is `oldestTimestamp`, built from // `createdAt` on both ends. const displayTimestamp = m.sentAt ?? m.createdAt; return { id: m.id ?? "", ...(mergedMessageIds.length > 0 ? { mergedMessageIds } : {}), ...(m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}), role: m.role, timestamp: new Date(displayTimestamp).toISOString(), attachments: msgAttachments, ...(toolCalls.length > 0 ? { toolCalls } : {}), ...(rendered.surfaces.length > 0 ? { surfaces: rendered.surfaces } : {}), ...(textSegments.length > 0 ? { textSegments } : {}), ...(rendered.thinkingSegments.length > 0 ? { thinkingSegments: rendered.thinkingSegments } : {}), ...(alignedContentOrder.length > 0 ? { contentOrder: alignedContentOrder } : {}), contentBlocks, ...(m.subagentNotification ? { subagentNotification: m.subagentNotification } : {}), ...(m.acpNotification ? { acpNotification: m.acpNotification } : {}), ...(m.backgroundEventNotification ? { backgroundEventNotification: true } : {}), ...(m.backgroundToolCompletion ? { backgroundToolCompletion: m.backgroundToolCompletion } : {}), ...(m.systemCard ? { systemCard: true } : {}), ...(m.providerError ? { providerError: m.providerError } : {}), ...(m.slackMessage ? { slackMessage: m.slackMessage } : {}), }; }), ); // Snapshot↔stream alignment token: the `seq` of the last event whose // content is durably persisted for this conversation, read from the // `conversations.seq` column. Returned on every resolved-conversation // response so a client can apply only stream events with a higher `seq`. // Null when nothing has been persisted (the conversation was created before // any stream activity, or predates the column) -- the client cold-starts. const persistedSeq = getConversationPersistedSeq(resolvedConversationId); // Authoritative "is the agent mid-turn?" signal, sourced from the // `processing_started_at` column (persisted, survives daemon restarts). // Clients use this to distinguish a live turn still in flight from a // turn that silently died — without it, a dropped SSE stream leaves the // UI spinning forever with no way to learn the server is actually idle. const processing = isConversationProcessing(resolvedConversationId); // Append the in-memory queue's pending user messages to the newest page so a // cold reload restores them alongside persisted history. They are the newest // rows in the conversation (enqueued during the in-flight turn) and are not // yet persisted, so they belong only on a request for the latest content — // never on an older-history page (`beforeTimestamp` set). if (beforeTimestamp == null) { messages.push(...buildQueuedMessagePayloads(resolvedConversationId)); } if (isPaginated) { // Prefer the page's oldest visible row (the documented cursor semantic). // When a scan-cap-truncated page comes back empty there's no visible row // to anchor on, so fall back to the resume cursor so the client still gets // a `(timestamp, id)` to continue paginating from instead of stalling. const oldestTimestamp = rawMessages.length > 0 ? rawMessages[0].createdAt : scanResumeCursor?.createdAt; const oldestMessageId = rawMessages.length > 0 ? rawMessages[0].id : scanResumeCursor?.id; // `page=latest` always emits both metadata fields so the web client has // a stable contract; emit `null` when the conversation is empty. // The existing `beforeTimestamp` branch keeps its conditional shape to // avoid disturbing current callers. if (isLatestPage && beforeTimestamp == null) { return { messages, hasMore, oldestTimestamp: oldestTimestamp ?? null, oldestMessageId: oldestMessageId ?? null, seq: persistedSeq, processing, }; } return { messages, hasMore, ...(oldestTimestamp != null ? { oldestTimestamp } : {}), ...(oldestMessageId != null ? { oldestMessageId } : {}), seq: persistedSeq, processing, }; } return { messages, seq: persistedSeq, processing }; } /** * Persist the pre-chat onboarding payload to disk. * * Runs only on the very first message of a fresh conversation. Four * artifacts are produced: * * 1. `data/onboarding-context.json` — sidecar read by the * relationship-state writer so onboarding-sourced facts survive * the pure-recomputation write cycle (every turn boundary rebuilds * facts from markdown; the sidecar is the durable source for the * tool/task/tone chips). * 2. `IDENTITY.md` — assistant persona seed file, only written when * missing so we never clobber existing content. Feeds the system * prompt and the relationship-state writer's `parseIdentity` * helper after a daemon restart when the in-memory onboarding * context is gone. * 3. Onboarding section in the guardian persona file — written via * `writeOnboardingSection`, which handles the user's preferred * name (with fallback to root `USER.md`). * 4. `data/relationship-state.json` — kicked off fire-and-forget so * the Home page can populate immediately on first visit instead * of waiting for the first agent-turn boundary. * * Never throws: every write is guarded and logged as a warning on * failure. The route handler path must never reject because of a * best-effort persistence step. */ export function persistOnboardingArtifacts(onboarding: { tools: string[]; tasks: string[]; tone: string; userName?: string; occupation?: string; assistantName?: string; priorAssistants?: string[]; cohort?: string; websiteUrl?: string; contentSourceUrl?: string; researchFindings?: string[]; }): void { writeOnboardingSidecar(onboarding); const assistantName = onboarding.assistantName?.trim(); if (assistantName) { const identityPath = getWorkspacePromptPath("IDENTITY.md"); try { if (existsSync(identityPath)) { const content = readFileSync(identityPath, "utf-8"); const updated = content.replace( /^- (?:\*\*)?Name:(?:\*\*)?\s*.*$/m, () => `- **Name:** ${assistantName}`, ); if (updated !== content) { writeFileSync(identityPath, updated, "utf-8"); } } else { writeFileSync( identityPath, `# Identity\n\n- **Name:** ${assistantName}\n`, "utf-8", ); } } catch (err) { log.warn( { err, identityPath }, "Failed to seed IDENTITY.md from onboarding", ); } } try { const normalized = normalizeOnboardingContext(onboarding); writeOnboardingSection(normalized); } catch (err) { log.warn({ err }, "Failed to write onboarding section to persona file"); } void writeRelationshipState().catch((err) => { log.warn( { err }, "Failed to kick off relationship-state write after onboarding", ); }); } type ClientMetadataBag = Partial>; /** * Read the sanitized client-metadata headers (browser family/version, OS * surface, build version) sent by web-bundle clients. Values are persisted * under `metadata.client` on the user message, which `turn-events-store` * projects onto `TurnTelemetryEvent.client` for analytics. Returns * `undefined` when no valid header is present so callers can omit the bag. */ function readClientMetadataHeaders( headers: Record | undefined, ): ClientMetadataBag | undefined { if (!headers) { return undefined; } const bag: ClientMetadataBag = {}; for (const [field, headerName] of Object.entries( CLIENT_METADATA_HEADERS, ) as Array<[ClientMetadataField, string]>) { const value = sanitizeClientMetadataValue(headers[headerName]); if (value) { bag[field] = value; } } return Object.keys(bag).length > 0 ? bag : undefined; } /** * Attach the client-metadata bag to a persist-time metadata object under the * `client` key. Passes `metadata` through untouched (including `undefined`) * when there is no client metadata. */ function withClientMetadata( metadata: Record | undefined, clientMetadata: ClientMetadataBag | undefined, ): Record | undefined { if (!clientMetadata) { return metadata; } return { ...(metadata ?? {}), client: clientMetadata, }; } export async function handleSendMessage( { body: rawBody, headers }: RouteHandlerArgs, deps: { sendMessageDeps?: SendMessageDeps; approvalConversationGenerator?: ApprovalConversationGenerator; }, ): Promise { const body = (rawBody ?? {}) as { conversationKey?: string; conversationId?: string; content?: string; attachmentIds?: string[]; sourceChannel?: string; interface?: string; conversationType?: string; automated?: boolean; // Persist the user message but suppress it from the UI transcript (kept in // LLM history). Used by flows like research-onboarding's "Let's chat" // handoff to prime a proactive assistant greeting without showing the // triggering user message. Honored on the standard send path only. hidden?: boolean; // True when the turn was auto-sent on the user's behalf rather than typed. // Independent of `hidden`: the research prompt is visible AND scripted, // the kickoff greeting is hidden AND scripted. Absent means UNKNOWN. scripted?: boolean; bypassSecretCheck?: boolean; hostHomeDir?: string; hostUsername?: string; clientTimezone?: unknown; clientOs?: unknown; visibleAppId?: unknown; clientId?: string; clientMessageId?: string; inferenceProfile?: string | null; enabledPlugins?: string[] | null; riskThreshold?: string; onboarding?: { tools: string[]; tasks: string[]; tone: string; userName?: string; occupation?: string; assistantName?: string; googleConnected?: boolean; googleScopes?: string[]; priorAssistants?: string[]; cohort?: string; websiteUrl?: string; contentSourceUrl?: string; bootstrapTemplate?: string; initialMessage?: string; skills?: string[]; researchFindings?: string[]; title?: string; }; }; const actorPrincipalId = headers?.["x-vellum-actor-principal-id"]; const principalType = headers?.["x-vellum-principal-type"]; const originClientId = headers?.["x-vellum-client-id"]?.trim() || undefined; const clientMetadata = readClientMetadataHeaders(headers); const { conversationKey, content, attachmentIds } = body; const inboundConversationId = typeof body.conversationId === "string" && body.conversationId.length > 0 ? body.conversationId : undefined; const clientMessageId = typeof body.clientMessageId === "string" ? body.clientMessageId : undefined; const requestedInferenceProfile = typeof body.inferenceProfile === "string" ? body.inferenceProfile : undefined; const requestedRiskThreshold = body.riskThreshold; if ( body.inferenceProfile != null && typeof body.inferenceProfile !== "string" ) { throw new BadRequestError( "inferenceProfile must be a non-empty string or null", ); } if (requestedInferenceProfile === "") { throw new BadRequestError( "inferenceProfile must be a non-empty string or null", ); } if (requestedInferenceProfile !== undefined) { const { llm } = getConfig(); const profiles = getEffectiveProfilesForProvider( llm.profiles, llm.defaultProvider ?? null, ); if ( !Object.prototype.hasOwnProperty.call(profiles, requestedInferenceProfile) ) { throw new BadRequestError( `Profile "${requestedInferenceProfile}" is not defined in llm.profiles`, ); } } // `undefined` leaves the stored scope untouched; `null` clears it to the // default; `[]` scopes the chat to no plugins. const requestedEnabledPlugins = body.enabledPlugins; if ( requestedEnabledPlugins != null && (!Array.isArray(requestedEnabledPlugins) || requestedEnabledPlugins.some((p) => typeof p !== "string")) ) { throw new BadRequestError( "enabledPlugins must be an array of strings or null", ); } if ( requestedRiskThreshold !== undefined && !isValidRiskThreshold(requestedRiskThreshold) ) { throw new BadRequestError( `riskThreshold must be one of: ${VALID_RISK_THRESHOLDS.join(", ")}`, ); } if (!body.sourceChannel || typeof body.sourceChannel !== "string") { throw new BadRequestError("sourceChannel is required"); } const sourceChannel = parseChannelId(body.sourceChannel); if (!sourceChannel) { throw new BadRequestError( `Invalid sourceChannel: ${ body.sourceChannel }. Valid values: ${CHANNEL_IDS.join(", ")}`, ); } if (!body.interface || typeof body.interface !== "string") { throw new BadRequestError("interface is required"); } const sourceInterface = parseInterfaceId(body.interface); if (!sourceInterface) { throw new BadRequestError( `Invalid interface: ${body.interface}. Valid values: ${INTERFACE_IDS.join( ", ", )}`, ); } const clientTimezone = typeof body.clientTimezone === "string" ? (canonicalizeTimeZone(body.clientTimezone) ?? undefined) : undefined; // Client OS surface ("web" | "ios" | "macos" | "windows" | "android"), reported // separately from the transport `interface`. Validated against the dedicated // `ClientOs` value set (NOT the interface vocabulary) and only kept when it // resolves — it drives the per-turn `client_os:` context line, never // transport/host-proxy gating. const clientOs = typeof body.clientOs === "string" ? (parseClientOs(body.clientOs) ?? undefined) : undefined; // App the client has open on screen. Purely view state: it drives the // per-turn `visible_app:` context line and nothing else, so an id that no // longer resolves (deleted app) is dropped silently during assembly rather // than failing the send. Traversal-shaped ids are rejected here so nothing // downstream has to treat the value as a path segment. const visibleAppId = typeof body.visibleAppId === "string" && isSafeVisibleAppId(body.visibleAppId) ? body.visibleAppId : undefined; // Reject non-string content values (numbers, objects, etc.) if (content != null && typeof content !== "string") { throw new BadRequestError("content must be a string"); } const trimmedContent = typeof content === "string" ? content.trim() : ""; const hasAttachments = Array.isArray(attachmentIds) && attachmentIds.length > 0; if (trimmedContent.length === 0 && !hasAttachments) { throw new BadRequestError("content or attachmentIds is required"); } // Validate that all attachment IDs resolve if (hasAttachments) { const resolved = getAttachmentsByIds(attachmentIds); if (resolved.length !== attachmentIds.length) { const resolvedIds = new Set(resolved.map((a) => a.id)); const missing = attachmentIds.filter((id) => !resolvedIds.has(id)); throw new BadRequestError( `Attachment IDs not found: ${missing.join(", ")}`, ); } } // Block messages containing known-format secrets before any persistence if (trimmedContent.length > 0 && !body.bypassSecretCheck) { const ingressResult = checkIngressForSecrets(trimmedContent); if (ingressResult.blocked) { return new RouteResponse( JSON.stringify({ accepted: false, error: "secret_blocked", message: ingressResult.userNotice, detectedTypes: ingressResult.detectedTypes, }), { "content-type": "application/json" }, 422, ); } } if (!deps.sendMessageDeps) { throw new RouteError( "Message processing is not available", "SERVICE_UNAVAILABLE", 503, ); } // Reject the legacy "private" mode explicitly rather than silently coercing // it to "standard" — clients that still populate this field expect privacy // semantics that no longer exist. if (body.conversationType === "private") { throw new BadRequestError( "Private conversations are no longer supported. Update your client to omit conversationType or send 'standard'.", ); } // Desktop messages are always from the guardian — reset the heartbeat // timer so the next heartbeat is a full interval after this interaction. HeartbeatService.getInstance()?.resetTimer(); // Resolve the target conversation. Fetch by `conversationId` (the // assistant-minted internal id) when the client supplies it — clients // must obtain this id from a prior daemon response, so a missing row // is a 404. Otherwise fall through to the external-key path: the // client-supplied `conversationKey` (external-key lookup; materializes // on first use) or, when neither is provided, a channel-dependent // default. The vellum channel mints a fresh conversation on every // empty-handed send so first-message-of-a-new-chat surfaces with a // server-minted id; other channels (phone, slack, …) share a stable // `default::` thread so repeated calls from the // same channel/interface stay co-located. let mapping: { conversationId: string; conversationType: string; created: boolean; }; if (inboundConversationId !== undefined) { const existing = getConversation(inboundConversationId); if (!existing) { throw new NotFoundError( `Conversation ${inboundConversationId} not found`, ); } mapping = { conversationId: existing.id, conversationType: existing.conversationType, created: false, }; } else { const resolvedConversationKey = conversationKey && conversationKey.length > 0 ? conversationKey : sourceChannel === "vellum" ? crypto.randomUUID() : `default:${sourceChannel}:${sourceInterface}`; // An onboarding flow may supply an explicit title for the conversation it // mints behind the scenes (e.g. the research pass) so it isn't left with an // auto-generated title. Applied only when this call creates the row. const onboardingTitle = body.onboarding?.title?.trim() || undefined; mapping = getOrCreateConversation(resolvedConversationKey, { conversationType: "standard", title: onboardingTitle, // This route already resolved the channel the message arrived on, and // this is the seam that materializes the row, so the conversation is // attributed from the moment it exists rather than on its first // message. origin: sourceChannel, }); } if (requestedRiskThreshold !== undefined) { const result = await ipcCall("set_conversation_threshold", { conversationId: mapping.conversationId, threshold: requestedRiskThreshold, }); if (result === undefined) { log.error( { conversationId: mapping.conversationId, threshold: requestedRiskThreshold, }, "Failed to set conversation risk threshold override via gateway IPC", ); throw new InternalError("Failed to persist risk threshold override"); } } const smDeps = deps.sendMessageDeps; // Notify all connected clients that the conversation list changed when // this is the first message in a standard conversation, so sidebars on // other devices can refresh. We check for first-message rather than // first-create because the SSE subscribe handler (events-routes.ts) may // have already materialised the conversation from a draft key before any // message was sent — in that case `mapping.created` is `false` even // though, from the user's perspective, this is a brand-new conversation // that other clients don't yet know about. if (mapping.conversationType === "standard") { if (!hasMessages(mapping.conversationId)) { publishConversationListAndMetadataChanged( "created", mapping.conversationId, originClientId, ); } } // Build transport metadata from the request so the daemon can inject // host environment hints (home directory, username) into the LLM context. // The `supportsHostProxy` type predicate narrows `sourceInterface` to // `HostProxyInterfaceId` in the truthy branch, which is exactly the // discriminant the `HostProxyTransportMetadata` variant expects — so the // construction site stays in lock-step with the runtime capability gate. const transport = supportsHostProxy(sourceInterface) ? ({ channelId: sourceChannel, interfaceId: sourceInterface, hostHomeDir: body.hostHomeDir, hostUsername: body.hostUsername, ...(clientTimezone ? { clientTimezone } : {}), ...(clientOs ? { clientOs } : {}), ...(visibleAppId ? { visibleAppId } : {}), } satisfies HostProxyTransportMetadata) : ({ channelId: sourceChannel, interfaceId: sourceInterface, ...(clientTimezone ? { clientTimezone } : {}), ...(clientOs ? { clientOs } : {}), ...(visibleAppId ? { visibleAppId } : {}), } satisfies NonHostProxyTransportMetadata); const conversation = await smDeps.getOrCreateConversation( mapping.conversationId, { transport }, ); if (requestedInferenceProfile !== undefined) { setConversationInferenceProfile( mapping.conversationId, requestedInferenceProfile, ); conversation.applyInferenceProfileState({ profile: requestedInferenceProfile, sessionId: null, expiresAt: null, }); } if (requestedEnabledPlugins !== undefined) { conversation.setEnabledPlugins(requestedEnabledPlugins); } // Store pre-chat onboarding context on the conversation when this is the // very first message (no prior messages loaded). Artifact persistence // (IDENTITY.md, USER.md, sidecar) runs before either the canned greeting // broadcast or normal LLM inference so client-side identity reads observe // the selected assistant name. const isFirstOnboarding = !!body.onboarding && conversation.messages.length === 0; if (isFirstOnboarding) { conversation.setOnboardingContext(body.onboarding!); } // Resolve guardian context from the AuthContext's actorPrincipalId via the // gateway guardian binding: a vellum principal is the guardian or nobody. // // Resolved into a local; the conversation's slot is stamped only where this // request commits to running a turn, since that write is what supplies the // acting actor for a run and must not fire for a send that merely queues. let resolvedTrustCtx: TrustContext; if (actorPrincipalId) { // Dev bypass (HTTP auth disabled): the synthetic "dev-bypass" principal // won't match any guardian binding. Resolve the real guardian principal and // map that through, failing closed to unknown on an empty gateway. if (isHttpAuthDisabled() && actorPrincipalId === "dev-bypass") { const guardianPrincipalId = await findLocalGuardianPrincipalId(); let trustCtx: TrustContext = guardianPrincipalId ? withSourceChannel( sourceChannel, await resolveLocalPrincipalTrustContext({ actorPrincipalId: guardianPrincipalId, sourceChannel: "vellum", conversationExternalId: "local", }), ) : { trustClass: "unknown", sourceChannel }; if (guardianPrincipalId && trustCtx.trustClass === "unknown") { const healed = await reResolveTrustOnResetDrift( guardianPrincipalId, sourceChannel, ); if (healed) { trustCtx = healed; } } resolvedTrustCtx = trustCtx; } else { let trustCtx = withSourceChannel( sourceChannel, await resolveLocalPrincipalTrustContext({ actorPrincipalId, sourceChannel: "vellum", conversationExternalId: "local", }), ); if (trustCtx.trustClass === "unknown") { const healed = await reResolveTrustOnResetDrift( actorPrincipalId, sourceChannel, ); if (healed && healed.trustClass !== "unknown") { trustCtx = healed; log.info( { actorPrincipalId, trustClass: trustCtx.trustClass }, "Trust re-resolved from local mirror after gateway returned unknown", ); } else { log.warn( { actorPrincipalId, sourceChannel, trustClass: "unknown", principalType, }, "JWT-verified actor resolved to unknown trust class — possible guardian binding drift (e.g. DB reset without re-bootstrap)", ); } } resolvedTrustCtx = trustCtx; } } else { // Service principals (svc_gateway) or tokens without an actor ID // get a minimal guardian context so downstream code has something. resolvedTrustCtx = { trustClass: "guardian", sourceChannel }; } // The trust this request's turn runs under: the sender resolved above, // never a read of the shared slot. Between here and the loop that slot is // writable by paths that do not own this turn (channel ingress for another // actor, live-voice hydration, pointer elevation, the voice bridge). const turnTrustContext = resolvedTrustCtx; const isInteractive = isInteractiveInterface(sourceInterface); // Translate the dev-bypass actor principal to the real guardian principal // before the same-actor host-proxy gate so web/iOS turns match the macOS // client's SSE-registered principal. No-op for real JWT principals in // non-dev-bypass deployments. const sourceActorPrincipalId = await resolveActorPrincipalIdForLocalGuardian( actorPrincipalId ?? undefined, ); // Bash/File/Transfer singletons are globally available via isAvailable() — // no per-conversation gating needed. CU is per-conversation (owns step // count, AX tree history, loop detection). if ( shouldAttachHostProxyForCapability( "host_cu", sourceInterface, sourceActorPrincipalId, ) ) { if (!conversation.isProcessing() || !conversation.hostCuProxy) { conversation.setHostCuProxy(new HostCuProxy()); } } else if (!conversation.isProcessing()) { conversation.setHostCuProxy(undefined); } // App-control mirrors CU's per-conversation lifecycle: the proxy owns a // singleton lock plus per-session loop tracking. Instantiation is // unconditional when the capability is reachable — feature-flag gating // lives in the skill-projection layer (which reads the `feature-flag: // app-control` declaration in SKILL.md frontmatter), so an attached proxy // is harmless when the flag resolves to off. if ( shouldAttachHostProxyForCapability( "host_app_control", sourceInterface, sourceActorPrincipalId, ) ) { if (!conversation.isProcessing() || !conversation.hostAppControlProxy) { conversation.setHostAppControlProxy( new HostAppControlProxy(mapping.conversationId), ); } } else if (!conversation.isProcessing()) { conversation.setHostAppControlProxy(undefined); } // Only preactivate when the conversation is idle — if it's processing, // this message will be queued and preactivation is deferred to dequeue // time in drainQueueImpl to avoid mutating in-flight turn state. if (!conversation.isProcessing()) { preactivateHostProxySkills( conversation, sourceInterface, sourceActorPrincipalId, ); } // Delivery needs no wiring: the conversation's sink is the SSE hub for its // whole life. Presence travels with the turn (`isInteractive` below), which // is what keeps host_bash/host_file/host_cu gated for non-desktop // interfaces. The chrome-extension interface is non-interactive (no SSE // prompter UI) but still has a connected client that can service // host_browser_request events; that single proxy is restored explicitly // below without claiming a human is present. if (isInteractive) { // A client that reconnected mid-phase resyncs the current activity state // and any subagent card it left stale while disconnected. conversation.replayActivityState(); getSubagentManager().reannounceChildStatuses(mapping.conversationId); } // ── URL scan path: rewrite first message for scan onboarding ── // When onboarding provides a websiteUrl or contentSourceUrl and the // first message is the macOS wake-up greeting, bypass the canned // greeting and rewrite the user message to a scan instruction so real // LLM inference runs against the URL. const sanitizeUrl = (u?: string) => u?.trim().replace(/[\r\n\t]/g, "") || undefined; const websiteUrl = sanitizeUrl(body.onboarding?.websiteUrl); const contentSourceUrl = sanitizeUrl(body.onboarding?.contentSourceUrl); const scanUrl = websiteUrl || contentSourceUrl; const isWakeUp = isWakeUpGreeting( trimmedContent, conversation.getMessages().length, ); const isScanPath = !!scanUrl && isWakeUp; // Self-intro path: when we know a name, send a natural introduction on the // user's behalf instead of the canned greeting, so the assistant generates a // real first response. Gated behind the `self-intro-greeting` flag (default // off); `undefined` (flag off or no names) falls back to the canned path. const selfIntroGreetingEnabled = isWakeUp && isAssistantFeatureFlagEnabled(SELF_INTRO_GREETING_FLAG); const selfIntro = selfIntroGreetingEnabled ? buildSelfIntroMessage(body.onboarding ?? undefined) : undefined; let effectiveContent: string | undefined; if (isScanPath) { const scanVariant = websiteUrl ? ("website" as const) : ("content-source" as const); effectiveContent = buildScanFirstMessage(scanUrl, scanVariant); // Fall through to normal inference path below } else if (selfIntroGreetingEnabled && body.onboarding?.initialMessage) { effectiveContent = body.onboarding.initialMessage; } else if (isWakeUp && selfIntro) { // Rewrite to the self-introduction and fall through to real inference // (mirrors the scan path above). effectiveContent = selfIntro; } else if (isWakeUp) { const cannedGreeting = getCannedFirstGreeting(body.onboarding ?? undefined); conversation.setProcessing(true); let cleanupDeferred = false; try { const rawContent = content ?? ""; const attachments = hasAttachments ? smDeps.resolveAttachments(attachmentIds) : []; const greetingMeta = { userMessageChannel: sourceChannel, assistantMessageChannel: sourceChannel, userMessageInterface: sourceInterface, assistantMessageInterface: sourceInterface, }; const persisted = await persistQueuedMessageBody(conversation, { content: rawContent, attachments, requestId: uuidv7(), metadata: greetingMeta, clientMessageId, // This path answers and returns without starting a turn, so it never // reaches the stamp below; name the sender on the row directly. trustContext: resolvedTrustCtx, ...(clientOs ? { requestClientOs: clientOs } : {}), }); const conversationId = mapping.conversationId; const channelMeta = buildChannelMetadata(sourceChannel, sourceInterface, { trustContext: resolvedTrustCtx, }); const assistantMsg = createAssistantMessage(cannedGreeting); const persistedAssistant = await addMessage( mapping.conversationId, "assistant", JSON.stringify(assistantMsg.content), { metadata: channelMeta }, ); conversation.getMessages().push(assistantMsg); const response = { accepted: true, messageId: persisted.id, conversationId, }; if (isFirstOnboarding) { persistOnboardingArtifacts(body.onboarding!); try { recordOnboardingEvent({ screen: "complete", tools: body.onboarding!.tools, tasks: body.onboarding!.tasks, tone: body.onboarding!.tone, googleConnected: body.onboarding!.googleConnected, googleScopes: body.onboarding!.googleScopes, }); } catch (err) { log.warn({ err }, "Failed to record onboarding telemetry event"); } } scheduleCannedReplyRelease({ conversation, origin: "canned_greeting", emit: () => { broadcastMessage({ type: "user_message_echo", text: rawContent, conversationId, messageId: persisted.id, clientMessageId, }); broadcastMessage({ type: "assistant_text_delta", text: cannedGreeting, conversationId, }); emitCannedMessageComplete( broadcastMessage, conversationId, persistedAssistant.id, ); // Rows persisted before this deferred burst; advance the // snapshot↔stream anchor past the events just emitted so `/messages` // never returns these rows behind a stale anchor. recordConversationPersistedSeq(conversationId, getCurrentSeq()); publishConversationMessagesChanged(conversationId, originClientId); }, afterRelease: () => { conversation.warmPromptCache(); }, }); log.info( { conversationId, personalized: !!body.onboarding }, "Served canned first greeting — skipped LLM inference", ); cleanupDeferred = true; return response; } finally { if (!cleanupDeferred && conversation.isProcessing()) { conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "send_error_path"); } } } if (isFirstOnboarding) { persistOnboardingArtifacts(body.onboarding!); try { recordOnboardingEvent({ screen: "complete", tools: body.onboarding!.tools, tasks: body.onboarding!.tasks, tone: body.onboarding!.tone, googleConnected: body.onboarding!.googleConnected, googleScopes: body.onboarding!.googleScopes, }); } catch (err) { log.warn({ err }, "Failed to record onboarding telemetry event"); } } // When the scan path rewrote the first message, prefer the rewritten // content for all downstream consumers (guardian reply, enqueue, agent // loop) so they see the scan instruction rather than the wake-up greeting. const contentAfterScan = effectiveContent ?? content ?? ""; const attachments = hasAttachments ? smDeps.resolveAttachments(attachmentIds) : []; // Resolve the verified actor's external user ID and principal for inline // approval routing from the conversation's guardian context. const verifiedActorExternalUserId = resolvedTrustCtx.guardianExternalUserId; const verifiedActorPrincipalId = resolvedTrustCtx.guardianPrincipalId ?? undefined; // Try to consume the message as a guardian approval/rejection reply. // On failure, degrade to the existing queue/auto-deny path rather than // surfacing a 500 — mirrors the handler's catch-and-fallback. try { const inlineReplyResult = await tryConsumeGuardianReply({ conversationId: mapping.conversationId, sourceChannel, sourceInterface, content: contentAfterScan, attachments, conversation, onEvent: broadcastMessage, // Desktop path: disable NL classification to avoid consuming non-decision // messages while a tool confirmation is pending. Deterministic code-prefix // and callback parsing remain active. Mirrors conversation-process.ts behavior. approvalConversationGenerator: sourceChannel === "vellum" ? undefined : deps.approvalConversationGenerator, verifiedActorExternalUserId, verifiedActorPrincipalId, originClientId, }); if (inlineReplyResult.consumed) { return { accepted: true, conversationId: mapping.conversationId, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}), }; } } catch (err) { log.warn( { err, conversationId: mapping.conversationId }, "Inline approval consumption failed, falling through to normal send path", ); } if (conversation.isProcessing()) { // Queue the message so it's processed when the current turn completes const requestId = uuidv7(); const enqueueResult = conversation.enqueueMessage({ content: contentAfterScan, attachments, onEvent: broadcastMessage, requestId, metadata: withClientMetadata( { userMessageChannel: sourceChannel, assistantMessageChannel: sourceChannel, userMessageInterface: sourceInterface, assistantMessageInterface: sourceInterface, ...(body.automated === true ? { automated: true } : {}), // Carry the transcript-suppression flag through the queue so a // hidden send that lands mid-turn stays hidden when drained — // the drain path persists this metadata and skips the echo. ...(body.hidden === true ? { hidden: true } : {}), // Same reason: the queue round-trips metadata, not persist options, // so a scripted send that lands mid-turn can only keep its marker // this way. Both booleans forwarded, since false is a real assertion // ("the user typed this"), not an absence. ...(typeof body.scripted === "boolean" ? { scripted: body.scripted } : {}), }, clientMetadata, ), isInteractive, sourceActorPrincipalId, transport, clientMessageId, // The sender's own trust, so the drain runs this message as the actor // who sent it rather than as whoever the slot happens to hold when the // queue is worked. trustContext: resolvedTrustCtx, }); if (enqueueResult.rejected) { return new RouteResponse( JSON.stringify({ accepted: false, error: "queue_full" }), { "content-type": "application/json" }, 429, ); } // Auto-deny pending confirmations only after enqueue succeeds, so we // don't cancel approval-gated workflows when the replacement message // is itself rejected by the queue budget. // Wrapped in try-catch: the message is already enqueued, so a failure // here must not turn the 202 response into a 500 — that would leave // the client showing "Failed to send" for a message the daemon will // process from the queue. // // Supersede encodes user intent — a typed message while a prompt is open // means the user chose to move on. A hidden send is a machine signal // (e.g. the channel-setup wizard-close marker), not a user decision: it // must not auto-deny live approval prompts or steer a parked // ask_question to a message the user never typed. Daemon-injected // synthetic messages (subagent/ACP notifications) skip this path the // same way by enqueuing directly. if (body.hidden !== true) { try { // Supersede interactions left pending by the in-flight turn: auto-deny // confirmations (with gateway/client sync) and steer to the enqueued // message if an ask_question is parked. Centralized so the CLI signal // path (signals/user-message.ts) gets identical handling. supersedePendingInteractionsOnEnqueue( mapping.conversationId, requestId, ); // Expire any orphaned guardian requests that survived without a // matching in-memory pending interaction (e.g. prompter timeouts). await expireOrphanedGuardianRequests(mapping.conversationId); } catch (err) { log.warn( { err, conversationId: mapping.conversationId }, "Post-enqueue auto-deny failed — queued message unaffected", ); } } return { accepted: true, queued: true, conversationId: mapping.conversationId, requestId, }; } // Auto-deny pending confirmations for idle conversations. The legacy // handleUserMessage called autoDenyPendingConfirmations unconditionally // before dispatching, so an idle conversation with lingering confirmations // (e.g. the user never responded to a tool-approval prompt) must deny // them before starting the new turn. // Hidden sends are machine signals, not user decisions — like the queue // branch's supersede bypass above, they must not deny confirmations that // outlived a turn (e.g. a guardian approval still awaiting a channel // reply). The next visible send performs the cleanup instead. if (body.hidden !== true && conversation.hasAnyPendingConfirmation()) { for (const interaction of pendingInteractions.getByConversation( mapping.conversationId, )) { if (interaction.kind === "confirmation") { conversation.emitConfirmationStateChanged({ conversationId: mapping.conversationId, requestId: interaction.requestId, state: "denied" as const, source: "auto_deny" as const, }); // Sync the gateway request status so stale "pending" records don't // get matched by later guardian reply routing. Fire-and-forget: the // in-memory denial is authoritative here; a CAS miss (already // decided elsewhere) or a lost sync is reaped by the orphan sweep. void decideGuardianRequest({ id: interaction.requestId, expectedStatus: "pending", status: "denied", }).catch((err) => { log.warn( { err, requestId: interaction.requestId }, "Auto-deny guardian request status sync failed", ); }); } } conversation.denyAllPendingConfirmations(); pendingInteractions.removeByConversation(mapping.conversationId); } // Expire any orphaned guardian requests that survived without a // matching in-memory pending interaction (e.g. prompter timeouts). await expireOrphanedGuardianRequests(mapping.conversationId); // Conversation is idle — persist and fire agent loop immediately. // // Stamping the sender here rather than at resolution is what keeps the two // in step: the slot hydrates and scopes the turn started just below // (`ensureActorScopedHistory`, persisted provenance, the loop's own trust), // so it must name whoever this request is about to run as. A request that // queues instead returns above without stamping — it is not starting a run, // and its actor rides the queue item to the drain. conversation.setTrustContext(resolvedTrustCtx); conversation.setTurnChannelContext({ userMessageChannel: sourceChannel, assistantMessageChannel: sourceChannel, }); conversation.setTurnInterfaceContext({ userMessageInterface: sourceInterface, assistantMessageInterface: sourceInterface, }); conversation.currentTurnSourceActorPrincipalId = sourceActorPrincipalId; await conversation.ensureActorScopedHistory(); // Resolve slash commands before persisting or running the agent loop. // `contentAfterScan` already carries the scan-rewritten content when // applicable; reuse it here for consistency. const rawContent = contentAfterScan; const slashContext = buildSlashContextForContent(rawContent, { conversationId: mapping.conversationId, messageCount: conversation.getMessages().length, inputTokens: conversation.usageStats.inputTokens, outputTokens: conversation.usageStats.outputTokens, estimatedCost: conversation.usageStats.estimatedCost, userMessageInterface: sourceInterface, }); const slashResult = await resolveSlash(rawContent, slashContext); if (slashResult.kind === "unknown") { conversation.setProcessing(true); let cleanupDeferred = false; try { const slashMeta = { userMessageChannel: sourceChannel, assistantMessageChannel: sourceChannel, userMessageInterface: sourceInterface, assistantMessageInterface: sourceInterface, ...(body.automated === true ? { automated: true } : {}), ...(typeof body.scripted === "boolean" ? { scripted: body.scripted } : {}), }; const persisted = await persistQueuedMessageBody(conversation, { content: rawContent, attachments, requestId: uuidv7(), metadata: withClientMetadata(slashMeta, clientMetadata), clientMessageId, ...(clientOs ? { requestClientOs: clientOs } : {}), }); if (persisted.deduplicated) { return { accepted: true, messageId: persisted.id, conversationId: mapping.conversationId, }; } const channelMeta = buildChannelMetadata(sourceChannel, sourceInterface, { trustContext: conversation.trustContext, }); const assistantMsg = createAssistantMessage(slashResult.message); const persistedAssistant = await addMessage( mapping.conversationId, "assistant", JSON.stringify(assistantMsg.content), { metadata: channelMeta }, ); conversation.getMessages().push(assistantMsg); // Snapshot model info now so the deferred callback cannot observe // a config change from a concurrent request. const modelInfoEvent = isModelSlashCommand(rawContent) ? await buildModelInfoEvent(mapping.conversationId) : null; const response = { accepted: true, messageId: persisted.id, conversationId: mapping.conversationId, }; // Defer event publishing to next tick so the HTTP response reaches the // client first. This ensures the client's serverToLocalConversationMap is // populated before SSE events arrive, preventing dropped events in new // desktop conversations. // // conversation.processing and drainQueue are also deferred so the current // slash command's events are emitted before the next queued message // starts processing. const conversationId = mapping.conversationId; const message = slashResult.message; scheduleCannedReplyRelease({ conversation, origin: "slash_command", emit: () => { broadcastMessage({ type: "user_message_echo", text: rawContent, conversationId, messageId: persisted.id, clientMessageId, }); if (modelInfoEvent) { broadcastMessage(modelInfoEvent); } broadcastMessage({ type: "assistant_text_delta", text: message, conversationId, }); emitCannedMessageComplete( broadcastMessage, conversationId, persistedAssistant.id, ); // Same anchor advance as the canned-greeting path above. recordConversationPersistedSeq(conversationId, getCurrentSeq()); publishConversationMessagesChanged(conversationId, originClientId); }, }); cleanupDeferred = true; return response; } finally { // No-op for the slash-command early-return path (handled inside // setTimeout above), but still needed for error paths. if (!cleanupDeferred && conversation.isProcessing()) { conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "send_error_path"); } } } if (slashResult.kind === "compact") { conversation.setProcessing(true); const slashMeta = { userMessageChannel: sourceChannel, assistantMessageChannel: sourceChannel, userMessageInterface: sourceInterface, assistantMessageInterface: sourceInterface, }; let persisted: Awaited>; try { persisted = await persistQueuedMessageBody(conversation, { content: rawContent, attachments, requestId: uuidv7(), metadata: withClientMetadata(slashMeta, clientMetadata), clientMessageId, ...(clientOs ? { requestClientOs: clientOs } : {}), }); } catch (err) { // The fire-and-forget compaction below owns clearing `processing`, but a // throw from this initial persist never reaches it — reset here so the // conversation isn't stranded in queued mode. conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "compact_command"); throw err; } if (persisted.deduplicated) { conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "compact_dedup"); return { accepted: true, messageId: persisted.id, conversationId: mapping.conversationId, }; } const conversationId = mapping.conversationId; const channelMeta = buildChannelMetadata(sourceChannel, sourceInterface, { trustContext: conversation.trustContext, }); // Fire-and-forget: return 202 immediately, run compaction async. // forceCompact() makes an LLM call that can exceed the client's // HTTP timeout on large contexts, causing a false "Failed to send". (async () => { try { broadcastMessage({ type: "user_message_echo", text: rawContent, conversationId, messageId: persisted.id, clientMessageId, }); publishConversationMessagesChanged(conversationId, originClientId); conversation.emitActivityState("thinking", "context_compacting"); // Same sink the result card below goes out on, so the indicator and // the card can never be delivered to different places. const result = await conversation.forceCompact(broadcastMessage); const cardId = await persistCannedAssistantCard({ conversation, conversationId, text: formatCompactResult(result), metadata: channelMeta, }); // Attribute the compaction LLM call to the card it produced — same // linkage as the summarize-up-to route. if (result.summaryRequestLogId) { linkRequestLogsToMessage([result.summaryRequestLogId], cardId); } } catch (err) { log.error({ err, conversationId }, "Compact command failed"); broadcastMessage({ type: "conversation_error", conversationId, code: "UNKNOWN", userMessage: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`, retryable: true, }); } finally { conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "compact_command"); } })(); return { accepted: true, messageId: persisted.id, conversationId, }; } if (slashResult.kind === "clean") { conversation.setProcessing(true); const conversationId = mapping.conversationId; // Outer try/finally guarantees the processing flag is cleared (and the // queue drained) on every failure path — including a throw from the // initial user-message persist below, which would otherwise leave the // conversation stuck in queued mode indefinitely. try { const slashMeta = { userMessageChannel: sourceChannel, assistantMessageChannel: sourceChannel, userMessageInterface: sourceInterface, assistantMessageInterface: sourceInterface, }; const persisted = await persistQueuedMessageBody(conversation, { content: rawContent, attachments, requestId: uuidv7(), metadata: withClientMetadata(slashMeta, clientMetadata), clientMessageId, ...(clientOs ? { requestClientOs: clientOs } : {}), }); if (persisted.deduplicated) { return { accepted: true, messageId: persisted.id, conversationId, }; } const channelMeta = buildChannelMetadata(sourceChannel, sourceInterface, { trustContext: conversation.trustContext, }); try { broadcastMessage({ type: "user_message_echo", text: rawContent, conversationId, messageId: persisted.id, clientMessageId, }); publishConversationMessagesChanged(conversationId, originClientId); const result = await conversation.forceClean(); await persistCannedAssistantCard({ conversation, conversationId, text: formatCleanResult(result), metadata: channelMeta, }); } catch (err) { log.error({ err, conversationId }, "Clean command failed"); broadcastMessage({ type: "conversation_error", conversationId, code: "UNKNOWN", userMessage: `Clean failed: ${err instanceof Error ? err.message : String(err)}`, retryable: true, }); } return { accepted: true, messageId: persisted.id, conversationId, }; } finally { conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "clean_command"); } } const resolvedContent = slashResult.content; const requestId = uuidv7(); const persistResult = await conversation.persistUserMessage({ content: resolvedContent, attachments, requestId, metadata: withClientMetadata( body.automated === true || body.hidden === true ? { ...(body.automated === true ? { automated: true } : {}), ...(body.hidden === true ? { hidden: true } : {}), } : undefined, clientMetadata, ), scripted: body.scripted, clientMessageId, ...(clientOs ? { requestClientOs: clientOs } : {}), }); const messageId = persistResult.id; if (persistResult.deduplicated) { return { accepted: true, messageId, conversationId: mapping.conversationId, }; } // A hidden message is suppressed from the UI transcript: don't echo it back // to clients (the echo would render a user bubble the list-messages filter // otherwise hides). The turn still runs below, and the assistant's reply // streams normally — so the chat reads as a proactive greeting. if (body.hidden !== true) { broadcastMessage({ type: "user_message_echo", text: resolvedContent, conversationId: mapping.conversationId, messageId, requestId, clientMessageId, }); // The row this echo announces was durably persisted above, so advance // the snapshot↔stream anchor to the echo's seq (stamped inline by // `broadcastMessage`). Without this, `/messages` returns the row while // still advertising the previous flush's anchor — under-claiming, which // breaks the contract that the snapshot reflects all of this // conversation's events through the advertised seq. Safe to claim here: // the agent loop for this turn hasn't started, so no streamed-but- // unflushed content exists for this conversation. recordConversationPersistedSeq(mapping.conversationId, getCurrentSeq()); } publishConversationMessagesChanged(mapping.conversationId, originClientId); // Fire-and-forget the agent loop; events flow to the hub via broadcastMessage. conversation .runAgentLoop(resolvedContent, messageId, { onEvent: broadcastMessage, isInteractive, isUserMessage: true, turnTrustContext, ...(body.hidden === true ? { isHiddenPrompt: true } : {}), }) .catch((err) => { log.error( { err, conversationId: mapping.conversationId }, "Agent loop failed (POST /messages)", ); }); return { accepted: true, messageId, conversationId: mapping.conversationId, }; } function escapeXmlContent(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">"); } async function generateLlmSuggestion( provider: Provider, assistantText: string, priorUserText: string | null, conversationId: string, ): Promise { const log = (await import("../../util/logger.js")).getLogger("runtime-http"); const truncatedAssistant = escapeXmlContent( assistantText.length > 2000 ? assistantText.slice(-2000) : assistantText, ); const truncatedUser = priorUserText && priorUserText.length > 500 ? escapeXmlContent(priorUserText.slice(-500)) : priorUserText ? escapeXmlContent(priorUserText) : priorUserText; const systemPrompt = [ "You generate short, casual reply suggestions a user might type next in a chat.", "Match the tone and register of the preceding conversation.", "", "CRITICAL — write from the USER'S perspective only, NEVER from the assistant's:", "- The suggestion is what the USER will type into the chat input", '- Use first-person "I" only if the user has used it in their prior messages', '- NEVER start with phrases like "I can help", "Here\'s what", "Let me", "I\'d suggest" — those are assistant-voice', "- Think: if you were the user reading the assistant's reply, what question or follow-up would you ask next?", "", "Output only the reply text inside the requested tags — no preamble, no commentary.", ].join("\n"); const userPrompt = `Here is the end of a conversation:\n\n` + `${truncatedUser ?? "(no prior user message)"}\n` + `${truncatedAssistant}\n\n` + `Write the USER'S next reply — what the user would type. Focus on the LAST question or call-to-action in the assistant message. Keep it short (under 15 words), casual, and in the user's voice. ` + `The reply must read as something typed BY the user, not something the assistant would say. Respond in this exact format:\n\n` + `YOUR_REPLY_HERE`; // Single user message only — no assistant-role prefill. Anthropic // rejects assistant prefill whenever the request triggers extended // thinking (e.g. Opus 4.x at `effort: "xhigh"`), and the call-site // config is user-controlled, so we can't statically guarantee a // prefill-safe model. Keep `stop_sequences: [""]` as an // early-termination hint; the parser below handles both tagged and // untagged responses so untagged "casual answer" replies still work. // // Force `thinking: disabled` + `effort: none` so the call works on any // user profile — including thinking-enabled profiles (Opus 4.x at // `effort: high|xhigh`, etc.) where Anthropic 400s on `temperature` ≠ 1 // when thinking is enabled or in adaptive mode. A 60-token reply chip // doesn't benefit from extended thinking anyway, and burning thinking // tokens here would be wasteful. const response = await provider.sendMessage( [{ role: "user", content: [{ type: "text", text: userPrompt }] }], { tools: [], // no tools systemPrompt, config: { callSite: "replySuggestion", conversationId, max_tokens: 60, stop_sequences: [""], temperature: 0.7, thinking: { type: "disabled" }, effort: "none", }, }, ); const textBlock = response.content.find((b) => b.type === "text"); const raw = textBlock && "text" in textBlock ? textBlock.text : ""; // Prefer the content inside when the model honors the // tag format. If the response has no tags, fall back to the raw text — // a plain "Sure, tomorrow works" without tags is still a valid chip. const tagMatch = raw.match(/([\s\S]*?)(?:<\/reply>|$)/i); const extracted = tagMatch ? tagMatch[1] : raw; const stripped = extracted .replace(/<\/?reply>/gi, "") .replace(/^["'`]+|["'`]+$/g, "") .trim(); if (!stripped) { log.debug("Suggestion rejected: empty LLM response"); return null; } // Take first line only const firstLine = stripped.split("\n")[0].trim(); if (!firstLine) { log.debug( { rawLength: stripped.length }, "Suggestion rejected: empty after first-line extraction", ); return null; } return firstLine; } export async function handleGetSuggestion( { queryParams }: RouteHandlerArgs, deps: { suggestionCache: Map; suggestionInFlight: Map>; }, ): Promise> { const noSuggestion = { suggestion: null, messageId: null, source: "none" as const, }; const conversationKey = queryParams?.conversationKey; const conversationId = queryParams?.conversationId; if (!conversationKey && !conversationId) { throw new BadRequestError( "conversationKey or conversationId query parameter is required", ); } let resolvedConversationId: string | undefined; if (conversationId) { resolvedConversationId = conversationId; } else if (conversationKey) { const mapping = getConversationByKey(conversationKey); if (mapping) { resolvedConversationId = mapping.conversationId; } else if (getConversation(conversationKey)) { resolvedConversationId = conversationKey; } } if (!resolvedConversationId) { return noSuggestion; } const rawMessages = getMessages(resolvedConversationId); if (rawMessages.length === 0) { return noSuggestion; } // Staleness check: compare requested messageId against the latest // assistant message BEFORE filtering by text content. This ensures // that a newer tool-only assistant turn (empty text) still causes // older messageId requests to be correctly marked as stale. const requestedMessageId = queryParams?.messageId; if (requestedMessageId) { for (let i = rawMessages.length - 1; i >= 0; i--) { if (rawMessages[i].role === "assistant") { if (rawMessages[i].id !== requestedMessageId) { return { ...noSuggestion, stale: true }; } break; } } } const { suggestionCache, suggestionInFlight } = deps; const log = (await import("../../util/logger.js")).getLogger("runtime-http"); // Walk backwards to find the last assistant message with text content for (let i = rawMessages.length - 1; i >= 0; i--) { const msg = rawMessages[i]; if (msg.role !== "assistant") { continue; } const content: unknown = msg.content; const rendered = renderHistoryContent(content); const text = rendered.text.trim(); if (!text) { continue; } // If a messageId was requested and the first text-bearing assistant // message is a *different* message, the request is stale. if (requestedMessageId && msg.id !== requestedMessageId) { return { ...noSuggestion, stale: true }; } // Return cached suggestion if we already generated one for this message const cached = suggestionCache.get(msg.id); if (cached !== undefined) { return { suggestion: cached, messageId: msg.id, source: "llm" as const }; } // Find the most recent user message preceding this assistant turn so the // suggestion model can see both sides of the conversation and doesn't have // to guess which role it's generating for. let priorUserText: string | null = null; for (let j = i - 1; j >= 0; j--) { if (rawMessages[j].role !== "user") { continue; } const userContent: unknown = rawMessages[j].content; const userText = renderHistoryContent(userContent).text.trim(); if (userText) { priorUserText = userText; break; } } // Try LLM suggestion using the configured provider const provider = await getConfiguredProvider("replySuggestion"); if (provider) { try { // Deduplicate concurrent requests let promise = suggestionInFlight.get(msg.id); if (!promise) { promise = generateLlmSuggestion( provider, text, priorUserText, resolvedConversationId, ); suggestionInFlight.set(msg.id, promise); } const llmSuggestion = await promise; suggestionInFlight.delete(msg.id); if (llmSuggestion) { // Evict oldest entries if cache is at capacity if (suggestionCache.size >= SUGGESTION_CACHE_MAX) { const oldest = suggestionCache.keys().next().value!; suggestionCache.delete(oldest); } suggestionCache.set(msg.id, llmSuggestion); return { suggestion: llmSuggestion, messageId: msg.id, source: "llm" as const, }; } } catch (err) { suggestionInFlight.delete(msg.id); log.warn( { err, conversationKey, messageId: msg.id }, "LLM suggestion failed", ); } } else { log.debug( { conversationKey, messageId: msg.id }, "Suggestion skipped: no provider available", ); } return noSuggestion; } return noSuggestion; } /** * GET /search?q=[&limit=][&maxMessagesPerConversation=] * * Full-text search across all conversations (message content + titles). * Returns ranked results grouped by conversation, each with matching message excerpts. */ async function handleSearchConversations({ queryParams, }: RouteHandlerArgs): Promise> { const query = queryParams?.q ?? ""; if (!query.trim()) { throw new BadRequestError("q query parameter is required"); } const limit = queryParams?.limit ? Number(queryParams.limit) : undefined; const maxMessagesPerConversation = queryParams?.maxMessagesPerConversation ? Number(queryParams.maxMessagesPerConversation) : undefined; const results = await searchConversations(query, { ...(limit !== undefined && !isNaN(limit) ? { limit } : {}), ...(maxMessagesPerConversation !== undefined && !isNaN(maxMessagesPerConversation) ? { maxMessagesPerConversation } : {}), }); return { query, results }; } // --------------------------------------------------------------------------- // Module-level state // --------------------------------------------------------------------------- const suggestionCache = new Map(); const suggestionInFlight = new Map>(); function resolveAttachments(attachmentIds: string[]) { const resolved = getAttachmentsByIds(attachmentIds, { hydrateFileData: true, }); const sourcePaths = getSourcePathsForAttachments(attachmentIds); return resolved.map((a) => ({ id: a.id, filename: a.originalFilename, mimeType: a.mimeType, data: a.dataBase64, ...(sourcePaths.has(a.id) ? { filePath: sourcePaths.get(a.id) } : {}), })); } // --------------------------------------------------------------------------- // Route definitions // --------------------------------------------------------------------------- export const ROUTES: RouteDefinition[] = [ { operationId: "messages_get", endpoint: "messages", method: "GET", policy: { requiredScopes: ["chat.read"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "List messages", description: "Return messages for a conversation, including attachments and interface file metadata.", tags: ["messages"], queryParams: [ { name: "conversationId", type: "string", required: false, description: "Conversation UUID. One of conversationId or conversationKey is required.", }, { name: "conversationKey", type: "string", required: false, description: "Channel/external conversation key. One of conversationId or conversationKey is required.", }, { name: "page", type: "string", required: false, description: "When set to 'latest', returns the most recent page of messages with pagination metadata.", }, { name: "beforeTimestamp", type: "integer", required: false, description: "Return messages older than this timestamp (ms since epoch). Used for paging older history.", }, { name: "limit", type: "integer", required: false, description: "Maximum number of messages to return.", }, ], responseBody: z.object({ messages: z .array(ConversationMessageSchema) .describe("Array of message objects"), hasMore: z .boolean() .optional() .describe("Whether older messages exist beyond this page"), oldestTimestamp: z .number() .nullable() .optional() .describe( "Timestamp of the oldest message in this page (ms since epoch). Null when page=latest is used on an empty conversation.", ), oldestMessageId: z .string() .nullable() .optional() .describe("ID of the oldest message in this page"), seq: z .number() .nullable() .optional() .describe( "Global SSE `seq` of the last event whose content is durably persisted for this conversation in the current daemon process. A client can align this snapshot with the `/events` stream by applying only events with `seq` greater than this value. Null when no events have been persisted in this process (cold conversation, after a daemon restart, or when the conversation has aged out of the in-memory map) — clients should cold-start in that case. Absent on older daemons that predate this field.", ), processing: z .boolean() .optional() .describe( "Whether the agent is currently mid-turn for this conversation, sourced authoritatively from the persisted `processing_started_at` column. `true` means a turn is in flight; `false` means the conversation is idle. Clients use this to recover from a dropped SSE stream: if a turn appears to be running locally but the server reports `processing: false`, the turn has ended (or died) and the UI should stop waiting rather than spin indefinitely. Absent on older daemons that predate this field.", ), }), handler: (args) => handleListMessages(args), }, { operationId: "messages_post", endpoint: "messages", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Send a message", description: "Send a user message to a conversation and trigger the assistant response.", tags: ["messages"], responseStatus: "202", requestBody: z.object({ conversationId: z .string() .nullable() .optional() .describe( "Internal conversation id (0.8.6+ strict lookup). Omit both id and key to mint a new conversation server-side.", ), conversationKey: z.string().nullable().optional(), content: z.string().describe("Message text content"), attachments: z .array(z.unknown()) .describe("Optional inline file attachments") .optional(), attachmentIds: z .array(z.string()) .describe("Ids of previously uploaded attachments to attach") .optional(), sourceChannel: z .string() .describe('Originating channel id (e.g. "vellum")'), interface: z .string() .describe('Originating interface id (e.g. "vellum")'), conversationType: z.string().optional(), slashCommand: z.string().optional(), clientTimezone: z.string().optional(), clientOs: z .string() .optional() .describe( 'Client OS surface ("web" | "ios" | "macos" | "windows" | "android"), reported separately from `interface`. Drives the per-turn `client_os` context only; does not affect transport/host-proxy capabilities.', ), visibleAppId: z .string() .optional() .describe( 'Id of the app the client currently has open on screen (app viewer or the app-editing split). Drives the per-turn `visible_app:` context line so the assistant can resolve "the app" to what the user is looking at. View state only: it never affects transport, routing, or tool gating, and is omitted whenever no app is in view.', ), clientMessageId: z .string() .describe( "Client-generated idempotency nonce. Persisted on the row and echoed back on the message_echo event and the messages snapshot so the client can correlate its optimistic row by identity. Duplicate sends for the same (conversation, clientMessageId) are deduplicated server-side.", ) .optional(), inferenceProfile: z.string().nullable().optional(), enabledPlugins: z .array(z.string()) .nullable() .optional() .describe( "Plugin ids that scope this conversation to a subset of installed plugins (first-party defaults are always available). When present on a message, it sets/updates the conversation's plugin scope (the web client sends it only on the first message of a new chat). null clears the scope to default (all enabled plugins); omitting the field leaves the existing scope unchanged.", ), riskThreshold: z.enum(VALID_RISK_THRESHOLDS).optional(), bypassSecretCheck: z .boolean() .optional() .describe( 'When true, skip the secret-ingress scan for this message only. Set exclusively when the user explicitly confirms a client-side blocked send (the composer\'s "Send anyway" action); it is per-message and never persisted.', ), hidden: z .boolean() .optional() .describe( "When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used for machine signals the user never typed (proactive-greeting priming, channel-setup wizard close). Suppression covers the queued path too: a hidden send that lands mid-turn returns { queued: true, requestId } but never appears in list-messages queued snapshots, emits no echo, and does not supersede pending interactions. Honored on the standard send path only — slash-command content bypasses it.", ), scripted: z .boolean() .optional() .describe( "When true, this turn was auto-sent on the user's behalf rather than typed by them: onboarding research prompts, the personality rewrite message, research corrections, hidden kickoff greetings, the legacy pre-chat bootstrap. Stamped onto the persisted message and forwarded to turn telemetry, where activation metrics exclude it. Send false for a genuine typed message; OMIT the field only if the client genuinely cannot tell, since absent means UNKNOWN and a wrong false is trusted downstream. Independent of `hidden`: a turn can be visible and scripted (the research prompt) or hidden and scripted (the kickoff greeting).", ), onboarding: z .object({ tools: z.array(z.string()), tasks: z.array(z.string()), tone: z.string(), userName: z.string().optional(), occupation: z.string().optional(), assistantName: z.string().optional(), googleConnected: z.boolean().optional(), googleScopes: z.array(z.string()).optional(), priorAssistants: z.array(z.string()).optional(), cohort: z.string().optional(), websiteUrl: z.string().optional(), contentSourceUrl: z.string().optional(), bootstrapTemplate: z.string().optional(), initialMessage: z.string().optional(), skills: z.array(z.string()).optional(), researchFindings: z .array(z.string()) .optional() .describe( "Findings from pre-chat onboarding research that the user explicitly kept on the results screen. Written into the persona's onboarding section so the first turn can reference them.", ), title: z .string() .optional() .describe( "Explicit title for the conversation minted on this first message. Persisted as a user-set title (never overwritten by the auto-titler). Used by onboarding flows that mint a conversation behind the scenes.", ), }) .describe("PreChat onboarding context, sent on the first message only") .optional(), }), responseBody: z.object({ accepted: z.boolean(), conversationId: z.string().optional(), messageId: z.string().optional(), queued: z.boolean().optional(), requestId: z.string().optional(), }), handler: async (args) => handleSendMessage(args, { sendMessageDeps: { getOrCreateConversation: getOrCreateConversationInstance, assistantEventHub, resolveAttachments, }, approvalConversationGenerator: createApprovalConversationGenerator(), }), }, { operationId: "search_get", endpoint: "search", method: "GET", policy: { requiredScopes: ["chat.read"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Search conversations", description: "Full-text search across all conversations.", tags: ["conversations"], responseBody: z.object({ query: z.string(), results: z.array(z.unknown()), }), handler: handleSearchConversations, }, { operationId: "suggestion_get", endpoint: "suggestion", method: "GET", policy: { requiredScopes: ["chat.read"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Get reply suggestion", description: "Return an LLM-generated follow-up suggestion for the most recent assistant message.", tags: ["messages"], queryParams: [ { name: "conversationId", type: "string", description: "Conversation ID to fetch a suggestion for. Either this or conversationKey is required.", }, { name: "conversationKey", type: "string", description: "Legacy conversation key. Either this or conversationId is required.", }, { name: "messageId", type: "string", description: "Optional. Latest assistant message ID the client has seen — used to detect staleness.", }, ], responseBody: z.object({ suggestion: z.string().nullable(), messageId: z.string().nullable(), source: z.string(), stale: z.boolean().optional(), }), handler: async (args) => handleGetSuggestion(args, { suggestionCache, suggestionInFlight, }), }, ];