Files
simstudioai--sim/apps/sim/lib/copilot/chat/post.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1142 lines
39 KiB
TypeScript

import { type Context as OtelContext, context as otelContextApi } from '@opentelemetry/api'
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { isZodError, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload'
import {
buildPersistedAssistantMessage,
buildPersistedUserMessage,
withStoppedContentBlock,
} from '@/lib/copilot/chat/persisted-message'
import {
processContextsServer,
resolveActiveResourceContext,
} from '@/lib/copilot/chat/process-contents'
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants'
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
import {
CopilotChatFinalizeOutcome,
CopilotChatPersistOutcome,
CopilotTransport,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/copilot/request/http'
import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start'
import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/copilot/request/otel'
import {
acquirePendingChatStream,
getPendingChatStreamId,
releasePendingChatStream,
} from '@/lib/copilot/request/session'
import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types'
import { persistChatResources } from '@/lib/copilot/resources/persistence'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
import {
getUserEntityPermissions,
isWorkspaceAccessDeniedError,
type PermissionType,
} from '@/lib/workspaces/permissions/utils'
import type { ChatContext } from '@/stores/panel'
export const maxDuration = 3600
const logger = createLogger('UnifiedChatAPI')
const DEFAULT_MODEL = 'claude-opus-4-8'
const FileAttachmentSchema = z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
path: z.string().optional(),
})
const ResourceAttachmentSchema = z.object({
type: z.enum([
'workflow',
'table',
'file',
'knowledgebase',
'folder',
'filefolder',
'task',
'log',
'scheduledtask',
'generic',
]),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
})
const GENERIC_RESOURCE_TITLE: Record<z.infer<typeof ResourceAttachmentSchema>['type'], string> = {
workflow: 'Workflow',
table: 'Table',
file: 'File',
knowledgebase: 'Knowledge Base',
folder: 'Folder',
filefolder: 'File Folder',
task: 'Task',
log: 'Log',
scheduledtask: 'Scheduled Task',
generic: 'Resource',
}
const ChatContextSchema = z.object({
kind: z.enum([
'past_chat',
'workflow',
'current_workflow',
'blocks',
'logs',
'workflow_block',
'knowledge',
'docs',
'table',
'file',
'folder',
'filefolder',
'scheduledtask',
'integration',
'skill',
]),
label: z.string(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
knowledgeId: z.string().optional(),
blockId: z.string().optional(),
blockIds: z.array(z.string()).optional(),
executionId: z.string().optional(),
tableId: z.string().optional(),
fileId: z.string().optional(),
folderId: z.string().optional(),
fileFolderId: z.string().optional(),
skillId: z.string().optional(),
scheduleId: z.string().optional(),
})
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
workflowName: z.string().optional(),
model: z.string().optional().default(DEFAULT_MODEL),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
resourceAttachments: z.array(ResourceAttachmentSchema).optional(),
provider: z.string().optional(),
contexts: z.array(ChatContextSchema).optional(),
commands: z.array(z.string()).optional(),
userTimezone: z.string().optional(),
})
type UnifiedChatRequest = z.infer<typeof ChatMessageSchema>
type UnifiedChatBranch =
| {
kind: 'workflow'
workflowId: string
workflowName?: string
workspaceId?: string
effectiveModel: string
selectedModel: string
mode: UnifiedChatRequest['mode']
provider?: string
goRoute: '/api/copilot'
titleModel: string
titleProvider?: string
notifyWorkspaceStatus: false
buildPayload: (params: {
message: string
userId: string
userMessageId: string
chatId?: string
contexts: Array<{ type: string; content: string; tag?: string; path?: string }>
fileAttachments?: UnifiedChatRequest['fileAttachments']
userPermission?: string
entitlements?: string[]
userTimezone?: string
userMetadata?: { name?: string; email?: string; timezone?: string }
workflowId: string
workflowName?: string
workspaceId?: string
mode: UnifiedChatRequest['mode']
provider?: string
commands?: string[]
prefetch?: boolean
implicitFeedback?: string
workspaceContext?: string
vfs?: VfsSnapshotV1
}) => Promise<Record<string, unknown>>
buildExecutionContext: (params: {
userId: string
chatId?: string
userTimezone?: string
messageId: string
}) => Promise<ExecutionContext>
}
| {
kind: 'workspace'
workspaceId: string
workspacePermission: PermissionType | null
effectiveModel: string
goRoute: '/api/mothership'
titleModel: string
titleProvider?: undefined
notifyWorkspaceStatus: true
buildPayload: (params: {
message: string
userId: string
userMessageId: string
chatId?: string
contexts: Array<{ type: string; content: string; tag?: string; path?: string }>
fileAttachments?: UnifiedChatRequest['fileAttachments']
userPermission?: string
entitlements?: string[]
userTimezone?: string
userMetadata?: { name?: string; email?: string; timezone?: string }
workspaceContext?: string
vfs?: VfsSnapshotV1
}) => Promise<Record<string, unknown>>
buildExecutionContext: (params: {
userId: string
chatId?: string
userTimezone?: string
messageId: string
}) => Promise<ExecutionContext>
}
function normalizeContexts(contexts: UnifiedChatRequest['contexts']) {
if (!Array.isArray(contexts)) {
return contexts
}
return contexts.map((ctx) => {
if (ctx.kind !== 'blocks') return ctx
if (Array.isArray(ctx.blockIds) && ctx.blockIds.length > 0) return ctx
if (ctx.blockId) return { ...ctx, blockIds: [ctx.blockId] }
return ctx
})
}
async function resolveAgentContexts(params: {
contexts?: UnifiedChatRequest['contexts']
resourceAttachments?: UnifiedChatRequest['resourceAttachments']
userId: string
message: string
workspaceId?: string
chatId?: string
requestId: string
}): Promise<Array<{ type: string; content: string; tag?: string; path?: string }>> {
const { contexts, resourceAttachments, userId, message, workspaceId, chatId, requestId } = params
let agentContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = []
if (Array.isArray(contexts) && contexts.length > 0) {
try {
agentContexts = await processContextsServer(
contexts as ChatContext[],
userId,
message,
workspaceId,
chatId
)
} catch (error) {
logger.error(`[${requestId}] Failed to process contexts`, error)
}
}
if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0 && workspaceId) {
const results = await Promise.allSettled(
resourceAttachments.map(async (resource) => {
const ctx = await resolveActiveResourceContext(
resource.type,
resource.id,
workspaceId,
userId,
chatId
)
if (!ctx) return null
return { ...ctx, tag: resource.active ? '@active_tab' : '@open_tab' }
})
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(`[${requestId}] Failed to resolve resource attachment`, result.reason)
}
}
}
return agentContexts
}
async function persistUserMessage(params: {
chatId?: string
userMessageId: string
message: string
fileAttachments?: UnifiedChatRequest['fileAttachments']
contexts?: UnifiedChatRequest['contexts']
workspaceId?: string
notifyWorkspaceStatus: boolean
/**
* Root context for the mothership request. When present the persist
* span is created explicitly under it, which avoids relying on
* AsyncLocalStorage propagation — some upstream awaits (Next.js
* framework frames, Turbopack-instrumented I/O) can swap the active
* store out from under us in dev, which would otherwise leave this
* span parented to the about-to-be-dropped Next.js HTTP span.
*/
parentOtelContext?: OtelContext
}): Promise<void> {
const {
chatId,
userMessageId,
message,
fileAttachments,
contexts,
workspaceId,
notifyWorkspaceStatus,
parentOtelContext,
} = params
if (!chatId) return
return withCopilotSpan(
TraceSpan.CopilotChatPersistUserMessage,
{
[TraceAttr.DbSystem]: 'postgresql',
[TraceAttr.DbSqlTable]: 'copilot_chats',
[TraceAttr.ChatId]: chatId,
[TraceAttr.ChatUserMessageId]: userMessageId,
[TraceAttr.ChatMessageBytes]: message.length,
[TraceAttr.ChatFileAttachmentCount]: fileAttachments?.length ?? 0,
[TraceAttr.ChatContextCount]: contexts?.length ?? 0,
...(workspaceId ? { [TraceAttr.WorkspaceId]: workspaceId } : {}),
},
async (span) => {
const userMsg = buildPersistedUserMessage({
id: userMessageId,
content: message,
fileAttachments,
contexts,
})
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(copilotChats)
.set({
conversationId: userMessageId,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, chatId))
.returning({ model: copilotChats.model })
if (!row) return null
await appendCopilotChatMessages(
chatId,
[userMsg],
{ streamId: userMessageId, chatModel: row.model ?? null },
tx
)
return row
})
span.setAttribute(
TraceAttr.ChatPersistOutcome,
updated ? CopilotChatPersistOutcome.Appended : CopilotChatPersistOutcome.ChatNotFound
)
if (notifyWorkspaceStatus && updated && workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'started',
streamId: userMessageId,
})
}
},
parentOtelContext
)
}
async function buildInitialExecutionContext(params: {
userId: string
workflowId?: string
workspaceId?: string
chatId?: string
messageId: string
userTimezone?: string
requestMode: string
}): Promise<ExecutionContext> {
const { userId, workflowId, workspaceId, chatId, messageId, userTimezone, requestMode } = params
if (workflowId && !workspaceId) {
const context = await prepareExecutionContext(userId, workflowId, chatId)
return {
...context,
messageId,
userTimezone,
requestMode,
copilotToolExecution: true,
}
}
const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId)
return {
userId,
workflowId: workflowId ?? '',
workspaceId,
chatId,
decryptedEnvVars,
messageId,
userTimezone,
requestMode,
copilotToolExecution: true,
}
}
function buildOnComplete(params: {
chatId?: string
userMessageId: string
requestId: string
workspaceId?: string
notifyWorkspaceStatus: boolean
/**
* Root agent span for this request. When present, the final
* assistant message + invoked tool calls are recorded as
* `gen_ai.output.messages` on it before persistence runs. Keeps
* the Honeycomb Gen AI view complete across both the Sim root
* span and the Go-side `llm.stream` spans.
*/
otelRoot?: {
setOutputMessages: (output: {
assistantText?: string
toolCalls?: Array<{ id: string; name: string; arguments?: Record<string, unknown> }>
}) => void
}
}) {
const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus, otelRoot } = params
return async (result: OrchestratorResult) => {
if (otelRoot && result.success) {
otelRoot.setOutputMessages({
assistantText: result.content,
toolCalls: result.toolCalls?.map((tc) => ({
id: tc.id,
name: tc.name,
arguments: tc.params,
})),
})
}
if (!chatId) return
try {
if (result.cancelled) {
const finalization = await finalizeAssistantTurn({
chatId,
userMessageId,
assistantMessage: withStoppedContentBlock(
buildPersistedAssistantMessage(result, requestId)
),
streamMarkerPolicy: 'active-or-cleared',
})
const shouldPublishCompletion =
finalization.updated ||
finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted
if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) {
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'completed',
streamId: userMessageId,
})
}
return
}
// On a non-success terminal (e.g. a transient provider error like
// "overloaded"), persist whatever streamed before the failure — same as
// the cancelled path — instead of dropping the partial assistant output.
const assistantMessage = buildPersistedAssistantMessage(result, requestId)
const hasPartial =
!!assistantMessage.content?.trim() || (assistantMessage.contentBlocks?.length ?? 0) > 0
await finalizeAssistantTurn({
chatId,
userMessageId,
...(result.success || hasPartial ? { assistantMessage } : {}),
// Match the cancelled path so the partial still persists if onError
// raced ahead and already cleared the stream marker.
...(result.success ? {} : { streamMarkerPolicy: 'active-or-cleared' as const }),
})
if (notifyWorkspaceStatus && workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'completed',
streamId: userMessageId,
})
}
} catch (error) {
logger.error(`[${requestId}] Failed to persist chat messages`, {
chatId,
error: getErrorMessage(error, 'Unknown error'),
})
}
}
}
function buildOnError(params: {
chatId?: string
userMessageId: string
requestId: string
workspaceId?: string
notifyWorkspaceStatus: boolean
}) {
const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus } = params
return async (_error: Error, result?: OrchestratorResult) => {
if (!chatId) return
try {
// Persist whatever streamed before a thrown backend error, mirroring the
// cancelled / non-success completion path, so the partial assistant turn
// (text + tool calls + subagent work) survives the refetch instead of the
// chat collapsing to an empty assistant row.
const assistantMessage = result
? buildPersistedAssistantMessage(result, requestId)
: undefined
const hasPartial =
!!assistantMessage?.content?.trim() || (assistantMessage?.contentBlocks?.length ?? 0) > 0
await finalizeAssistantTurn({
chatId,
userMessageId,
...(hasPartial ? { assistantMessage } : {}),
streamMarkerPolicy: 'active-or-cleared',
})
if (notifyWorkspaceStatus && workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId,
chatId,
type: 'completed',
streamId: userMessageId,
})
}
} catch (error) {
logger.error(`[${requestId}] Failed to finalize errored chat stream`, {
chatId,
error: getErrorMessage(error, 'Unknown error'),
})
}
}
}
async function resolveBranch(params: {
authenticatedUserId: string
workflowId?: string
workflowName?: string
workspaceId?: string
model?: string
mode?: UnifiedChatRequest['mode']
provider?: string
}): Promise<UnifiedChatBranch | NextResponse> {
const {
authenticatedUserId,
workflowId: providedWorkflowId,
workflowName,
workspaceId: requestedWorkspaceId,
model,
mode,
provider,
} = params
if (providedWorkflowId || workflowName) {
const resolved = await resolveWorkflowIdForUser(
authenticatedUserId,
providedWorkflowId,
workflowName,
requestedWorkspaceId
)
if (resolved.status !== 'resolved') {
return createBadRequestResponse(resolved.message)
}
const resolvedWorkflowId = resolved.workflowId
const resolvedWorkspaceId = resolved.workspaceId
const selectedModel = model || DEFAULT_MODEL
return {
kind: 'workflow',
workflowId: resolvedWorkflowId,
workflowName: resolved.workflowName,
workspaceId: resolvedWorkspaceId,
effectiveModel: selectedModel,
selectedModel,
mode: mode ?? 'agent',
provider,
goRoute: '/api/copilot',
titleModel: selectedModel,
titleProvider: provider,
notifyWorkspaceStatus: false,
buildPayload: async (payloadParams) =>
buildCopilotRequestPayload(
{
message: payloadParams.message,
workflowId: payloadParams.workflowId,
workflowName: payloadParams.workflowName,
workspaceId: payloadParams.workspaceId,
userId: payloadParams.userId,
userMessageId: payloadParams.userMessageId,
mode: payloadParams.mode ?? 'agent',
model: selectedModel,
provider: payloadParams.provider,
contexts: payloadParams.contexts,
fileAttachments: payloadParams.fileAttachments,
commands: payloadParams.commands,
chatId: payloadParams.chatId,
prefetch: payloadParams.prefetch,
implicitFeedback: payloadParams.implicitFeedback,
workspaceContext: payloadParams.workspaceContext,
vfs: payloadParams.vfs,
userPermission: payloadParams.userPermission,
entitlements: payloadParams.entitlements,
userTimezone: payloadParams.userTimezone,
userMetadata: payloadParams.userMetadata,
},
{ selectedModel }
),
buildExecutionContext: async ({ userId, chatId, userTimezone, messageId }) =>
buildInitialExecutionContext({
userId,
workflowId: resolvedWorkflowId,
workspaceId: resolvedWorkspaceId,
chatId,
messageId,
userTimezone,
requestMode: mode ?? 'agent',
}),
}
}
if (!requestedWorkspaceId) {
return createBadRequestResponse('workspaceId is required when workflowId is not provided')
}
const workspacePermission = await getUserEntityPermissions(
authenticatedUserId,
'workspace',
requestedWorkspaceId
)
if (workspacePermission === null) {
return createBadRequestResponse('Workspace not found or access denied')
}
return {
kind: 'workspace',
workspaceId: requestedWorkspaceId,
workspacePermission,
effectiveModel: DEFAULT_MODEL,
goRoute: '/api/mothership',
titleModel: DEFAULT_MODEL,
notifyWorkspaceStatus: true,
buildPayload: async (payloadParams) =>
buildCopilotRequestPayload(
{
message: payloadParams.message,
workspaceId: requestedWorkspaceId,
userId: payloadParams.userId,
userMessageId: payloadParams.userMessageId,
mode: 'agent',
model: '',
contexts: payloadParams.contexts,
fileAttachments: payloadParams.fileAttachments,
chatId: payloadParams.chatId,
workspaceContext: payloadParams.workspaceContext,
vfs: payloadParams.vfs,
userPermission: payloadParams.userPermission,
entitlements: payloadParams.entitlements,
userTimezone: payloadParams.userTimezone,
userMetadata: payloadParams.userMetadata,
includeMothershipTools: true,
},
{ selectedModel: '' }
),
buildExecutionContext: async ({ userId, chatId, userTimezone, messageId }) =>
buildInitialExecutionContext({
userId,
workspaceId: requestedWorkspaceId,
chatId,
messageId,
userTimezone,
requestMode: 'agent',
}),
}
}
export async function handleUnifiedChatPost(req: NextRequest) {
let actualChatId: string | undefined
let userMessageId = ''
let chatStreamLockAcquired = false
// Started once we've parsed the body (need userMessageId to stamp as
// streamId). Every subsequent span (persistUserMessage,
// createRunSegment, the whole SSE stream, etc.) nests under this
// root via AsyncLocalStorage / explicit propagation, and the stream's
// terminal code path calls finish() when the request actually ends.
// Errors thrown from the handler before the stream starts are
// finished here in the catch below.
let otelRoot: ReturnType<typeof startCopilotOtelRoot> | undefined
// Canonical logical ID; assigned from otelRoot.requestId (the OTel
// trace ID) as soon as startCopilotOtelRoot runs. Empty only in the
// narrow pre-otelRoot window where errors don't correlate anyway.
let requestId = ''
const executionId = generateId()
const runId = generateId()
try {
const session = await getSession()
if (!session?.user?.id) {
return createUnauthorizedResponse()
}
const authenticatedUserId = session.user.id
const authenticatedUserEmail = session.user.email
const authenticatedUserName =
typeof session.user.name === 'string' ? session.user.name : undefined
const body = ChatMessageSchema.parse(await req.json())
const userMetadata = {
...(authenticatedUserName ? { name: authenticatedUserName } : {}),
...(authenticatedUserEmail ? { email: authenticatedUserEmail } : {}),
...(body.userTimezone ? { timezone: body.userTimezone } : {}),
}
const normalizedContexts = normalizeContexts(body.contexts) ?? []
userMessageId = body.userMessageId || generateId()
otelRoot = startCopilotOtelRoot({
streamId: userMessageId,
executionId,
runId,
transport: CopilotTransport.Stream,
userMessagePreview: body.message,
})
if (otelRoot.requestId) {
requestId = otelRoot.requestId
}
// Identity stamp — Go already stamps `user.id` on spans from the
// validated API-key path, but Sim is the only side of the wire
// that knows the human-facing email. Stamping both on the Sim
// root (so they show up on `rootAttrs` in Tempo search) saves
// the "turn user.id into a real person" round-trip to the DB
// for every ad-hoc investigation.
otelRoot.span.setAttribute(TraceAttr.UserId, authenticatedUserId)
if (authenticatedUserEmail) {
otelRoot.span.setAttribute(TraceAttr.UserEmail, authenticatedUserEmail)
}
// `setInputMessages` is internally gated on
// OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT; safe to call.
otelRoot.setInputMessages({ userMessage: body.message })
// Wrap the rest of the handler so nested spans attach to the
// root via AsyncLocalStorage (otherwise they orphan into new traces).
const activeOtelRoot = otelRoot
return await otelContextApi.with(activeOtelRoot.context, async () => {
const branch = await withCopilotSpan(
TraceSpan.CopilotChatResolveBranch,
{
[TraceAttr.WorkflowId]: body.workflowId ?? '',
[TraceAttr.WorkspaceId]: body.workspaceId ?? '',
},
() =>
resolveBranch({
authenticatedUserId,
workflowId: body.workflowId,
workflowName: body.workflowName,
workspaceId: body.workspaceId,
model: body.model,
mode: body.mode,
provider: body.provider,
}),
activeOtelRoot.context
)
if (branch instanceof NextResponse) {
// Non-actionable 4xx (400 bad-request from resolveBranch): stamp
// outcome=error for dashboards but leave span status UNSET so
// error alerts don't fire on normal validation rejections.
activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, branch.status)
activeOtelRoot.finish('error')
return branch
}
let currentChat: ChatLoadResult['chat'] = null
let conversationHistory: unknown[] = []
let chatIsNew = false
actualChatId = body.chatId
if (body.chatId || body.createNewChat) {
const chatResult = await withCopilotSpan(
TraceSpan.CopilotChatResolveOrCreateChat,
{
[TraceAttr.ChatPreexisting]: !!body.chatId,
[TraceAttr.CopilotChatIsNew]: !!body.createNewChat,
},
() =>
resolveOrCreateChat({
chatId: body.chatId,
userId: authenticatedUserId,
...(branch.kind === 'workflow' ? { workflowId: branch.workflowId } : {}),
workspaceId: branch.workspaceId,
model: branch.titleModel,
type: branch.kind === 'workflow' ? 'copilot' : 'mothership',
}),
activeOtelRoot.context
)
currentChat = chatResult.chat
actualChatId = chatResult.chatId || body.chatId
chatIsNew = chatResult.isNew
conversationHistory = Array.isArray(chatResult.conversationHistory)
? chatResult.conversationHistory
: []
if (body.chatId && !currentChat) {
activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, 404)
activeOtelRoot.finish('error')
return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
}
}
if (chatIsNew && actualChatId && body.resourceAttachments?.length) {
await persistChatResources(
actualChatId,
body.resourceAttachments.map((r) => ({
type: r.type,
id: r.id,
title: r.title ?? GENERIC_RESOURCE_TITLE[r.type],
}))
)
}
let pendingStreamWaitMs = 0
if (actualChatId) {
const lockStart = Date.now()
chatStreamLockAcquired = await acquirePendingChatStream(actualChatId, userMessageId)
pendingStreamWaitMs = Date.now() - lockStart
if (!chatStreamLockAcquired) {
const activeStreamId = await getPendingChatStreamId(actualChatId)
// 409 is in the actionable set (see `isActionableErrorStatus`);
// pass a synthesized Error so the span escalates to ERROR status
// and surfaces on pending-stream-collision dashboards.
activeOtelRoot.span.setAttribute(TraceAttr.HttpStatusCode, 409)
activeOtelRoot.finish(
'error',
new Error('A response is already in progress for this chat.')
)
return NextResponse.json(
{
error: 'A response is already in progress for this chat.',
...(activeStreamId ? { activeStreamId } : {}),
},
{ status: 409 }
)
}
}
// Stamp request-shape metadata on the root `gen_ai.agent.execute`
// span now that `branch`, attachment counts, and the pending-stream
// wait are all known. This turns dashboard slicing by
// `copilot.surface` / `copilot.mode` / `copilot.interrupted_prior_stream`
// into a simple TraceQL filter.
activeOtelRoot.setRequestShape({
branchKind: branch.kind,
mode: body.mode,
model: branch.effectiveModel,
provider: body.provider,
createNewChat: body.createNewChat,
prefetch: body.prefetch,
fileAttachmentsCount: body.fileAttachments?.length ?? 0,
resourceAttachmentsCount: body.resourceAttachments?.length ?? 0,
contextsCount: normalizedContexts.length,
commandsCount: body.commands?.length ?? 0,
pendingStreamWaitMs,
})
const workspaceId = branch.workspaceId
// The workspace branch already resolved this permission (and gated on it)
// during branch resolution; reuse it instead of querying again.
const userPermissionPromise =
branch.kind === 'workspace'
? Promise.resolve(branch.workspacePermission)
: workspaceId
? getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch(
(error) => {
logger.warn('Failed to load user permissions', {
error: getErrorMessage(error),
workspaceId,
})
return null
}
)
: Promise.resolve(null)
const entitlementsPromise = workspaceId
? computeWorkspaceEntitlements(workspaceId, authenticatedUserId)
: Promise.resolve([])
// Wrap the pre-LLM prep work in spans so the trace waterfall shows
// where time is going between "request received" and "llm.stream
// opens". Previously these ran bare under the root and inflated the
// apparent "gap" before the model call. Each promise is its own
// span; they run concurrently under Promise.all below.
const workspaceContextPromise = workspaceId
? withCopilotSpan(
TraceSpan.CopilotChatBuildWorkspaceContext,
{ [TraceAttr.WorkspaceId]: workspaceId },
() => generateWorkspaceSnapshot(workspaceId, authenticatedUserId),
activeOtelRoot.context
)
: Promise.resolve(undefined)
const agentContextsPromise = withCopilotSpan(
TraceSpan.CopilotChatResolveAgentContexts,
{
[TraceAttr.CopilotContextsCount]: normalizedContexts.length,
[TraceAttr.CopilotResourceAttachmentsCount]: body.resourceAttachments?.length ?? 0,
},
() =>
resolveAgentContexts({
contexts: normalizedContexts,
resourceAttachments: body.resourceAttachments,
userId: authenticatedUserId,
message: body.message,
workspaceId,
chatId: actualChatId,
requestId,
}),
activeOtelRoot.context
)
const persistUserMessagePromise = persistUserMessage({
chatId: actualChatId,
userMessageId,
message: body.message,
fileAttachments: body.fileAttachments,
contexts: normalizedContexts,
workspaceId,
notifyWorkspaceStatus: branch.notifyWorkspaceStatus,
parentOtelContext: activeOtelRoot.context,
})
const executionContextPromise = withCopilotSpan(
TraceSpan.CopilotChatBuildExecutionContext,
{ [TraceAttr.CopilotBranchKind]: branch.kind },
() =>
branch.buildExecutionContext({
userId: authenticatedUserId,
chatId: actualChatId,
userTimezone: body.userTimezone,
messageId: userMessageId,
}),
activeOtelRoot.context
)
const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] =
await Promise.all([
agentContextsPromise,
userPermissionPromise,
entitlementsPromise,
workspaceContextPromise,
persistUserMessagePromise,
executionContextPromise,
])
// Both halves come from one primary-db fetch (workspace-context.ts):
// `workspaceContext` is the markdown transition fallback, `vfs` is the
// typed snapshot Go diffs into baseline+delta messages.
const workspaceContext = workspaceSnapshot?.markdown
const vfs = workspaceSnapshot?.snapshot
executionContext.userPermission = userPermission ?? undefined
// buildPayload is the last synchronous step before the outbound
// Sim → Go HTTP call. It runs per-tool schema generation (subscription
// lookup + registry iteration, cached 30s) and file upload tracking
// per attachment. Wrapping it so we can see how much of the
// "before llm.stream" gap lives here vs elsewhere.
const requestPayload = await withCopilotSpan(
TraceSpan.CopilotChatBuildPayload,
{
[TraceAttr.CopilotBranchKind]: branch.kind,
[TraceAttr.CopilotFileAttachmentsCount]: body.fileAttachments?.length ?? 0,
[TraceAttr.CopilotContextsCount]: normalizedContexts.length,
},
() =>
branch.kind === 'workflow'
? branch.buildPayload({
message: body.message,
userId: authenticatedUserId,
userMessageId,
chatId: actualChatId,
contexts: agentContexts,
fileAttachments: body.fileAttachments,
userPermission: userPermission ?? undefined,
entitlements,
userTimezone: body.userTimezone,
userMetadata,
workflowId: branch.workflowId,
workflowName: branch.workflowName,
workspaceId: branch.workspaceId,
mode: branch.mode,
provider: branch.provider,
commands: body.commands,
prefetch: body.prefetch,
implicitFeedback: body.implicitFeedback,
workspaceContext,
vfs,
})
: branch.buildPayload({
message: body.message,
userId: authenticatedUserId,
userMessageId,
chatId: actualChatId,
contexts: agentContexts,
fileAttachments: body.fileAttachments,
userPermission: userPermission ?? undefined,
entitlements,
userTimezone: body.userTimezone,
userMetadata,
workspaceContext,
vfs,
}),
activeOtelRoot.context
)
if (actualChatId) {
activeOtelRoot.span.setAttribute(TraceAttr.ChatId, actualChatId)
}
if (workspaceId) {
activeOtelRoot.span.setAttribute(TraceAttr.WorkspaceId, workspaceId)
}
const stream = createSSEStream({
requestPayload,
userId: authenticatedUserId,
streamId: userMessageId,
executionId,
runId,
chatId: actualChatId,
currentChat,
isNewChat: conversationHistory.length === 0,
message: body.message,
titleModel: branch.titleModel,
...(branch.titleProvider ? { titleProvider: branch.titleProvider } : {}),
requestId,
workspaceId,
otelRoot: activeOtelRoot,
orchestrateOptions: {
userId: authenticatedUserId,
...(branch.kind === 'workflow' ? { workflowId: branch.workflowId } : {}),
...(workspaceId ? { workspaceId } : {}),
chatId: actualChatId,
executionId,
runId,
goRoute: branch.goRoute,
autoExecuteTools: true,
interactive: true,
executionContext,
onComplete: buildOnComplete({
chatId: actualChatId,
userMessageId,
requestId,
workspaceId,
notifyWorkspaceStatus: branch.notifyWorkspaceStatus,
otelRoot,
}),
onError: buildOnError({
chatId: actualChatId,
userMessageId,
requestId,
workspaceId,
notifyWorkspaceStatus: branch.notifyWorkspaceStatus,
}),
},
})
captureServerEvent(
authenticatedUserId,
'copilot_chat_sent',
{
...(branch.kind === 'workflow' ? { workflow_id: branch.workflowId } : {}),
...(workspaceId ? { workspace_id: workspaceId } : {}),
has_file_attachments: (body.fileAttachments?.length ?? 0) > 0,
has_contexts: normalizedContexts.length > 0,
mode: branch.kind === 'workflow' ? branch.mode : 'agent',
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
// Expose the root gen_ai.agent.execute span's trace identity to
// the browser so subsequent HTTP calls (stop, abort, confirm,
// SSE reconnect) can echo it back as `traceparent` — making
// all side-channel work on this request appear as child spans
// of this same trace in Tempo instead of disconnected roots.
// W3C traceparent format: `00-<trace-id>-<parent-id>-<flags>`.
const rootCtx = activeOtelRoot.span.spanContext()
const rootTraceparent = `00-${rootCtx.traceId}-${rootCtx.spanId}-${
(rootCtx.traceFlags & 0x1) === 0x1 ? '01' : '00'
}`
return new Response(stream, {
headers: {
...SSE_RESPONSE_HEADERS,
traceparent: rootTraceparent,
},
})
}) // end otelContextApi.with
} catch (error) {
if (chatStreamLockAcquired && actualChatId && userMessageId) {
await releasePendingChatStream(actualChatId, userMessageId)
}
otelRoot?.finish('error', error)
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
if (isWorkspaceAccessDeniedError(error)) {
return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 })
}
logger.error(`[${requestId}] Error handling unified chat request`, {
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: getErrorMessage(error, 'Internal server error'),
},
{ status: 500 }
)
}
}