chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot'
|
||||
import { validationErrorResponse } from '@/lib/api/server'
|
||||
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
|
||||
import { CopilotAbortOutcome } 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 { fetchGo } from '@/lib/copilot/request/go/fetch'
|
||||
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
|
||||
import { withCopilotSpan, withIncomingGoSpan } from '@/lib/copilot/request/otel'
|
||||
import {
|
||||
abortActiveStream,
|
||||
releasePendingChatStream,
|
||||
waitForPendingChatStream,
|
||||
} from '@/lib/copilot/request/session'
|
||||
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CopilotChatAbortAPI')
|
||||
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
|
||||
const STREAM_ABORT_SETTLE_TIMEOUT_MS = 8000
|
||||
|
||||
// POST /api/copilot/chat/abort — fires on user Stop; marks the Go
|
||||
// side aborted then waits for the prior stream to settle.
|
||||
export const POST = withRouteHandler((request: NextRequest) =>
|
||||
withIncomingGoSpan(
|
||||
request.headers,
|
||||
TraceSpan.CopilotChatAbortStream,
|
||||
undefined,
|
||||
async (rootSpan) => {
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Unauthorized)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch((err) => {
|
||||
logger.warn('Abort request body parse failed; continuing with empty object', {
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
return {}
|
||||
})
|
||||
const validation = copilotChatAbortBodySchema.safeParse(body)
|
||||
if (!validation.success) {
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId)
|
||||
return validationErrorResponse(validation.error, 'Invalid request body')
|
||||
}
|
||||
const { streamId, chatId: parsedChatId } = validation.data
|
||||
let chatId = parsedChatId
|
||||
|
||||
if (!streamId) {
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.MissingStreamId)
|
||||
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
|
||||
}
|
||||
rootSpan.setAttributes({
|
||||
[TraceAttr.StreamId]: streamId,
|
||||
[TraceAttr.UserId]: authenticatedUserId,
|
||||
})
|
||||
|
||||
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => {
|
||||
logger.warn('getLatestRunForStream failed while resolving abort context', {
|
||||
streamId,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (!chatId && run?.chatId) {
|
||||
chatId = run.chatId
|
||||
}
|
||||
const workspaceId = run?.workspaceId ?? undefined
|
||||
if (chatId) rootSpan.setAttribute(TraceAttr.ChatId, chatId)
|
||||
|
||||
const aborted = await abortActiveStream(streamId)
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortLocalAborted, aborted)
|
||||
|
||||
let goAbortOk = false
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
Object.assign(headers, getMothershipSourceEnvHeaders())
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort('timeout:go_explicit_abort_fetch'),
|
||||
GO_EXPLICIT_ABORT_TIMEOUT_MS
|
||||
)
|
||||
const mothershipBaseURL = await getMothershipBaseURL({ userId: authenticatedUserId })
|
||||
const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
messageId: streamId,
|
||||
userId: authenticatedUserId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
}),
|
||||
spanName: 'sim → go /api/streams/explicit-abort',
|
||||
operation: 'explicit_abort',
|
||||
attributes: {
|
||||
[TraceAttr.StreamId]: streamId,
|
||||
...(chatId ? { [TraceAttr.ChatId]: chatId } : {}),
|
||||
},
|
||||
}).finally(() => clearTimeout(timeout))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Explicit abort marker request failed: ${response.status}`)
|
||||
}
|
||||
goAbortOk = true
|
||||
} catch (err) {
|
||||
logger.warn('Explicit abort marker request failed after local abort', {
|
||||
streamId,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
}
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortGoMarkerOk, goAbortOk)
|
||||
|
||||
if (chatId) {
|
||||
const settled = await withCopilotSpan(
|
||||
TraceSpan.CopilotChatAbortWaitSettle,
|
||||
{
|
||||
[TraceAttr.ChatId]: chatId,
|
||||
[TraceAttr.StreamId]: streamId,
|
||||
[TraceAttr.SettleTimeoutMs]: STREAM_ABORT_SETTLE_TIMEOUT_MS,
|
||||
},
|
||||
async (settleSpan) => {
|
||||
const start = Date.now()
|
||||
const ok = await waitForPendingChatStream(
|
||||
chatId,
|
||||
STREAM_ABORT_SETTLE_TIMEOUT_MS,
|
||||
streamId
|
||||
)
|
||||
settleSpan.setAttributes({
|
||||
[TraceAttr.SettleWaitMs]: Date.now() - start,
|
||||
[TraceAttr.SettleCompleted]: ok,
|
||||
})
|
||||
return ok
|
||||
}
|
||||
)
|
||||
if (!settled) {
|
||||
// The holder didn't settle within the grace window even though the
|
||||
// user explicitly stopped it and abort markers are written on both
|
||||
// sides (local + Go). Don't leave the chat hostage to a wedged
|
||||
// handler: break its stream lock. This is safe by construction —
|
||||
// releaseLock only deletes when the value still matches this
|
||||
// streamId (never clobbers a newer stream), and the old handler's
|
||||
// heartbeat uses extendLock-if-owner, so it observes the loss and
|
||||
// stops heartbeating rather than re-asserting.
|
||||
await releasePendingChatStream(chatId, streamId)
|
||||
logger.warn('Stream did not settle after abort; force-released chat stream lock', {
|
||||
chatId,
|
||||
streamId,
|
||||
})
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.ForceReleased)
|
||||
return NextResponse.json({ aborted, settled: false, forceReleased: true })
|
||||
}
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Settled)
|
||||
return NextResponse.json({ aborted, settled: true })
|
||||
}
|
||||
|
||||
rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.NoChatId)
|
||||
return NextResponse.json({ aborted })
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Tests for copilot chat delete API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetAccessibleCopilotChat, mockGetAccessibleCopilotChatAuth } = vi.hoisted(() => ({
|
||||
mockGetAccessibleCopilotChat: vi.fn(),
|
||||
mockGetAccessibleCopilotChatAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
|
||||
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
|
||||
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat-status', () => ({
|
||||
chatPubSub: { publishStatusChanged: vi.fn() },
|
||||
}))
|
||||
|
||||
import { DELETE } from './route'
|
||||
|
||||
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest('http://localhost:3000/api/copilot/chat/delete', {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Copilot Chat Delete API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'ws-1' }])
|
||||
mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
|
||||
mockGetAccessibleCopilotChatAuth.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should successfully delete a chat', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: true })
|
||||
|
||||
expect(dbChainMockFns.delete).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.where).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing chatId', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('DELETE', {})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - chatId is not a string', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 12345,
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: false, error: 'Failed to delete chat' })
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors in request body', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/chat/delete', {
|
||||
method: 'DELETE',
|
||||
body: '{invalid-json',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to delete chat')
|
||||
})
|
||||
|
||||
it('should delete chat even if it does not exist (idempotent)', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetAccessibleCopilotChatAuth.mockResolvedValueOnce(null)
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: 'non-existent-chat',
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('should delete chat with empty string chatId (validation should fail)', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('DELETE', {
|
||||
chatId: '',
|
||||
})
|
||||
|
||||
const response = await DELETE(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(dbChainMockFns.delete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { deleteCopilotChatContract } from '@/lib/api/contracts/copilot'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
|
||||
import { chatPubSub } from '@/lib/copilot/chat-status'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('DeleteChatAPI')
|
||||
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const validated = await parseRequest(
|
||||
deleteCopilotChatContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
invalidJson: 'throw',
|
||||
}
|
||||
)
|
||||
if (!validated.success) return validated.response
|
||||
const parsed = validated.data.body
|
||||
|
||||
const chat = await getAccessibleCopilotChatAuth(parsed.chatId, session.user.id)
|
||||
if (!chat) {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(copilotChats)
|
||||
.where(and(eq(copilotChats.id, parsed.chatId), eq(copilotChats.userId, session.user.id)))
|
||||
.returning({ workspaceId: copilotChats.workspaceId })
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Chat deleted', { chatId: parsed.chatId })
|
||||
|
||||
if (deleted.workspaceId) {
|
||||
chatPubSub?.publishStatusChanged({
|
||||
workspaceId: deleted.workspaceId,
|
||||
chatId: parsed.chatId,
|
||||
type: 'deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting chat:', error)
|
||||
return NextResponse.json({ success: false, error: 'Failed to delete chat' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
|
||||
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
|
||||
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
|
||||
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createForbiddenResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import { readFilePreviewSessions } from '@/lib/copilot/request/session'
|
||||
import { readEvents } from '@/lib/copilot/request/session/buffer'
|
||||
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import {
|
||||
assertActiveWorkspaceAccess,
|
||||
isWorkspaceAccessDeniedError,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
function transformChat(chat: {
|
||||
id: string
|
||||
title: string | null
|
||||
model: string | null
|
||||
messages: unknown
|
||||
planArtifact?: unknown
|
||||
config?: unknown
|
||||
conversationId?: string | null
|
||||
resources?: unknown
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}) {
|
||||
return {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
model: chat.model,
|
||||
messages: Array.isArray(chat.messages) ? chat.messages : [],
|
||||
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
|
||||
planArtifact: chat.planArtifact || null,
|
||||
config: chat.config || null,
|
||||
...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}),
|
||||
...('resources' in chat
|
||||
? { resources: Array.isArray(chat.resources) ? chat.resources : [] }
|
||||
: {}),
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type CopilotChatListRow = Pick<
|
||||
typeof copilotChats.$inferSelect,
|
||||
'id' | 'title' | 'model' | 'createdAt' | 'updatedAt'
|
||||
>
|
||||
|
||||
function transformChatListItem(chat: CopilotChatListRow) {
|
||||
return {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
model: chat.model,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const chatId = searchParams.get('chatId')
|
||||
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
if (chatId) {
|
||||
const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId)
|
||||
if (!chat) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let streamSnapshot: {
|
||||
events: ReturnType<typeof toStreamBatchEvent>[]
|
||||
previewSessions: Awaited<ReturnType<typeof readFilePreviewSessions>>
|
||||
status: string
|
||||
} | null = null
|
||||
if (chat.conversationId) {
|
||||
try {
|
||||
const [events, previewSessions, run] = await Promise.all([
|
||||
readEvents(chat.conversationId, '0'),
|
||||
readFilePreviewSessions(chat.conversationId).catch((error) => {
|
||||
logger.warn('Failed to read preview sessions for copilot chat', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return []
|
||||
}),
|
||||
getLatestRunForStream(chat.conversationId, authenticatedUserId).catch((error) => {
|
||||
logger.warn('Failed to fetch latest run for copilot chat snapshot', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return null
|
||||
}),
|
||||
])
|
||||
|
||||
streamSnapshot = {
|
||||
events: events.map(toStreamBatchEvent),
|
||||
previewSessions,
|
||||
status:
|
||||
typeof run?.status === 'string'
|
||||
? run.status
|
||||
: events.length > 0
|
||||
? 'active'
|
||||
: 'unknown',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load copilot chat stream snapshot', {
|
||||
chatId,
|
||||
conversationId: chat.conversationId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMessages = Array.isArray(chat.messages)
|
||||
? chat.messages
|
||||
.filter((message): message is Record<string, unknown> => Boolean(message))
|
||||
.map(normalizeMessage)
|
||||
: []
|
||||
const effectiveMessages = buildEffectiveChatTranscript({
|
||||
messages: normalizedMessages,
|
||||
activeStreamId: chat.conversationId || null,
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
})
|
||||
|
||||
logger.info(`Retrieved chat ${chatId}`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chat: {
|
||||
...transformChat(chat),
|
||||
messages: effectiveMessages,
|
||||
...(streamSnapshot ? { streamSnapshot } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!workflowId && !workspaceId) {
|
||||
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
const scopeFilter = workflowId
|
||||
? eq(copilotChats.workflowId, workflowId)
|
||||
: eq(copilotChats.workspaceId, workspaceId!)
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
title: copilotChats.title,
|
||||
model: copilotChats.model,
|
||||
createdAt: copilotChats.createdAt,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
|
||||
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
|
||||
logger.info(`Retrieved ${chats.length} chats for ${scope}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chats: chats.map(transformChatListItem),
|
||||
})
|
||||
} catch (error) {
|
||||
if (isWorkspaceAccessDeniedError(error)) {
|
||||
return createForbiddenResponse('Workspace access denied')
|
||||
}
|
||||
logger.error('Error fetching copilot chats:', error)
|
||||
return createInternalServerErrorResponse('Failed to fetch chats')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { renameCopilotChatContract } from '@/lib/api/contracts/copilot'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
|
||||
import { chatPubSub } from '@/lib/copilot/chat-status'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('RenameChatAPI')
|
||||
|
||||
export const PATCH = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
renameCopilotChatContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, title } = parsed.data.body
|
||||
|
||||
const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id)
|
||||
if (!chat) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const [updated] = await db
|
||||
.update(copilotChats)
|
||||
.set({ title, updatedAt: now, lastSeenAt: now })
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
|
||||
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Chat renamed', { chatId, title })
|
||||
|
||||
if (updated.workspaceId) {
|
||||
chatPubSub?.publishStatusChanged({
|
||||
workspaceId: updated.workspaceId,
|
||||
chatId,
|
||||
type: 'renamed',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error renaming chat:', error)
|
||||
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
addCopilotChatResourceContract,
|
||||
removeCopilotChatResourceContract,
|
||||
reorderCopilotChatResourcesContract,
|
||||
} from '@/lib/api/contracts/copilot'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createNotFoundResponse,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence'
|
||||
import { GENERIC_RESOURCE_TITLES } from '@/lib/copilot/resources/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CopilotChatResourcesAPI')
|
||||
|
||||
const VALID_RESOURCE_TYPES = new Set<ResourceType>([
|
||||
'table',
|
||||
'file',
|
||||
'workflow',
|
||||
'knowledgebase',
|
||||
'folder',
|
||||
'scheduledtask',
|
||||
'log',
|
||||
'integration',
|
||||
])
|
||||
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
addCopilotChatResourceContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
createBadRequestResponse(error.issues.map((e) => e.message).join(', ')),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, resource } = parsed.data.body
|
||||
|
||||
// Ephemeral UI tab (client does not POST this; guard for old clients / bugs).
|
||||
if (resource.id === 'streaming-file') {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
if (!VALID_RESOURCE_TYPES.has(resource.type)) {
|
||||
return createBadRequestResponse(`Invalid resource type: ${resource.type}`)
|
||||
}
|
||||
|
||||
const [chat] = await db
|
||||
.select({ resources: copilotChats.resources })
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!chat) {
|
||||
return createNotFoundResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
|
||||
const key = `${resource.type}:${resource.id}`
|
||||
const prev = existing.find((r) => `${r.type}:${r.id}` === key)
|
||||
|
||||
let merged: ChatResource[]
|
||||
if (prev) {
|
||||
if (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(resource.title)) {
|
||||
merged = existing.map((r) =>
|
||||
`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r
|
||||
)
|
||||
} else {
|
||||
merged = existing
|
||||
}
|
||||
} else {
|
||||
merged = [...existing, resource]
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() })
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
|
||||
logger.info('Added resource to chat', { chatId, resource })
|
||||
|
||||
return NextResponse.json({ success: true, resources: merged })
|
||||
} catch (error) {
|
||||
logger.error('Error adding chat resource:', error)
|
||||
return createInternalServerErrorResponse('Failed to add resource')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withRouteHandler(async (req: NextRequest) => {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
reorderCopilotChatResourcesContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
createBadRequestResponse(error.issues.map((e) => e.message).join(', ')),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, resources: newOrder } = parsed.data.body
|
||||
|
||||
const [chat] = await db
|
||||
.select({ resources: copilotChats.resources })
|
||||
.from(copilotChats)
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!chat) {
|
||||
return createNotFoundResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : []
|
||||
const existingKeys = new Set(existing.map((r) => `${r.type}:${r.id}`))
|
||||
const newKeys = new Set(newOrder.map((r) => `${r.type}:${r.id}`))
|
||||
|
||||
if (existingKeys.size !== newKeys.size || ![...existingKeys].every((k) => newKeys.has(k))) {
|
||||
return createBadRequestResponse('Reordered resources must match existing resources')
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() })
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
|
||||
logger.info('Reordered resources for chat', { chatId, count: newOrder.length })
|
||||
|
||||
return NextResponse.json({ success: true, resources: newOrder })
|
||||
} catch (error) {
|
||||
logger.error('Error reordering chat resources:', error)
|
||||
return createInternalServerErrorResponse('Failed to reorder resources')
|
||||
}
|
||||
})
|
||||
|
||||
export const DELETE = withRouteHandler(async (req: NextRequest) => {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
removeCopilotChatResourceContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
createBadRequestResponse(error.issues.map((e) => e.message).join(', ')),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, resourceType, resourceId } = parsed.data.body
|
||||
|
||||
const [updated] = await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
resources: sql`COALESCE((
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(${copilotChats.resources}) elem
|
||||
WHERE NOT (elem->>'type' = ${resourceType} AND elem->>'id' = ${resourceId})
|
||||
), '[]'::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
|
||||
.returning({ resources: copilotChats.resources })
|
||||
|
||||
if (!updated) {
|
||||
return createNotFoundResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
const merged = Array.isArray(updated.resources) ? (updated.resources as ChatResource[]) : []
|
||||
|
||||
logger.info('Removed resource from chat', { chatId, resourceType, resourceId })
|
||||
|
||||
return NextResponse.json({ success: true, resources: merged })
|
||||
} catch (error) {
|
||||
logger.error('Error removing chat resource:', error)
|
||||
return createInternalServerErrorResponse('Failed to remove resource')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { copilotChatGetContract } from '@/lib/api/contracts/copilot'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
|
||||
import { GET as getChat } from '@/app/api/copilot/chat/queries'
|
||||
|
||||
export { maxDuration }
|
||||
|
||||
export const POST = handleUnifiedChatPost
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const parsed = await parseRequest(copilotChatGetContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
return getChat(request)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({
|
||||
mockAppendCopilotChatMessages: vi.fn(),
|
||||
mockPublishStatusChanged: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/messages-store', () => ({
|
||||
appendCopilotChatMessages: mockAppendCopilotChatMessages,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat-status', () => ({
|
||||
chatPubSub: {
|
||||
publishStatusChanged: mockPublishStatusChanged,
|
||||
},
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/copilot/chat/stop/route'
|
||||
|
||||
function createRequest(body: Record<string, unknown>) {
|
||||
return new NextRequest('http://localhost:3000/api/copilot/chat/stop', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row
|
||||
* (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup
|
||||
* (both terminate on `.limit(1)`).
|
||||
*/
|
||||
function mockReads(opts: {
|
||||
chat: Record<string, unknown> | null
|
||||
last?: { messageId: string; role: string }
|
||||
}) {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : [])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : [])
|
||||
}
|
||||
|
||||
describe('copilot chat stop route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults.
|
||||
dbChainMockFns.limit.mockReset()
|
||||
resetDbChainMock()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce(null)
|
||||
|
||||
const response = await POST(
|
||||
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.json()).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('is a no-op when the chat is missing', async () => {
|
||||
mockReads({ chat: null })
|
||||
|
||||
const response = await POST(
|
||||
createRequest({ chatId: 'missing-chat', streamId: 'stream-1', content: '' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ success: true })
|
||||
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends a stopped assistant message even with no content', async () => {
|
||||
mockReads({
|
||||
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
|
||||
last: { messageId: 'stream-1', role: 'user' },
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ success: true })
|
||||
|
||||
const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(setArg.conversationId).toBeNull()
|
||||
expect(Object.hasOwn(setArg, 'messages')).toBe(false)
|
||||
|
||||
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
|
||||
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
|
||||
expect(appended[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
|
||||
})
|
||||
|
||||
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
chatId: 'chat-1',
|
||||
type: 'completed',
|
||||
streamId: 'stream-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('appends a stopped assistant message if the stream marker was already cleared', async () => {
|
||||
mockReads({
|
||||
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
|
||||
last: { messageId: 'stream-1', role: 'user' },
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ success: true })
|
||||
|
||||
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
|
||||
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
|
||||
expect(appended[0]).toMatchObject({ role: 'assistant', content: 'partial' })
|
||||
|
||||
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
chatId: 'chat-1',
|
||||
type: 'completed',
|
||||
streamId: 'stream-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('republishes completed status when the assistant was already persisted', async () => {
|
||||
mockReads({
|
||||
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
|
||||
last: { messageId: 'assistant-1', role: 'assistant' },
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ success: true })
|
||||
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).not.toHaveBeenCalled()
|
||||
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
chatId: 'chat-1',
|
||||
type: 'completed',
|
||||
streamId: 'stream-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
MothershipStreamV1EventType,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
|
||||
const { getLatestRunForStream, readEvents, readFilePreviewSessions, checkForReplayGap } =
|
||||
vi.hoisted(() => ({
|
||||
getLatestRunForStream: vi.fn(),
|
||||
readEvents: vi.fn(),
|
||||
readFilePreviewSessions: vi.fn(),
|
||||
checkForReplayGap: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/async-runs/repository', () => ({
|
||||
getLatestRunForStream,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/session', () => ({
|
||||
readEvents,
|
||||
readFilePreviewSessions,
|
||||
checkForReplayGap,
|
||||
createEvent: (event: Record<string, unknown>) => ({
|
||||
stream: {
|
||||
streamId: event.streamId,
|
||||
cursor: event.cursor,
|
||||
},
|
||||
seq: event.seq,
|
||||
trace: { requestId: event.requestId ?? '' },
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
}),
|
||||
encodeSSEEnvelope: (event: Record<string, unknown>) =>
|
||||
new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`),
|
||||
encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`),
|
||||
SSE_RESPONSE_HEADERS: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
|
||||
|
||||
import { GET } from './route'
|
||||
|
||||
async function readAllChunks(response: Response): Promise<string[]> {
|
||||
const reader = response.body?.getReader()
|
||||
expect(reader).toBeTruthy()
|
||||
|
||||
const chunks: string[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader!.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
chunks.push(new TextDecoder().decode(value))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
describe('copilot chat stream replay route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
readEvents.mockResolvedValue([])
|
||||
readFilePreviewSessions.mockResolvedValue([])
|
||||
checkForReplayGap.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns preview sessions in batch mode', async () => {
|
||||
getLatestRunForStream.mockResolvedValue({
|
||||
status: 'active',
|
||||
executionId: 'exec-1',
|
||||
id: 'run-1',
|
||||
})
|
||||
readFilePreviewSessions.mockResolvedValue([
|
||||
{
|
||||
schemaVersion: 1,
|
||||
id: 'preview-1',
|
||||
streamId: 'stream-1',
|
||||
toolCallId: 'preview-1',
|
||||
status: 'streaming',
|
||||
fileName: 'draft.md',
|
||||
previewText: 'hello',
|
||||
previewVersion: 2,
|
||||
updatedAt: '2026-04-10T00:00:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
'http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0&batch=true'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
previewSessions: [
|
||||
expect.objectContaining({
|
||||
id: 'preview-1',
|
||||
previewText: 'hello',
|
||||
previewVersion: 2,
|
||||
}),
|
||||
],
|
||||
status: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('stops replay polling when run becomes cancelled', async () => {
|
||||
getLatestRunForStream
|
||||
.mockResolvedValueOnce({
|
||||
status: 'active',
|
||||
executionId: 'exec-1',
|
||||
id: 'run-1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'cancelled',
|
||||
executionId: 'exec-1',
|
||||
id: 'run-1',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
|
||||
)
|
||||
|
||||
const chunks = await readAllChunks(response)
|
||||
expect(chunks[0]).toBe(': accepted\n\n')
|
||||
expect(chunks.join('')).toContain(
|
||||
JSON.stringify({
|
||||
status: MothershipStreamV1CompletionStatus.cancelled,
|
||||
reason: 'terminal_status',
|
||||
})
|
||||
)
|
||||
expect(getLatestRunForStream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('emits structured terminal replay error when run metadata disappears', async () => {
|
||||
getLatestRunForStream
|
||||
.mockResolvedValueOnce({
|
||||
status: 'active',
|
||||
executionId: 'exec-1',
|
||||
id: 'run-1',
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
|
||||
)
|
||||
|
||||
const chunks = await readAllChunks(response)
|
||||
const body = chunks.join('')
|
||||
expect(body).toContain(`"type":"${MothershipStreamV1EventType.error}"`)
|
||||
expect(body).toContain('"code":"resume_run_unavailable"')
|
||||
expect(body).toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
|
||||
})
|
||||
|
||||
it('uses the latest live request id for synthetic terminal replay events', async () => {
|
||||
getLatestRunForStream
|
||||
.mockResolvedValueOnce({
|
||||
status: 'active',
|
||||
executionId: 'exec-1',
|
||||
id: 'run-1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'cancelled',
|
||||
executionId: 'exec-1',
|
||||
id: 'run-1',
|
||||
})
|
||||
readEvents
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
stream: { streamId: 'stream-1', cursor: '1' },
|
||||
seq: 1,
|
||||
trace: { requestId: 'req-live-123' },
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: {
|
||||
channel: 'assistant',
|
||||
text: 'hello',
|
||||
},
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0')
|
||||
)
|
||||
|
||||
const chunks = await readAllChunks(response)
|
||||
const terminalChunk = chunks[chunks.length - 1] ?? ''
|
||||
expect(terminalChunk).toContain(`"type":"${MothershipStreamV1EventType.complete}"`)
|
||||
expect(terminalChunk).toContain('"requestId":"req-live-123"')
|
||||
expect(terminalChunk).toContain('"status":"cancelled"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,483 @@
|
||||
import { type Context, context as otelContext, type Span, trace } from '@opentelemetry/api'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { copilotChatStreamContract } from '@/lib/api/contracts/copilot'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
|
||||
import {
|
||||
MothershipStreamV1CompletionStatus,
|
||||
MothershipStreamV1EventType,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import {
|
||||
CopilotResumeOutcome,
|
||||
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 { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation'
|
||||
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
|
||||
import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel'
|
||||
import {
|
||||
checkForReplayGap,
|
||||
createEvent,
|
||||
encodeSSEComment,
|
||||
encodeSSEEnvelope,
|
||||
readEvents,
|
||||
readFilePreviewSessions,
|
||||
SSE_RESPONSE_HEADERS,
|
||||
} from '@/lib/copilot/request/session'
|
||||
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const maxDuration = 3600
|
||||
|
||||
const logger = createLogger('CopilotChatStreamAPI')
|
||||
const POLL_INTERVAL_MS = 250
|
||||
const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
const MAX_STREAM_MS = 60 * 60 * 1000
|
||||
|
||||
function extractCanonicalRequestId(value: unknown): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value : ''
|
||||
}
|
||||
|
||||
function extractRunRequestId(run: { requestContext?: unknown } | null | undefined): string {
|
||||
if (!run || typeof run.requestContext !== 'object' || run.requestContext === null) {
|
||||
return ''
|
||||
}
|
||||
const requestContext = run.requestContext as Record<string, unknown>
|
||||
return (
|
||||
extractCanonicalRequestId(requestContext.requestId) ||
|
||||
extractCanonicalRequestId(requestContext.simRequestId)
|
||||
)
|
||||
}
|
||||
|
||||
function extractEnvelopeRequestId(envelope: { trace?: { requestId?: unknown } }): string {
|
||||
return extractCanonicalRequestId(envelope.trace?.requestId)
|
||||
}
|
||||
|
||||
function isTerminalStatus(
|
||||
status: string | null | undefined
|
||||
): status is MothershipStreamV1CompletionStatus {
|
||||
return (
|
||||
status === MothershipStreamV1CompletionStatus.complete ||
|
||||
status === MothershipStreamV1CompletionStatus.error ||
|
||||
status === MothershipStreamV1CompletionStatus.cancelled
|
||||
)
|
||||
}
|
||||
|
||||
function buildResumeTerminalEnvelopes(options: {
|
||||
streamId: string
|
||||
afterCursor: string
|
||||
status: MothershipStreamV1CompletionStatus
|
||||
message?: string
|
||||
code: string
|
||||
reason?: string
|
||||
requestId?: string
|
||||
}) {
|
||||
const baseSeq = Number(options.afterCursor || '0')
|
||||
const seq = Number.isFinite(baseSeq) ? baseSeq : 0
|
||||
const envelopes: ReturnType<typeof createEvent>[] = []
|
||||
const rid = options.requestId ?? ''
|
||||
|
||||
if (options.status === MothershipStreamV1CompletionStatus.error) {
|
||||
envelopes.push(
|
||||
createEvent({
|
||||
streamId: options.streamId,
|
||||
cursor: String(seq + 1),
|
||||
seq: seq + 1,
|
||||
requestId: rid,
|
||||
type: MothershipStreamV1EventType.error,
|
||||
payload: {
|
||||
message: options.message || 'Stream recovery failed before completion.',
|
||||
code: options.code,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
envelopes.push(
|
||||
createEvent({
|
||||
streamId: options.streamId,
|
||||
cursor: String(seq + envelopes.length + 1),
|
||||
seq: seq + envelopes.length + 1,
|
||||
requestId: rid,
|
||||
type: MothershipStreamV1EventType.complete,
|
||||
payload: {
|
||||
status: options.status,
|
||||
...(options.reason ? { reason: options.reason } : {}),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(copilotChatStreamContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { streamId, after: afterCursor, batch: batchMode } = parsed.data.query
|
||||
|
||||
if (!streamId) {
|
||||
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Root span for the whole resume/reconnect request. In stream mode the
|
||||
// work happens inside `ReadableStream.start`, which the Node runtime
|
||||
// invokes after this function returns and OUTSIDE the AsyncLocalStorage
|
||||
// scope installed by `startActiveSpan`. We therefore start the span
|
||||
// manually, capture its context, and re-enter that context inside the
|
||||
// stream callback so every nested `withCopilotSpan` / `withDbSpan` call
|
||||
// attaches to this root.
|
||||
//
|
||||
// `contextFromRequestHeaders` extracts the W3C `traceparent` the
|
||||
// client echoed (set via `streamTraceparentRef` on Sim's chat POST
|
||||
// response), so the resume span becomes a child of the original
|
||||
// chat's `gen_ai.agent.execute` trace instead of a disconnected
|
||||
// new root. On reconnects after page reload (client ref was wiped)
|
||||
// the header is absent and extraction leaves the ambient context
|
||||
// alone → the resume span becomes its own root. Same as pre-
|
||||
// linking behavior; no regression.
|
||||
const incomingContext = contextFromRequestHeaders(request.headers)
|
||||
const rootSpan = getCopilotTracer().startSpan(
|
||||
TraceSpan.CopilotResumeRequest,
|
||||
{
|
||||
attributes: {
|
||||
[TraceAttr.CopilotTransport]: batchMode ? CopilotTransport.Batch : CopilotTransport.Stream,
|
||||
[TraceAttr.StreamId]: streamId,
|
||||
[TraceAttr.UserId]: authenticatedUserId,
|
||||
[TraceAttr.CopilotResumeAfterCursor]: afterCursor || '0',
|
||||
},
|
||||
},
|
||||
incomingContext
|
||||
)
|
||||
const rootContext = trace.setSpan(incomingContext, rootSpan)
|
||||
|
||||
try {
|
||||
return await otelContext.with(rootContext, () =>
|
||||
handleResumeRequestBody({
|
||||
request,
|
||||
streamId,
|
||||
afterCursor,
|
||||
batchMode,
|
||||
authenticatedUserId,
|
||||
rootSpan,
|
||||
rootContext,
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
markSpanForError(rootSpan, err)
|
||||
rootSpan.end()
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
async function handleResumeRequestBody({
|
||||
request,
|
||||
streamId,
|
||||
afterCursor,
|
||||
batchMode,
|
||||
authenticatedUserId,
|
||||
rootSpan,
|
||||
rootContext,
|
||||
}: {
|
||||
request: NextRequest
|
||||
streamId: string
|
||||
afterCursor: string
|
||||
batchMode: boolean
|
||||
authenticatedUserId: string
|
||||
rootSpan: Span
|
||||
rootContext: Context
|
||||
}) {
|
||||
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => {
|
||||
logger.warn('Failed to fetch latest run for stream', {
|
||||
streamId,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
return null
|
||||
})
|
||||
logger.info('[Resume] Stream lookup', {
|
||||
streamId,
|
||||
afterCursor,
|
||||
batchMode,
|
||||
hasRun: !!run,
|
||||
runStatus: run?.status,
|
||||
})
|
||||
if (!run) {
|
||||
rootSpan.setAttribute(TraceAttr.CopilotResumeOutcome, CopilotResumeOutcome.StreamNotFound)
|
||||
rootSpan.end()
|
||||
return NextResponse.json({ error: 'Stream not found' }, { status: 404 })
|
||||
}
|
||||
rootSpan.setAttribute(TraceAttr.CopilotRunStatus, run.status)
|
||||
|
||||
if (batchMode) {
|
||||
const afterSeq = afterCursor || '0'
|
||||
const [events, previewSessions] = await Promise.all([
|
||||
readEvents(streamId, afterSeq),
|
||||
readFilePreviewSessions(streamId).catch((error) => {
|
||||
logger.warn('Failed to read preview sessions for stream batch', {
|
||||
streamId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return []
|
||||
}),
|
||||
])
|
||||
const batchEvents = events.map(toStreamBatchEvent)
|
||||
logger.info('[Resume] Batch response', {
|
||||
streamId,
|
||||
afterCursor: afterSeq,
|
||||
eventCount: batchEvents.length,
|
||||
previewSessionCount: previewSessions.length,
|
||||
runStatus: run.status,
|
||||
})
|
||||
rootSpan.setAttributes({
|
||||
[TraceAttr.CopilotResumeOutcome]: CopilotResumeOutcome.BatchDelivered,
|
||||
[TraceAttr.CopilotResumeEventCount]: batchEvents.length,
|
||||
[TraceAttr.CopilotResumePreviewSessionCount]: previewSessions.length,
|
||||
})
|
||||
rootSpan.end()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
events: batchEvents,
|
||||
previewSessions,
|
||||
status: run.status,
|
||||
...(run.chatId ? { chatId: run.chatId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
let totalEventsFlushed = 0
|
||||
let pollIterations = 0
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Re-enter the root OTel context so any `withCopilotSpan` call below
|
||||
// (inside flushEvents/checkForReplayGap/etc.) parents under
|
||||
// copilot.resume.request instead of becoming an orphan.
|
||||
return otelContext.with(rootContext, () => startInner(controller))
|
||||
},
|
||||
})
|
||||
|
||||
async function startInner(controller: ReadableStreamDefaultController) {
|
||||
let cursor = afterCursor || '0'
|
||||
let controllerClosed = false
|
||||
let sawTerminalEvent = false
|
||||
let currentRequestId = extractRunRequestId(run)
|
||||
let lastWriteTime = Date.now()
|
||||
// Stamp the logical request id + chat id on the resume root as soon
|
||||
// as we resolve them from the run row, so TraceQL joins work on
|
||||
// resume legs the same way they do on the original POST.
|
||||
if (currentRequestId) {
|
||||
rootSpan.setAttribute(TraceAttr.RequestId, currentRequestId)
|
||||
rootSpan.setAttribute(TraceAttr.SimRequestId, currentRequestId)
|
||||
}
|
||||
if (run?.chatId) {
|
||||
rootSpan.setAttribute(TraceAttr.ChatId, run.chatId)
|
||||
}
|
||||
|
||||
const closeController = () => {
|
||||
if (controllerClosed) return
|
||||
controllerClosed = true
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Controller already closed by runtime/client
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueEvent = (payload: unknown) => {
|
||||
if (controllerClosed) return false
|
||||
try {
|
||||
controller.enqueue(encodeSSEEnvelope(payload))
|
||||
lastWriteTime = Date.now()
|
||||
return true
|
||||
} catch {
|
||||
controllerClosed = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const enqueueComment = (comment: string) => {
|
||||
if (controllerClosed) return false
|
||||
try {
|
||||
controller.enqueue(encodeSSEComment(comment))
|
||||
lastWriteTime = Date.now()
|
||||
return true
|
||||
} catch {
|
||||
controllerClosed = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const abortListener = () => {
|
||||
controllerClosed = true
|
||||
}
|
||||
request.signal.addEventListener('abort', abortListener, { once: true })
|
||||
|
||||
const flushEvents = async () => {
|
||||
const events = await readEvents(streamId, cursor)
|
||||
if (events.length > 0) {
|
||||
logger.debug('[Resume] Flushing events', {
|
||||
streamId,
|
||||
afterCursor: cursor,
|
||||
eventCount: events.length,
|
||||
})
|
||||
}
|
||||
for (const envelope of events) {
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
totalEventsFlushed += 1
|
||||
cursor = envelope.stream.cursor ?? String(envelope.seq)
|
||||
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
|
||||
if (envelope.type === MothershipStreamV1EventType.complete) {
|
||||
sawTerminalEvent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emitTerminalIfMissing = (
|
||||
status: MothershipStreamV1CompletionStatus,
|
||||
options?: { message?: string; code: string; reason?: string }
|
||||
) => {
|
||||
if (controllerClosed || sawTerminalEvent) {
|
||||
return
|
||||
}
|
||||
for (const envelope of buildResumeTerminalEnvelopes({
|
||||
streamId,
|
||||
afterCursor: cursor,
|
||||
status,
|
||||
message: options?.message,
|
||||
code: options?.code ?? 'resume_terminal',
|
||||
reason: options?.reason,
|
||||
requestId: currentRequestId,
|
||||
})) {
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
cursor = envelope.stream.cursor ?? String(envelope.seq)
|
||||
if (envelope.type === MothershipStreamV1EventType.complete) {
|
||||
sawTerminalEvent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
enqueueComment('accepted')
|
||||
|
||||
const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId)
|
||||
if (gap) {
|
||||
for (const envelope of gap.envelopes) {
|
||||
if (!enqueueEvent(envelope)) {
|
||||
break
|
||||
}
|
||||
cursor = envelope.stream.cursor ?? String(envelope.seq)
|
||||
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
|
||||
if (envelope.type === MothershipStreamV1EventType.complete) {
|
||||
sawTerminalEvent = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await flushEvents()
|
||||
|
||||
while (!controllerClosed && Date.now() - startTime < MAX_STREAM_MS) {
|
||||
pollIterations += 1
|
||||
const currentRun = await getLatestRunForStream(streamId, authenticatedUserId).catch(
|
||||
(err) => {
|
||||
logger.warn('Failed to poll latest run for stream', {
|
||||
streamId,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
)
|
||||
if (!currentRun) {
|
||||
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
|
||||
message: 'The stream could not be recovered because its run metadata is unavailable.',
|
||||
code: 'resume_run_unavailable',
|
||||
reason: 'run_unavailable',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
currentRequestId = extractRunRequestId(currentRun) || currentRequestId
|
||||
|
||||
await flushEvents()
|
||||
|
||||
if (controllerClosed) {
|
||||
break
|
||||
}
|
||||
if (isTerminalStatus(currentRun.status)) {
|
||||
emitTerminalIfMissing(currentRun.status, {
|
||||
message:
|
||||
currentRun.status === MothershipStreamV1CompletionStatus.error
|
||||
? typeof currentRun.error === 'string'
|
||||
? currentRun.error
|
||||
: 'The recovered stream ended with an error.'
|
||||
: undefined,
|
||||
code: 'resume_terminal_status',
|
||||
reason: 'terminal_status',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (request.signal.aborted) {
|
||||
controllerClosed = true
|
||||
break
|
||||
}
|
||||
|
||||
if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) {
|
||||
enqueueComment('keepalive')
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) {
|
||||
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
|
||||
message: 'The stream recovery timed out before completion.',
|
||||
code: 'resume_timeout',
|
||||
reason: 'timeout',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controllerClosed && !request.signal.aborted) {
|
||||
logger.warn('Stream replay failed', {
|
||||
streamId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, {
|
||||
message: 'The stream replay failed before completion.',
|
||||
code: 'resume_internal',
|
||||
reason: 'stream_replay_failed',
|
||||
})
|
||||
}
|
||||
markSpanForError(rootSpan, error)
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', abortListener)
|
||||
closeController()
|
||||
rootSpan.setAttributes({
|
||||
[TraceAttr.CopilotResumeOutcome]: sawTerminalEvent
|
||||
? CopilotResumeOutcome.TerminalDelivered
|
||||
: controllerClosed
|
||||
? CopilotResumeOutcome.ClientDisconnected
|
||||
: CopilotResumeOutcome.EndedWithoutTerminal,
|
||||
[TraceAttr.CopilotResumeEventCount]: totalEventsFlushed,
|
||||
[TraceAttr.CopilotResumePollIterations]: pollIterations,
|
||||
[TraceAttr.CopilotResumeDurationMs]: Date.now() - startTime,
|
||||
})
|
||||
rootSpan.end()
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(stream, { headers: SSE_RESPONSE_HEADERS })
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Tests for copilot chat update-messages API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockSelect,
|
||||
mockFrom,
|
||||
mockWhere,
|
||||
mockLimit,
|
||||
mockUpdate,
|
||||
mockSet,
|
||||
mockUpdateWhere,
|
||||
mockReturning,
|
||||
mockReplaceCopilotChatMessages,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSelect: vi.fn(),
|
||||
mockFrom: vi.fn(),
|
||||
mockWhere: vi.fn(),
|
||||
mockLimit: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockSet: vi.fn(),
|
||||
mockUpdateWhere: vi.fn(),
|
||||
mockReturning: vi.fn(),
|
||||
mockReplaceCopilotChatMessages: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
transaction: async (
|
||||
cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown
|
||||
) => cb({ update: mockUpdate, select: mockSelect }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat/messages-store', () => ({
|
||||
replaceCopilotChatMessages: mockReplaceCopilotChatMessages,
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/copilot/chat/update-messages/route'
|
||||
|
||||
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Copilot Chat Update Messages API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockLimit.mockResolvedValue([])
|
||||
mockUpdate.mockReturnValue({ set: mockSet })
|
||||
mockSet.mockReturnValue({ where: mockUpdateWhere })
|
||||
mockUpdateWhere.mockReturnValue({ returning: mockReturning })
|
||||
mockReturning.mockResolvedValue([{ model: 'gpt-4' }])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing chatId', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing messages', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid message structure - missing required fields', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid message role', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'invalid-role',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should return 404 when chat is not found', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'non-existent-chat',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Chat not found or unauthorized')
|
||||
})
|
||||
|
||||
it('should return 404 when chat belongs to different user', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'other-user-chat',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Chat not found or unauthorized')
|
||||
})
|
||||
|
||||
it('should successfully update chat messages', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const existingChat = {
|
||||
id: 'chat-123',
|
||||
userId: 'user-123',
|
||||
messages: [],
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([existingChat])
|
||||
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello, how are you?',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: 'I am doing well, thank you!',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
messages,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
messageCount: 2,
|
||||
})
|
||||
|
||||
expect(mockSelect).toHaveBeenCalled()
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
|
||||
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
|
||||
'chat-123',
|
||||
messages,
|
||||
{ chatModel: 'gpt-4' },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('should successfully update chat messages with optional fields', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const existingChat = {
|
||||
id: 'chat-456',
|
||||
userId: 'user-123',
|
||||
messages: [],
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([existingChat])
|
||||
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: 'Hi there!',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'get_weather',
|
||||
arguments: { location: 'NYC' },
|
||||
},
|
||||
],
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Here is the weather information',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-456',
|
||||
messages,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
messageCount: 2,
|
||||
})
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
|
||||
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
|
||||
'chat-456',
|
||||
[
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: 'Hi there!',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
contentBlocks: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Here is the weather information',
|
||||
},
|
||||
{
|
||||
type: 'tool',
|
||||
phase: 'call',
|
||||
toolCall: {
|
||||
id: 'tool-1',
|
||||
name: 'get_weather',
|
||||
state: 'pending',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{ chatModel: 'gpt-4' },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle empty messages array', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const existingChat = {
|
||||
id: 'chat-789',
|
||||
userId: 'user-123',
|
||||
messages: [],
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([existingChat])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-789',
|
||||
messages: [],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
messageCount: 0,
|
||||
})
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
|
||||
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
|
||||
'chat-789',
|
||||
[],
|
||||
{ chatModel: 'gpt-4' },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle database errors during chat lookup', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
mockLimit.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to update chat messages')
|
||||
})
|
||||
|
||||
it('should handle database errors during update operation', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const existingChat = {
|
||||
id: 'chat-123',
|
||||
userId: 'user-123',
|
||||
messages: [],
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([existingChat])
|
||||
|
||||
mockSet.mockReturnValueOnce({
|
||||
where: vi.fn().mockRejectedValue(new Error('Update operation failed')),
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-123',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to update chat messages')
|
||||
})
|
||||
|
||||
it('should handle JSON parsing errors in request body', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', {
|
||||
method: 'POST',
|
||||
body: '{invalid-json',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const responseData = await response.json()
|
||||
expect(responseData.error).toBe('Failed to update chat messages')
|
||||
})
|
||||
|
||||
it('should handle large message arrays', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const existingChat = {
|
||||
id: 'chat-large',
|
||||
userId: 'user-123',
|
||||
messages: [],
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([existingChat])
|
||||
|
||||
const messages = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `msg-${i + 1}`,
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `Message ${i + 1}`,
|
||||
timestamp: new Date(2024, 0, 1, 10, i).toISOString(),
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-large',
|
||||
messages,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
messageCount: 100,
|
||||
})
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
|
||||
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
|
||||
'chat-large',
|
||||
messages,
|
||||
{ chatModel: 'gpt-4' },
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle messages with both user and assistant roles', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
|
||||
const existingChat = {
|
||||
id: 'chat-mixed',
|
||||
userId: 'user-123',
|
||||
messages: [],
|
||||
}
|
||||
mockLimit.mockResolvedValueOnce([existingChat])
|
||||
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
content: 'What is the weather like?',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
content: 'Let me check the weather for you.',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-weather',
|
||||
name: 'get_weather',
|
||||
arguments: { location: 'current' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
role: 'assistant',
|
||||
content: 'The weather is sunny and 75°F.',
|
||||
timestamp: '2024-01-01T10:02:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
role: 'user',
|
||||
content: 'Thank you!',
|
||||
timestamp: '2024-01-01T10:03:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
chatId: 'chat-mixed',
|
||||
messages,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: true,
|
||||
messageCount: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateCopilotMessagesContract } from '@/lib/api/contracts/copilot'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
|
||||
import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
|
||||
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createInternalServerErrorResponse,
|
||||
createNotFoundResponse,
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CopilotChatUpdateAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const tracker = createRequestTracker()
|
||||
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
updateCopilotMessagesContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
invalidJson: 'throw',
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId, messages, planArtifact, config } = parsed.data.body
|
||||
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Received messages to save`, {
|
||||
messageCount: messages.length,
|
||||
lastMsgId: lastMsg.id,
|
||||
lastMsgContentLength: lastMsg.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedMessages: PersistedMessage[] = messages.map((message) =>
|
||||
normalizeMessage(message as Record<string, unknown>)
|
||||
)
|
||||
|
||||
// Debug: Log what we're about to save
|
||||
const lastMsgParsed = normalizedMessages[normalizedMessages.length - 1]
|
||||
if (lastMsgParsed?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
|
||||
messageCount: normalizedMessages.length,
|
||||
lastMsgId: lastMsgParsed.id,
|
||||
lastMsgContentLength: lastMsgParsed.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
|
||||
|
||||
if (!chat) {
|
||||
return createNotFoundResponse('Chat not found or unauthorized')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (planArtifact !== undefined) {
|
||||
updateData.planArtifact = planArtifact
|
||||
}
|
||||
|
||||
if (config !== undefined) {
|
||||
updateData.config = config
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(copilotChats)
|
||||
.set(updateData)
|
||||
.where(eq(copilotChats.id, chatId))
|
||||
.returning({ model: copilotChats.model })
|
||||
if (!updated) return
|
||||
await replaceCopilotChatMessages(
|
||||
chatId,
|
||||
normalizedMessages,
|
||||
{ chatModel: updated.model ?? null },
|
||||
tx
|
||||
)
|
||||
})
|
||||
|
||||
logger.info(`[${tracker.requestId}] Successfully updated chat`, {
|
||||
chatId,
|
||||
newMessageCount: normalizedMessages.length,
|
||||
hasPlanArtifact: !!planArtifact,
|
||||
hasConfig: !!config,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
messageCount: normalizedMessages.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Error updating chat messages:`, error)
|
||||
return createInternalServerErrorResponse('Failed to update chat messages')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user