import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStopContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { normalizeMessage, type PersistedMessage, withStoppedContentBlock, } from '@/lib/copilot/chat/persisted-message' import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' import { chatPubSub } from '@/lib/copilot/chat-status' import { CopilotChatFinalizeOutcome, CopilotStopOutcome, } 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 { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatStopAPI') // POST /api/copilot/chat/stop — persists partial assistant content // when the user stops mid-stream. Lock release is handled by the // aborted server stream unwinding, not this handler. export const POST = withRouteHandler((req: NextRequest) => withIncomingGoSpan(req.headers, TraceSpan.CopilotChatStopStream, undefined, async (span) => { try { const session = await getSession() if (!session?.user?.id) { span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.Unauthorized) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const parsed = await parseRequest(copilotChatStopContract, req, {}) if (!parsed.success) { span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ValidationError) return parsed.response } const { chatId, streamId, content, contentBlocks, requestId } = parsed.data.body span.setAttributes({ [TraceAttr.ChatId]: chatId, [TraceAttr.StreamId]: streamId, [TraceAttr.UserId]: session.user.id, [TraceAttr.CopilotStopContentLength]: content.length, [TraceAttr.CopilotStopBlocksCount]: contentBlocks?.length ?? 0, ...(requestId ? { [TraceAttr.RequestId]: requestId } : {}), }) const hasContent = content.trim().length > 0 const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0 const assistantBlocks = hasBlocks ? contentBlocks : hasContent ? [{ type: 'text', channel: 'assistant', content }] : [] const assistantMessage: PersistedMessage = withStoppedContentBlock( normalizeMessage({ id: generateId(), role: 'assistant', content, timestamp: new Date().toISOString(), contentBlocks: assistantBlocks, ...(requestId ? { requestId } : {}), }) ) const result = await finalizeAssistantTurn({ chatId, userId: session.user.id, userMessageId: streamId, assistantMessage, streamMarkerPolicy: 'active-or-cleared', }) span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, result.appendedAssistant) const stopOutcome = !result.found ? CopilotStopOutcome.ChatNotFound : result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted ? CopilotStopOutcome.Persisted : CopilotStopOutcome.NoMatchingRow const shouldPublishCompleted = result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted if (shouldPublishCompleted && result.workspaceId) { chatPubSub?.publishStatusChanged({ workspaceId: result.workspaceId, chatId, type: 'completed', streamId, }) } span.setAttribute(TraceAttr.CopilotStopOutcome, stopOutcome) return NextResponse.json({ success: true }) } catch (error) { logger.error('Error stopping chat stream:', error) span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.InternalError) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) )