/** * Route handlers for conversation management operations. * * POST /v1/conversations — create a new conversation * POST /v1/conversations/switch — switch to an existing conversation * POST /v1/conversations/fork — fork an existing conversation * POST /v1/conversations/summarize — summarize context up to a message * PUT /v1/conversations/:id/inference-profile — set per-conversation inference profile * PUT /v1/conversations/:id/enabledplugins — set per-conversation plugin scope * PATCH /v1/conversations/:id/name — rename a conversation * DELETE /v1/conversations — clear all conversations * DELETE /v1/conversations/:id — delete a single conversation * POST /v1/conversations/:id/archive — archive a conversation * POST /v1/conversations/:id/unarchive — restore an archived conversation * POST /v1/conversations/archive/bulk — archive multiple conversations * POST /v1/conversations/:id/surface — promote to / demote from Recents * POST /v1/conversations/:id/cancel — cancel generation * POST /v1/conversations/:id/undo — undo last message * POST /v1/conversations/:id/retry — retry the last assistant turn * POST /v1/conversations/reorder — reorder / pin conversations */ import { v7 as uuidv7 } from "uuid"; import { z } from "zod"; import { isUserCancellation } from "../../daemon/conversation-error.js"; import { touchConversation } from "../../daemon/conversation-evictor.js"; import { discardLastAssistantDisplayTurn, extractUserPromptText, } from "../../daemon/conversation-history.js"; import { formatSummarizeUpToResult } from "../../daemon/conversation-process.js"; import { findConversation } from "../../daemon/conversation-registry.js"; import { destroyActiveConversation, getOrCreateConversation as getOrCreateConversationInstance, } from "../../daemon/conversation-store.js"; import { cancelGeneration, clearAllConversations, resolveMetaSlashCommand, switchConversation, undoLastMessage, } from "../../daemon/handlers/conversations.js"; import { normalizeConversationType } from "../../daemon/message-types/shared.js"; import { stripConversationIds } from "../../home/feed-writer.js"; import { archiveConversation, batchSetConversationPlacement, countConversationsByScheduleJobId, deleteConversation, forkConversation as forkConversationInStore, getConversation, isBackgroundEventMetadata, isEchoSuppressedUserMessage, setConversationEnabledPlugins, setConversationSurfaced, unarchiveConversation, updateConversationTitle, } from "../../persistence/conversation-crud.js"; import { getOrCreateConversation, resolveConversationId, setConversationKeyIfAbsent, } from "../../persistence/conversation-key-store.js"; import type { NonScheduledConversationType } from "../../persistence/conversation-types.js"; import { enqueueMemoryJob } from "../../persistence/jobs-store.js"; import { linkRequestLogsToMessage } from "../../persistence/llm-request-log-store.js"; import { deleteSchedule, getSchedule } from "../../schedule/schedule-store.js"; import { UserError } from "../../util/errors.js"; import { safeParseRecord } from "../../util/json.js"; import { getLogger } from "../../util/logger.js"; import { broadcastMessage } from "../assistant-event-hub.js"; import { ACTOR_PRINCIPALS } from "../auth/route-policy.js"; import { resolveActorPrincipalIdForLocalGuardian } from "../local-actor-identity.js"; import { buildConversationDetailResponse } from "../services/conversation-serializer.js"; import { publishConversationEnabledPluginsChanged, publishConversationListAndMetadataChanged, publishConversationListChanged, publishConversationMessagesChanged, publishConversationTitleChanged, } from "../sync/resource-sync-events.js"; import { persistCannedAssistantCard } from "./canned-message-complete.js"; import { buildChannelMetadata } from "./channel-metadata.js"; import { conversationSummarySchema } from "./conversation-list-routes.js"; import { BadRequestError, ConflictError, InternalError, NotFoundError, UnprocessableEntityError, } from "./errors.js"; import { setInferenceProfileSession } from "./inference-profile-session-handler.js"; import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; import { resolveVellumActorTrustContext } from "./vellum-actor-trust.js"; const log = getLogger("conversation-management-routes"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Conversation types this endpoint may mint. */ const createConversationTypeSchema = z.enum([ "standard", "background", ] as const satisfies readonly NonScheduledConversationType[]); function resolveOrThrow(rawId: string): string { const id = resolveConversationId(rawId); if (!id) { throw new NotFoundError(`Conversation ${rawId} not found`); } return id; } async function cancelScheduleIfLast(conversationId: string): Promise { const conv = getConversation(conversationId); if ( !conv?.scheduleJobId || countConversationsByScheduleJobId(conv.scheduleJobId) > 1 ) { return; } // A plugin-declared schedule outlives its run conversations: its lifecycle // belongs to the reconciler, and `deleteSchedule` refuses sourced rows. if (getSchedule(conv.scheduleJobId)?.sourceKey != null) { return; } await deleteSchedule(conv.scheduleJobId); } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- function handleCreateConversation({ body = {}, headers }: RouteHandlerArgs) { const conversationKey = (body.conversationKey as string | undefined) ?? crypto.randomUUID(); // The shared route adapter does not runtime-validate the body against the // Zod requestBody (it's codegen-only), so guard the types here: a malformed // `{ title: 123 }` would otherwise throw on `.trim()` and 500, and an // unsupported conversationType would reach the store unchecked. // `.nullish()`: an explicit JSON `null` is how serializers spell an absent // optional, so it means the same thing as omitting the key. const requestedType = createConversationTypeSchema .nullish() .safeParse(body.conversationType); if (!requestedType.success) { throw new BadRequestError( `conversationType must be one of ${createConversationTypeSchema.options.join(", ")}`, ); } if (body.title !== undefined && typeof body.title !== "string") { throw new BadRequestError("title must be a string"); } const customTitle = body.title?.trim() || undefined; const conversationType = requestedType.data ?? "standard"; const result = getOrCreateConversation(conversationKey, { conversationType, }); if (result.created) { // A caller-supplied title is user-set: persist it with isAutoTitle = 0 so // the async LLM titler's safe-overwrite check leaves it untouched. Without // one, fall back to the neutral "New Conversation" placeholder, which stays // replaceable by the auto-titler once messages arrive. if (customTitle) { updateConversationTitle(result.conversationId, customTitle, 0); } else { updateConversationTitle(result.conversationId, "New Conversation"); } publishConversationListAndMetadataChanged( "created", result.conversationId, headers?.["x-vellum-client-id"]?.trim() || undefined, ); } // On a conversationKey hit the row already exists, so the type it carries // can differ from the requested one. const storedType = normalizeConversationType(result.conversationType); log.info( { conversationId: result.conversationId, conversationKey, conversationType: storedType, created: result.created, }, "Created conversation via POST", ); return { id: result.conversationId, conversationKey, conversationType: storedType, created: result.created, }; } async function handleForkConversation({ body = {}, headers, }: RouteHandlerArgs) { const conversationId = body.conversationId as string | undefined; if (!conversationId || typeof conversationId !== "string") { throw new BadRequestError("Missing conversationId"); } if ( body.throughMessageId !== undefined && typeof body.throughMessageId !== "string" ) { throw new BadRequestError("throughMessageId must be a string"); } const resolvedConversationId = resolveConversationId(conversationId) ?? conversationId; try { const forkedConversation = forkConversationInStore({ conversationId: resolvedConversationId, throughMessageId: body.throughMessageId as string | undefined, }); const detail = buildConversationDetailResponse(forkedConversation.id); if (!detail) { throw new InternalError( `Forked conversation ${forkedConversation.id} could not be loaded`, ); } publishConversationListAndMetadataChanged( "created", forkedConversation.id, headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { conversation: detail.conversation }; } catch (err) { if (err instanceof UserError) { throw new NotFoundError(err.message); } throw err; } } async function handleSummarizeConversation({ body = {} }: RouteHandlerArgs) { const rawConversationId = body.conversationId; if (!rawConversationId || typeof rawConversationId !== "string") { throw new BadRequestError("Missing conversationId"); } const beforeMessageId = body.beforeMessageId; if (!beforeMessageId || typeof beforeMessageId !== "string") { throw new BadRequestError("Missing beforeMessageId"); } const conversationId = resolveConversationId(rawConversationId) ?? rawConversationId; // Gate on DB existence first: `getOrCreateConversationInstance` would // otherwise create a fresh conversation and mask the not-found case. if (!getConversation(conversationId)) { throw new NotFoundError(`Conversation ${rawConversationId} not found`); } const conversation = await getOrCreateConversationInstance(conversationId); // Synchronous check-then-claim (no await between them) so a concurrent // request cannot slip past the busy gate. if (conversation.isProcessing()) { throw new ConflictError( "The assistant is currently responding — try again when it finishes", ); } conversation.setProcessing(true); // Install an abort controller before the long summary call so Stop can // actually cancel it — mirroring the send path (`persistUserMessage`). // Without one, `abortConversation` finds no live controller, force-clears the // processing flag, and lets the summary keep running so it can apply/persist // after cancel and race the next turn. const abortController = new AbortController(); conversation.abortController = abortController; // The summarize action only ships from the vellum web/desktop client; the // interface id is omitted because this management route (unlike the send // path) does not receive one from the client. const channelMeta = buildChannelMetadata("vellum", undefined, { trustContext: conversation.trustContext, }); const persistCard = (text: string) => persistCannedAssistantCard({ conversation, conversationId, text, metadata: channelMeta, }); // Fire-and-forget: return 202 immediately, run summarization async. The // summary LLM call can exceed the client's HTTP timeout on large contexts. // The `context_compacted` SSE event, usage recording, and memory hooks are // all emitted inside the shared compaction write path — only the result // card is the route's responsibility. (async () => { // The paired terminal for the thinking activity emitted below. Clients // that started an indicator from the thinking event need a definitive // idle — the result card's `message_complete` covers the happy path, // but the hard-error path persists no card, so the idle emit in // `finally` is what guarantees the indicator always clears. let terminalReason: | "message_complete" | "error_terminal" | "generation_cancelled" = "message_complete"; try { conversation.emitActivityState("thinking", "context_compacting", { statusText: "Summarizing conversation", }); // The context-window usage push goes out on the same broadcast path as // the result card below, so the two can never be delivered to different // places. const result = await conversation.summarizeUpToMessage( beforeMessageId, broadcastMessage, ); // Stop aborted the in-flight summary: the compactor reports the aborted // provider call as a non-compacted result (it swallows the abort rather // than throwing), so detect cancellation via the signal. A cancelled // summary applies nothing — surface a clean cancellation instead of a // "skipped" card. A summary that already compacted before a late Stop // still shows its card; that work is already persisted. if (!result.compacted && abortController.signal.aborted) { terminalReason = "generation_cancelled"; return; } const cardId = await persistCard(formatSummarizeUpToResult(result)); // Attribute the compaction LLM call to the card it produced, so the // inspector shows it there instead of the unlinked-row recovery // guessing an enclosing turn. if (result.summaryRequestLogId) { linkRequestLogsToMessage([result.summaryRequestLogId], cardId); } } catch (err) { // A Stop that surfaces as a thrown abort (rather than the compactor's // swallowed no-op) is still a clean cancellation, not an error card. if ( isUserCancellation(err, { phase: "handler", aborted: abortController.signal.aborted, }) ) { terminalReason = "generation_cancelled"; return; } // Boundary/mapping UserErrors are expected user-facing outcomes, not // failures: surface them as a skipped card rather than an error event. if (err instanceof UserError) { try { await persistCard(`Summarization skipped — ${err.message}`); return; } catch (cardErr) { err = cardErr; } } terminalReason = "error_terminal"; log.error({ err, conversationId }, "Summarize command failed"); broadcastMessage({ type: "conversation_error", conversationId, code: "UNKNOWN", userMessage: `Summarization failed: ${err instanceof Error ? err.message : String(err)}`, retryable: true, }); } finally { conversation.emitActivityState("idle", terminalReason); // Null our controller before clearing the flag so a racing Stop takes // `abortConversation`'s force-clear branch instead of signalling a dead // controller. Identity-guarded: a newer turn may already own the field. if (conversation.abortController === abortController) { conversation.abortController = null; } conversation.setProcessing(false); void conversation.kickDrainQueue("loop_complete", "summarize_command"); } })(); return { accepted: true as const, conversationId }; } async function handleSwitchConversation({ body = {} }: RouteHandlerArgs) { const conversationId = body.conversationId as string | undefined; if (!conversationId || typeof conversationId !== "string") { throw new BadRequestError("Missing conversationId"); } const result = await switchConversation(conversationId); if (!result) { throw new NotFoundError(`Conversation ${conversationId} not found`); } if (body.conversationKey && typeof body.conversationKey === "string") { setConversationKeyIfAbsent(body.conversationKey, conversationId); } return { conversationId: result.conversationId, title: result.title, conversationType: normalizeConversationType(result.conversationType), ...(result.inferenceProfile != null ? { inferenceProfile: result.inferenceProfile } : {}), }; } async function handleSetInferenceProfile({ pathParams = {}, body = {}, headers, }: RouteHandlerArgs) { if ( body.profile !== null && (typeof body.profile !== "string" || (body.profile as string).length === 0) ) { throw new BadRequestError("profile must be a non-empty string or null"); } const result = await setInferenceProfileSession({ conversationId: pathParams.id!, profile: body.profile as string | null, ttlSeconds: body.ttlSeconds as number | null | undefined, sessionId: body.sessionId as string | undefined, originClientId: headers?.["x-vellum-client-id"]?.trim() || undefined, }); return result; } async function handleUpdateConversationEnabledPlugins({ pathParams = {}, body = {}, headers, }: RouteHandlerArgs) { const enabledPlugins = body.enabledPlugins as string[] | null | undefined; // The field is required; `null` is the explicit "clear the scope" signal. // A missing field (malformed/empty body) must not silently clear the scope. if (enabledPlugins === undefined) { throw new BadRequestError( "enabledPlugins is required (use null to clear the scope)", ); } if ( enabledPlugins !== null && (!Array.isArray(enabledPlugins) || enabledPlugins.some((p) => typeof p !== "string")) ) { throw new BadRequestError( "enabledPlugins must be an array of strings or null", ); } const resolvedId = resolveConversationId(pathParams.id!) ?? pathParams.id!; const conversation = getConversation(resolvedId); if (!conversation) { throw new NotFoundError(`Conversation ${pathParams.id} not found`); } // `null` clears the per-chat scope back to the default (all enabled // plugins); a `string[]` scopes the chat to those plugin ids. const nextEnabledPlugins = enabledPlugins; setConversationEnabledPlugins(resolvedId, nextEnabledPlugins); findConversation(resolvedId)?.setEnabledPlugins(nextEnabledPlugins); publishConversationEnabledPluginsChanged( resolvedId, headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { conversationId: resolvedId, enabledPlugins: nextEnabledPlugins }; } function handleRenameConversation({ pathParams = {}, body = {}, headers, }: RouteHandlerArgs) { const name = body.name as string | undefined; if (!name || typeof name !== "string") { throw new BadRequestError("Missing name"); } const conversation = getConversation(pathParams.id!); if (!conversation) { throw new NotFoundError(`Conversation ${pathParams.id} not found`); } updateConversationTitle(pathParams.id!, name, 0); publishConversationTitleChanged( pathParams.id!, name, headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { ok: true }; } async function handleClearAllConversations({ headers = {} }: RouteHandlerArgs) { const confirm = headers["x-confirm-destructive"]; if (confirm !== "clear-all-conversations") { throw new BadRequestError( "DELETE /v1/conversations permanently deletes ALL conversations, messages, and memory. " + "To confirm, set header X-Confirm-Destructive: clear-all-conversations", ); } await clearAllConversations(); publishConversationListChanged( "deleted", headers["x-vellum-client-id"]?.trim() || undefined, ); return undefined; } async function handleDeleteConversation({ pathParams = {}, headers, }: RouteHandlerArgs) { const resolvedId = resolveOrThrow(pathParams.id!); await cancelScheduleIfLast(resolvedId); // In-memory teardown only: `deleteConversation` drops the durable subagent // rows inside its own transaction. destroyActiveConversation(resolvedId, { keepSubagentRecords: true }); const deleted = deleteConversation(resolvedId); for (const segId of deleted.segmentIds) { enqueueMemoryJob("delete_qdrant_vectors", { targetType: "segment", targetId: segId, }); } for (const summaryId of deleted.deletedSummaryIds) { enqueueMemoryJob("delete_qdrant_vectors", { targetType: "summary", targetId: summaryId, }); } // The lexical-index purge is fired by `deleteConversation` itself (via the // `onConversationDeleted` persistence hook), so every delete caller cleans up // — no route-level purge needed here. log.info({ conversationId: resolvedId }, "Deleted conversation"); publishConversationListAndMetadataChanged( "deleted", resolvedId, headers?.["x-vellum-client-id"]?.trim() || undefined, ); void stripConversationIds(resolvedId); return undefined; } function handleArchiveConversation({ pathParams = {}, headers, }: RouteHandlerArgs) { const resolvedId = resolveOrThrow(pathParams.id!); const archived = archiveConversation(resolvedId); if (!archived) { throw new NotFoundError(`Conversation ${pathParams.id} not found`); } publishConversationListAndMetadataChanged( "reordered", resolvedId, headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { ok: true, conversationId: resolvedId }; } function handleUnarchiveConversation({ pathParams = {}, headers, }: RouteHandlerArgs) { const resolvedId = resolveOrThrow(pathParams.id!); const unarchived = unarchiveConversation(resolvedId); if (!unarchived) { throw new NotFoundError(`Conversation ${pathParams.id} not found`); } publishConversationListAndMetadataChanged( "reordered", resolvedId, headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { ok: true, conversationId: resolvedId }; } function handleArchiveConversationsBulk({ body = {}, headers, }: RouteHandlerArgs) { const rawIds = body.conversationIds as string[] | undefined; if (!Array.isArray(rawIds) || rawIds.length === 0) { throw new BadRequestError("conversationIds must be a non-empty array"); } const originClientId = headers?.["x-vellum-client-id"]?.trim() || undefined; const archivedIds: string[] = []; for (const rawId of rawIds) { try { const conversationId = resolveOrThrow(rawId); const archived = archiveConversation(conversationId); if (archived) { archivedIds.push(conversationId); } } catch (err) { log.error( { err, conversationId: rawId }, "POST /v1/conversations/archive/bulk: failed for conversation", ); // Best-effort: continue with remaining conversations. } } if (archivedIds.length > 0) { publishConversationListChanged("reordered", originClientId); } return { ok: true, archived: archivedIds.length }; } /** * Set or clear the `surfacedAt` promotion marker on a conversation. * * Surfacing is the explicit opt-in that makes a background/scheduled * conversation appear in the default conversation listing (and therefore the * Recents sidebar grouping). It is never set automatically — product flows * call this when a background conversation deserves foreground visibility * (e.g. the user sent a follow-up message in it). */ function handleSurfaceConversation({ pathParams = {}, body = {}, headers, }: RouteHandlerArgs) { if (typeof body.surfaced !== "boolean") { throw new BadRequestError("Missing surfaced boolean"); } const resolvedId = resolveOrThrow(pathParams.id!); const result = setConversationSurfaced(resolvedId, body.surfaced); if (!result) { throw new NotFoundError(`Conversation ${pathParams.id} not found`); } // Shape-changing for the default list (row appears in / disappears from // the standard listing), so publish with a shape-changing reason — web // refetches the paginated list, macOS gets the legacy typed broadcast. publishConversationListAndMetadataChanged( "reordered", resolvedId, headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { ok: true, conversationId: resolvedId, surfacedAt: result.surfacedAt, }; } function handleCancelGeneration({ pathParams = {} }: RouteHandlerArgs) { const resolvedId = resolveConversationId(pathParams.id!) ?? pathParams.id!; const cancelled = cancelGeneration(resolvedId); return { ok: true, cancelled, conversationId: resolvedId }; } async function handleUndoLastMessage({ pathParams = {} }: RouteHandlerArgs) { const result = await undoLastMessage(pathParams.id!); if (!result) { throw new NotFoundError(`No active conversation for ${pathParams.id}`); } return { removedCount: result.removedCount, conversationId: pathParams.id!, }; } /** * Retry the last assistant turn: permanently discard the latest assistant * display turn, keep its anchoring user message, and re-run generation from * that message. Accepted with 202; the regenerated turn streams over SSE * exactly like a normal send (`assistant_activity_state` thinking → * text/tool deltas → `message_complete`), and the discarded rows reach other * clients through the `conversation::messages` sync invalidation. */ async function handleRetryLastAssistantTurn({ pathParams = {}, headers, }: RouteHandlerArgs) { const rawId = pathParams.id!; const conversationId = resolveConversationId(rawId) ?? rawId; // Gate on DB existence first: `getOrCreateConversationInstance` would // otherwise create a fresh conversation and mask the not-found case. if (!getConversation(conversationId)) { throw new NotFoundError(`Conversation ${rawId} not found`); } const conversation = await getOrCreateConversationInstance(conversationId); touchConversation(conversationId); // Resolved before the busy gate so the awaits stay outside the // check-then-claim window below, but bound only once the claim succeeds: a // retry that loses the race is rejected, and a rejected request must not // repoint the conversation's actor at its requester. const actorPrincipalId = headers?.["x-vellum-actor-principal-id"]; const retryTrustContext = await resolveVellumActorTrustContext( actorPrincipalId, { healResetDrift: true }, ); const retrySourceActorPrincipalId = await resolveActorPrincipalIdForLocalGuardian(actorPrincipalId); // Synchronous check-then-claim (no await between them) so a concurrent // request cannot slip past the busy gate. Everything through the tail // deletion below is synchronous, so the claim can't be raced. if (conversation.isProcessing()) { throw new ConflictError( "The assistant is currently responding — try again when it finishes", ); } conversation.setProcessing(true); // Bind the requesting actor's trust for the turn we just claimed. A freshly // hydrated conversation has no trust context, and `loadFromDb` under an // unset context filters guardian-provenance history — the re-run would see // an empty transcript. conversation.setTrustContext(retryTrustContext); conversation.currentTurnSourceActorPrincipalId = retrySourceActorPrincipalId; const requestId = uuidv7(); const abortController = new AbortController(); let discarded: ReturnType; try { conversation.currentRequestId = requestId; // runAgentLoop requires a live controller; it is also what Stop signals. conversation.abortController = abortController; discarded = discardLastAssistantDisplayTurn(conversationId); if (!discarded) { throw new UnprocessableEntityError("No user message to retry from"); } } catch (err) { if (conversation.abortController === abortController) { conversation.abortController = null; } conversation.currentRequestId = undefined; conversation.setProcessing(false); throw err; } if (discarded.deletedMessageIds.length > 0) { // Published without an origin client id: the initiating client must // reconcile too — it discovers the discarded tail the same way other // clients do. publishConversationMessagesChanged(conversationId); } const anchor = discarded.anchor; const anchorMetadata = anchor.metadata ? safeParseRecord(anchor.metadata) : undefined; // A machine-signal anchor (wake trigger, subagent/ACP notification, hidden // send) re-runs as a hidden turn so it stays out of the titler, mirroring // how the original turn ran. const isHiddenPrompt = isEchoSuppressedUserMessage(anchorMetadata); // Reproduce the anchor's original permission mode so the re-run keeps the // same approval semantics: a background-event wake records the interactivity // it ran under (`backgroundEventInteractive`; see persistWakeTriggerMessage). // Legacy anchors written before the field existed fall back to non-interactive // — the conservative choice that keeps a retried scheduled turn from stalling // on approvals the original never asked for. const recordedInteractive = anchorMetadata?.backgroundEventInteractive; const isInteractive = typeof recordedInteractive === "boolean" ? recordedInteractive : !isBackgroundEventMetadata(anchorMetadata); const contentText = extractUserPromptText(anchor.content); // Fire-and-forget: return 202 immediately, stream the re-run over SSE. The // loop's own teardown clears processing, publishes the metadata sync // invalidation, and drains anything queued during the turn. (async () => { try { conversation.emitActivityState("thinking", "message_dequeued", { requestId, }); // Unconditional resync of the in-memory history to the truncated DB // state. `loadFromDb`'s tail-row rehydration leaves the anchor in // exactly the "next turn re-injects fresh" shape the loop expects. await conversation.loadFromDb(); await conversation.runAgentLoop(contentText, anchor.id, { onEvent: broadcastMessage, isUserMessage: true, isInteractive, // The re-run carries none of the anchor's original delivery // orchestration: a channel anchor's reply is not posted back to // Slack/Telegram (that is owned by the inbound event's // `finalizeEventDelivery`) and a voice anchor's is not spoken over a // session. The regenerated reply reaches SSE subscribers only, so the // push is the user's only copy once they leave. replyDeliveredInAppOnly: true, ...(isHiddenPrompt ? { isHiddenPrompt: true } : {}), }); } catch (err) { log.error({ err, conversationId }, "Retry turn failed"); broadcastMessage({ type: "conversation_error", conversationId, code: "UNKNOWN", userMessage: `Retry failed: ${err instanceof Error ? err.message : String(err)}`, retryable: true, }); // The loop's finally owns teardown once it starts; this guard covers // a throw before that claim (e.g. loadFromDb). Identity-guarded: a // newer turn may already own the controller field. if (conversation.abortController === abortController) { conversation.abortController = null; conversation.setProcessing(false); conversation.emitActivityState("idle", "error_terminal", { requestId, }); } } })(); return { accepted: true as const, conversationId, userMessageId: anchor.id, discardedCount: discarded.deletedMessageIds.length, }; } async function handleResolveMetaSlashCommand({ pathParams = {}, body = {}, }: RouteHandlerArgs) { const command = body.command as string | undefined; if (!command || typeof command !== "string") { throw new BadRequestError("Missing command"); } let result: Awaited>; try { result = await resolveMetaSlashCommand(pathParams.id!, command); } catch (err) { if (err instanceof UserError) { throw new BadRequestError(err.message); } throw err; } if (!result) { throw new NotFoundError(`No conversation for ${pathParams.id}`); } return result; } function handleReorderConversations({ body = {}, headers }: RouteHandlerArgs) { const updates = body.updates as | Array<{ conversationId: string; isPinned?: boolean; groupId?: string | null; }> | undefined; if (!Array.isArray(updates)) { throw new BadRequestError("Missing updates array"); } batchSetConversationPlacement( updates.map((u) => ({ id: u.conversationId, isPinned: u.isPinned, groupId: u.groupId, })), ); publishConversationListAndMetadataChanged( "reordered", updates.map((u) => u.conversationId), headers?.["x-vellum-client-id"]?.trim() || undefined, ); return { ok: true }; } // --------------------------------------------------------------------------- // Transport-agnostic route definitions // --------------------------------------------------------------------------- export const ROUTES: RouteDefinition[] = [ { operationId: "createConversation", endpoint: "conversations", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Create a conversation", description: "Create or get an existing conversation by key.", tags: ["conversations"], requestBody: z.object({ conversationKey: z .string() .optional() .describe( "Optional external key. Echoed back in the response. Non-vellum channels (Telegram, WhatsApp) use this to scope to a logical channel thread; vellum-web clients can omit it and rely on the assistant-minted `id`.", ), conversationType: createConversationTypeSchema .optional() .describe( 'Conversation type for the new row. "background" keeps it out of the foreground list and the sidebar\'s Recents grouping, used by internal side-channel flows (onboarding research, persona/identity rewrites) that mint a throwaway thread the user should never see. "scheduled" is not accepted here; scheduled rows are owned by the schedule pipeline.', ), title: z .string() .optional() .describe( "Explicit title for the conversation. When provided on creation, it is persisted as a user-set title (never overwritten by the auto-titler). Used by flows that mint a conversation up-front and don't want an auto-generated title.", ), }), responseBody: z.object({ id: z .string() .describe( "Assistant-minted internal conversation id. The authoritative identifier for the conversation.", ), conversationKey: z .string() .describe("Echo of the optional external key supplied by the client."), conversationType: z.string(), created: z.boolean(), }), handler: handleCreateConversation, }, { operationId: "forkConversation", endpoint: "conversations/fork", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Fork a conversation", description: "Create a copy of a conversation, optionally truncated at a specific message.", tags: ["conversations"], requestBody: z.object({ conversationId: z.string(), throughMessageId: z .string() .describe("Truncate the fork at this message") .optional(), }), responseBody: z.object({ conversation: conversationSummarySchema, }), handler: handleForkConversation, }, { operationId: "summarizeConversation", endpoint: "conversations/summarize", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Summarize a conversation up to a message", description: "Replace the conversation's context before the given message with a generated summary. " + "The boundary snaps to the start of the turn containing beforeMessageId; that turn and " + "everything after stay verbatim. Messages are never deleted.", tags: ["conversations"], requestBody: z.object({ conversationId: z.string(), beforeMessageId: z .string() .describe("Summarize all messages before this one"), }), responseBody: z.object({ accepted: z.literal(true), conversationId: z.string(), }), responseStatus: "202", handler: handleSummarizeConversation, }, { operationId: "switchConversation", endpoint: "conversations/switch", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Switch active conversation", description: "Set the active conversation for the current session.", tags: ["conversations"], requestBody: z.object({ conversationId: z.string(), conversationKey: z .string() .describe("Optional key to register for this conversation") .optional(), }), responseBody: z.object({ conversationId: z.string(), title: z.string(), conversationType: z.string(), inferenceProfile: z.string().optional(), }), handler: handleSwitchConversation, }, { operationId: "setConversationInferenceProfile", endpoint: "conversations/:id/inference-profile", method: "PUT", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Set conversation inference profile", description: "Override the LLM inference profile for a single conversation. " + "Optionally supply ttlSeconds to create a session-backed (expiring) override.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], requestBody: z.object({ profile: z.string().nullable(), ttlSeconds: z.number().positive().nullable().optional(), sessionId: z.string().uuid().optional(), }), responseBody: z.object({ conversationId: z.string(), profile: z.string().nullable(), sessionId: z.string().nullable(), expiresAt: z.number().nullable(), ttlSeconds: z.number().nullable().optional(), replaced: z .object({ profile: z.string().nullable(), sessionId: z.string().nullable(), expiresAt: z.number().nullable(), }) .nullable(), }), handler: handleSetInferenceProfile, }, { operationId: "conversations_by_id_enabledplugins_put", endpoint: "conversations/:id/enabledplugins", method: "PUT", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Set conversation enabled plugins", description: "Scope a single conversation to a subset of installed plugins " + "(first-party defaults are always available). Pass null to clear the " + "scope back to the default (all enabled plugins).", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], requestBody: z.object({ enabledPlugins: z.array(z.string()).nullable(), }), responseBody: z.object({ conversationId: z.string(), enabledPlugins: z.array(z.string()).nullable(), }), handler: handleUpdateConversationEnabledPlugins, }, { operationId: "renameConversation", endpoint: "conversations/:id/name", method: "PATCH", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Rename a conversation", description: "Update the display name of a conversation.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], requestBody: z.object({ name: z.string(), }), responseBody: z.object({ ok: z.boolean() }), handler: handleRenameConversation, }, { operationId: "clearAllConversations", endpoint: "conversations", method: "DELETE", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Clear all conversations", description: "Permanently delete ALL conversations, messages, and memory.", tags: ["conversations"], responseStatus: "204", handler: handleClearAllConversations, }, { operationId: "deleteConversation", endpoint: "conversations/:id", method: "DELETE", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Delete a conversation", description: "Permanently delete a single conversation and its messages.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], responseStatus: "204", handler: handleDeleteConversation, }, { operationId: "archiveConversation", endpoint: "conversations/:id/archive", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Archive a conversation", description: "Move a conversation to the archived state.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], responseBody: z.object({ ok: z.boolean(), conversationId: z.string(), }), handler: handleArchiveConversation, }, { operationId: "unarchiveConversation", endpoint: "conversations/:id/unarchive", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Unarchive a conversation", description: "Restore an archived conversation back to the default sidebar.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], responseBody: z.object({ ok: z.boolean(), conversationId: z.string(), }), handler: handleUnarchiveConversation, }, { operationId: "archiveConversationsBulk", endpoint: "conversations/archive/bulk", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Bulk archive conversations", description: "Archive multiple conversations in one request. Emits a single sync invalidation for the entire batch.", tags: ["conversations"], requestBody: z.object({ conversationIds: z.array(z.string()).min(1), }), responseBody: z.object({ ok: z.boolean(), archived: z.number() }), handler: handleArchiveConversationsBulk, }, { operationId: "surfaceConversation", endpoint: "conversations/:id/surface", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Surface a conversation into Recents", description: "Explicitly promote a background or scheduled conversation into the " + "default conversation listing (the Recents sidebar grouping), or demote " + "it with surfaced=false. Conversations are never surfaced automatically.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], requestBody: z.object({ surfaced: z .boolean() .describe( "true to surface the conversation into Recents, false to clear the promotion.", ), }), responseBody: z.object({ ok: z.boolean(), conversationId: z.string(), surfacedAt: z .number() .nullable() .describe("Epoch-ms timestamp of the promotion, or null when cleared."), }), handler: handleSurfaceConversation, }, { operationId: "cancelConversationGeneration", endpoint: "conversations/:id/cancel", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Cancel generation", description: "Abort the in-progress assistant response for a conversation.", tags: ["conversations"], pathParams: [{ name: "id" }], responseStatus: "202", responseBody: z.object({ ok: z.boolean(), cancelled: z.boolean(), conversationId: z.string(), }), handler: handleCancelGeneration, }, { operationId: "undoLastMessage", endpoint: "conversations/:id/undo", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Undo last message", description: "Remove the most recent user+assistant message pair from the conversation.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], responseBody: z.object({ removedCount: z.number().int(), conversationId: z.string(), }), handler: handleUndoLastMessage, }, { operationId: "retryLastAssistantTurn", endpoint: "conversations/:id/retry", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Retry the last assistant turn", description: "Permanently discard the most recent assistant display turn (the assistant rows and " + "tool-result rows after the last turn-starting user message), keep that user message, " + "and re-run generation from it. Accepted immediately; the regenerated turn streams " + "over SSE like a normal send.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], responseBody: z.object({ accepted: z.literal(true), conversationId: z.string(), userMessageId: z .string() .describe("The user message the turn is re-running from"), discardedCount: z .number() .int() .describe("Number of message rows discarded from the tail"), }), responseStatus: "202", additionalResponses: { "404": { description: "Conversation not found" }, "409": { description: "The assistant is currently responding" }, "422": { description: "No user message exists to retry from" }, }, handler: handleRetryLastAssistantTurn, }, { operationId: "resolveConversationSlashCommand", endpoint: "conversations/:id/slash", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Resolve a local meta slash command", description: "Run a local meta slash command (/clean, /status, /commands, /models) " + "without starting a turn: no messages are persisted and no turn events " + "are emitted. /clean also strips runtime injections from the history. " + "Returns the text to render and, for /clean, the post-strip context usage.", tags: ["conversations"], pathParams: [{ name: "id", type: "uuid" }], requestBody: z.object({ command: z .string() .describe("The slash command text, e.g. `/clean` or `/status`."), }), responseBody: z.object({ kind: z.enum(["clean", "info"]), text: z.string(), contextUsage: z .object({ tokens: z.number(), maxTokens: z.number().nullable(), fillRatio: z.number().nullable(), }) .optional(), }), handler: handleResolveMetaSlashCommand, }, { operationId: "reorderConversations", endpoint: "conversations/reorder", method: "POST", policy: { requiredScopes: ["chat.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Move conversations between sections", description: "Batch-update which group holds each conversation, and its pin state. " + "Lists are ordered by recency, so this sets placement, not order.", tags: ["conversations"], requestBody: z.object({ updates: z.array( z.object({ conversationId: z.string(), isPinned: z.boolean().optional(), groupId: z.string().nullable().optional(), }), ), }), responseBody: z.object({ ok: z.boolean() }), handler: handleReorderConversations, }, ];