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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,58 @@
import { type NextRequest, NextResponse } from 'next/server'
import { generateCopilotApiKeyContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const POST = withRouteHandler(async (req: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const parsed = await parseRequest(generateCopilotApiKeyContract, req, {})
if (!parsed.success) return parsed.response
const { name } = parsed.data.body
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId, name }),
spanName: 'sim → go /api/validate-key/generate',
operation: 'generate_api_key',
attributes: { [TraceAttr.UserId]: userId },
})
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to generate copilot API key' },
{ status: res.status || 500 }
)
}
const data = (await res.json().catch(() => null)) as { apiKey?: string; id?: string } | null
if (!data?.apiKey) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
}
return NextResponse.json(
{ success: true, key: { id: data?.id || 'new', apiKey: data.apiKey } },
{ status: 201 }
)
} catch (error) {
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
}
})
@@ -0,0 +1,395 @@
/**
* Tests for copilot api-keys API route
*
* @vitest-environment node
*/
import { authMockFns, createEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockGetMothershipBaseURL: vi.fn(),
}))
vi.mock('@/lib/copilot/constants', () => ({
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
SIM_AGENT_API_URL: 'https://agent.sim.example.com',
COPILOT_MODES: ['ask', 'build', 'plan'] as const,
COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const,
}))
vi.mock('@/lib/copilot/server/agent-url', () => ({
getMothershipBaseURL: mockGetMothershipBaseURL,
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' }))
import { DELETE, GET } from '@/app/api/copilot/api-keys/route'
// `fetchGo` reads `response.status` and `response.headers.get('content-length')`
// to stamp span attributes, so mock responses need both fields or the call
// path throws before the route handler sees the body.
function buildMockResponse(init: {
ok: boolean
status?: number
json: () => Promise<unknown>
}): Record<string, unknown> {
return {
ok: init.ok,
status: init.status ?? (init.ok ? 200 : 500),
headers: new Headers(),
json: init.json,
}
}
describe('Copilot API Keys API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com')
global.fetch = mockFetch
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return list of API keys with masked values', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
const mockApiKeys = [
{
id: 'key-1',
apiKey: 'sk-sim-abcdefghijklmnopqrstuv',
name: 'Production Key',
createdAt: '2024-01-01T00:00:00.000Z',
lastUsed: '2024-01-15T00:00:00.000Z',
},
{
id: 'key-2',
apiKey: 'sk-sim-zyxwvutsrqponmlkjihgfe',
name: null,
createdAt: '2024-01-02T00:00:00.000Z',
lastUsed: null,
},
]
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve(mockApiKeys),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.keys).toHaveLength(2)
expect(responseData.keys[0].id).toBe('key-1')
expect(responseData.keys[0].displayKey).toBe('•••••qrstuv')
expect(responseData.keys[0].name).toBe('Production Key')
expect(responseData.keys[1].displayKey).toBe('•••••jihgfe')
expect(responseData.keys[1].name).toBeNull()
})
it('should return empty array when user has no API keys', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve([]),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.keys).toEqual([])
})
it('should forward userId to Sim Agent', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve([]),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
await GET(request)
expect(mockFetch).toHaveBeenCalledWith(
'https://agent.sim.example.com/api/validate-key/get-api-keys',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-api-key': 'test-api-key',
}),
body: JSON.stringify({ userId: 'user-123' }),
})
)
})
it('should return error when Sim Agent returns non-ok response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: false,
status: 503,
json: () => Promise.resolve({ error: 'Service unavailable' }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(503)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to get keys' })
})
it('should return 500 when Sim Agent returns invalid response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve({ invalid: 'response' }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
it('should handle network errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to get keys' })
})
it('should handle API keys with empty apiKey string', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
const mockApiKeys = [
{
id: 'key-1',
apiKey: '',
name: 'Empty Key',
createdAt: '2024-01-01T00:00:00.000Z',
lastUsed: null,
},
]
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve(mockApiKeys),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.keys[0].displayKey).toBe('•••••')
})
it('should handle JSON parsing errors from Sim Agent', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.reject(new Error('Invalid JSON')),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await GET(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
})
describe('DELETE', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 when id parameter is missing', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys')
const response = await DELETE(request)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'id is required' })
})
it('should successfully delete an API key', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve({ success: true }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({ success: true })
expect(mockFetch).toHaveBeenCalledWith(
'https://agent.sim.example.com/api/validate-key/delete',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-api-key': 'test-api-key',
}),
body: JSON.stringify({ userId: 'user-123', apiKeyId: 'key-123' }),
})
)
})
it('should return error when Sim Agent returns non-ok response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: false,
status: 404,
json: () => Promise.resolve({ error: 'Key not found' }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=non-existent')
const response = await DELETE(request)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to delete key' })
})
it('should return 500 when Sim Agent returns invalid response', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.resolve({ success: false }),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
it('should handle network errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Failed to delete key' })
})
it('should handle JSON parsing errors from Sim Agent on delete', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
mockFetch.mockResolvedValueOnce(
buildMockResponse({
ok: true,
json: () => Promise.reject(new Error('Invalid JSON')),
})
)
const request = new NextRequest('http://localhost:3000/api/copilot/api-keys?id=key-123')
const response = await DELETE(request)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Invalid response from Sim Agent' })
})
})
})
+105
View File
@@ -0,0 +1,105 @@
import { type NextRequest, NextResponse } from 'next/server'
import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts'
import { getSession } from '@/lib/auth'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId }),
spanName: 'sim → go /api/validate-key/get-api-keys',
operation: 'get_api_keys',
attributes: { [TraceAttr.UserId]: userId },
})
if (!res.ok) {
return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 })
}
const apiKeys = (await res.json().catch(() => null)) as
| { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[]
| null
if (!Array.isArray(apiKeys)) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
}
const keys = apiKeys.map((k) => {
const value = typeof k.apiKey === 'string' ? k.apiKey : ''
const last6 = value.slice(-6)
const displayKey = `•••••${last6}`
return {
id: k.id,
displayKey,
name: k.name || null,
createdAt: k.createdAt || null,
lastUsed: k.lastUsed || null,
}
})
return NextResponse.json({ keys }, { status: 200 })
} catch (error) {
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
}
})
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const queryResult = deleteCopilotApiKeyQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams)
)
if (!queryResult.success) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
const { id } = queryResult.data
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({ userId, apiKeyId: id }),
spanName: 'sim → go /api/validate-key/delete',
operation: 'delete_api_key',
attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ApiKeyId]: id },
})
if (!res.ok) {
return NextResponse.json({ error: 'Failed to delete key' }, { status: res.status || 500 })
}
const data = (await res.json().catch(() => null)) as { success?: boolean } | null
if (!data?.success) {
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
}
})
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockFlags,
mockDbLimit,
mockCheckInternalApiKey,
mockCheckServerSideUsageLimits,
mockCheckOrgMemberUsageLimit,
} = vi.hoisted(() => ({
mockFlags: { isHosted: true },
mockDbLimit: vi.fn(),
mockCheckInternalApiKey: vi.fn(),
mockCheckServerSideUsageLimits: vi.fn(),
mockCheckOrgMemberUsageLimit: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: () => ({ from: () => ({ where: () => ({ limit: mockDbLimit }) }) }),
},
}))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkServerSideUsageLimits: mockCheckServerSideUsageLimits,
checkOrgMemberUsageLimit: mockCheckOrgMemberUsageLimit,
}))
vi.mock('@/lib/copilot/request/http', () => ({
checkInternalApiKey: mockCheckInternalApiKey,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withIncomingGoSpan: (
_headers: unknown,
_span: unknown,
_attrs: unknown,
fn: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isHosted() {
return mockFlags.isHosted
},
}))
import { POST } from '@/app/api/copilot/api-keys/validate/route'
function request(body: Record<string, unknown>) {
return createMockRequest('POST', body, { 'x-api-key': 'internal' })
}
describe('POST /api/copilot/api-keys/validate — per-member enforcement', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.isHosted = true
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockDbLimit.mockResolvedValue([{ id: 'user-1' }])
mockCheckServerSideUsageLimits.mockResolvedValue({
isExceeded: false,
currentUsage: 0,
limit: 100,
})
mockCheckOrgMemberUsageLimit.mockResolvedValue({
isExceeded: false,
currentUsage: 0,
limit: null,
})
})
it('returns 402 when the pooled/personal limit is exceeded (existing behavior)', async () => {
mockCheckServerSideUsageLimits.mockResolvedValue({
isExceeded: true,
currentUsage: 200,
limit: 100,
})
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(402)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
it('returns 402 when the per-member org-workspace cap is exceeded', async () => {
mockCheckOrgMemberUsageLimit.mockResolvedValue({
isExceeded: true,
currentUsage: 5,
limit: 4,
})
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(402)
expect(mockCheckOrgMemberUsageLimit).toHaveBeenCalledWith('user-1', 'ws-1')
})
it('returns 200 when under both limits', async () => {
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(200)
})
it('rejects with 400 when workspaceId is omitted (contract-required, fail closed)', async () => {
const res = await POST(request({ userId: 'user-1' }))
expect(res.status).toBe(400)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
it('skips the per-member check when not hosted', async () => {
mockFlags.isHosted = false
const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' }))
expect(res.status).toBe(200)
expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,144 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
checkOrgMemberUsageLimit,
checkServerSideUsageLimits,
} from '@/lib/billing/calculations/usage-monitor'
import { CopilotValidateOutcome } 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 { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { isHosted } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotApiKeysValidate')
/**
* Incoming-from-Go: extracts traceparent so this handler's work shows up as
* a child of the Go-side `sim.validate_api_key` span in the same trace. If
* there's no traceparent (manual curl / browser), the helper falls back to a
* new root span.
*/
export const POST = withRouteHandler((req: NextRequest) =>
withIncomingGoSpan(
req.headers,
TraceSpan.CopilotAuthValidateApiKey,
{
[TraceAttr.HttpMethod]: 'POST',
[TraceAttr.HttpRoute]: '/api/copilot/api-keys/validate',
},
async (span) => {
try {
const auth = checkInternalApiKey(req)
if (!auth.success) {
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.InternalAuthFailed
)
span.setAttribute(TraceAttr.HttpStatusCode, 401)
return new NextResponse(null, { status: 401 })
}
const parsed = await parseRequest(
validateCopilotApiKeyContract,
req,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid validation request', { errors: error.issues })
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.InvalidBody
)
span.setAttribute(TraceAttr.HttpStatusCode, 400)
return validationErrorResponse(error, 'userId is required')
},
invalidJsonResponse: () => {
logger.warn('Invalid validation request: invalid JSON')
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.InvalidBody
)
span.setAttribute(TraceAttr.HttpStatusCode, 400)
return NextResponse.json(
{ error: 'userId is required', details: [] },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const { userId, workspaceId } = parsed.data.body
span.setAttribute(TraceAttr.UserId, userId)
const [existingUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
if (!existingUser) {
logger.warn('[API VALIDATION] userId does not exist', { userId })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UserNotFound)
span.setAttribute(TraceAttr.HttpStatusCode, 403)
return NextResponse.json({ error: 'User not found' }, { status: 403 })
}
logger.info('[API VALIDATION] Validating usage limit', { userId })
const { isExceeded, currentUsage, limit } = await checkServerSideUsageLimits(userId)
span.setAttributes({
[TraceAttr.BillingUsageCurrent]: currentUsage,
[TraceAttr.BillingUsageLimit]: limit,
[TraceAttr.BillingUsageExceeded]: isExceeded,
})
logger.info('[API VALIDATION] Usage limit validated', {
userId,
currentUsage,
limit,
isExceeded,
})
if (isExceeded) {
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}
// Per-member org-workspace cap (hosted-only). Blocks the mothership/copilot
// chat request itself when the user is over their personal credit limit for
// the org that owns this workspace, independent of the pooled org limit.
// workspaceId is contract-required, so the gate can't be silently skipped.
if (isHosted) {
const memberCheck = await checkOrgMemberUsageLimit(userId, workspaceId)
if (memberCheck.isExceeded) {
logger.info('[API VALIDATION] Per-member org usage limit exceeded', {
userId,
workspaceId,
currentUsage: memberCheck.currentUsage,
limit: memberCheck.limit,
})
span.setAttribute(
TraceAttr.CopilotValidateOutcome,
CopilotValidateOutcome.UsageExceeded
)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}
}
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.Ok)
span.setAttribute(TraceAttr.HttpStatusCode, 200)
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('Error validating usage limit', { error })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InternalError)
span.setAttribute(TraceAttr.HttpStatusCode, 500)
return NextResponse.json({ error: 'Failed to validate usage' }, { status: 500 })
}
}
)
)
+121
View File
@@ -0,0 +1,121 @@
import { db } from '@sim/db'
import { settings, user } from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteCopilotByokKeyContract,
listCopilotByokKeysContract,
upsertCopilotByokKeyContract,
} from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
/**
* Enterprise BYOK key management for the current workspace's mothership.
*
* Unlike the cross-environment admin inspector (`/api/admin/mothership`), this
* talks to the SAME copilot the workspace's mothership actually runs on —
* `SIM_AGENT_API_URL` (local in dev, prod copilot in prod) — and authenticates
* with the hosted internal key (`COPILOT_API_KEY`), the exact credential
* mothership chat uses. Copilot requires that key (`SIM_AGENT_API_KEY`) and
* rejects self-hosted callers, so BYOK can only ever be written through our
* hosted Sim. The route is superuser-gated; the workspace id rides in the
* request and is resolved by the caller from the route.
*/
async function getAuthorizedSuperUserId(): Promise<string | null> {
const session = await getSession()
if (!session?.user?.id) return null
const [currentUser] = await db
.select({ role: user.role, superUserModeEnabled: settings.superUserModeEnabled })
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.id, session.user.id))
.limit(1)
const authorized = currentUser?.role === 'admin' && (currentUser.superUserModeEnabled ?? false)
return authorized ? session.user.id : null
}
async function forwardToCopilot(
method: 'GET' | 'POST' | 'DELETE',
query: URLSearchParams,
body?: string
) {
const headers: Record<string, string> = { ...getMothershipSourceEnvHeaders() }
if (env.COPILOT_API_KEY) headers['x-api-key'] = env.COPILOT_API_KEY
if (body !== undefined) headers['Content-Type'] = 'application/json'
const qs = query.toString()
const targetUrl = `${SIM_AGENT_API_URL}/api/admin/byok${qs ? `?${qs}` : ''}`
try {
const upstream = await fetch(targetUrl, {
method,
headers,
...(body !== undefined ? { body } : {}),
})
const text = await upstream.text()
// boundary-raw-fetch: copilot returns JSON; tolerate an empty body.
const data = text ? JSON.parse(text) : {}
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{ error: `Failed to reach copilot: ${getErrorMessage(error, 'Unknown error')}` },
{ status: 502 }
)
}
}
export const GET = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedSuperUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(listCopilotByokKeysContract, req, {})
if (!parsed.success) return parsed.response
return forwardToCopilot(
'GET',
new URLSearchParams({ workspaceId: parsed.data.query.workspaceId })
)
})
export const POST = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedSuperUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(upsertCopilotByokKeyContract, req, {})
if (!parsed.success) return parsed.response
// Bind the audit field to the authenticated superuser, ignoring any
// client-supplied createdBy so provisioning is always attributable.
const body = JSON.stringify({ ...parsed.data.body, createdBy: userId })
return forwardToCopilot('POST', new URLSearchParams(), body)
})
export const DELETE = withRouteHandler(async (req: NextRequest) => {
const userId = await getAuthorizedSuperUserId()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(deleteCopilotByokKeyContract, req, {})
if (!parsed.success) return parsed.response
return forwardToCopilot(
'DELETE',
new URLSearchParams({
workspaceId: parsed.data.query.workspaceId,
provider: parsed.data.query.provider,
})
)
})
@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { validateCopilotByokContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('CopilotByokValidate')
/**
* Authoritative entitlement gate for enterprise BYOK, called server-to-server by
* the mothership (Go) before it uses a workspace's own provider key. Gated by
* INTERNAL_API_SECRET — never exposed to the browser.
*
* Returns 200 when EITHER:
* - the requesting user is a superuser admin (platform admin with superuser
* mode on), who may use BYOK on any workspace for management/testing; OR
* - the user is a member of the workspace (prevents one org from causing
* another org's stored key to be used) AND the workspace is on an
* enterprise plan.
*
* Any other case returns 403 (not entitled) or 401 (bad internal auth). The Go
* caller fails closed to hosted keys on anything but a 200.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const auth = checkInternalApiKey(req)
if (!auth.success) {
return new NextResponse(null, { status: 401 })
}
const parsed = await parseRequest(validateCopilotByokContract, req, {})
if (!parsed.success) return parsed.response
const { workspaceId, userId } = parsed.data.body
try {
// Superuser admins may use BYOK on any workspace (management/testing).
const { effectiveSuperUser } = await verifyEffectiveSuperUser(userId)
if (effectiveSuperUser) {
return new NextResponse(null, { status: 200 })
}
// Everyone else must be a workspace member on an enterprise plan. The
// membership check prevents one org from using another org's stored key.
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
logger.warn('BYOK validate denied: user is not a member of the workspace', {
workspaceId,
userId,
})
return new NextResponse(null, { status: 403 })
}
const eligible = await isWorkspaceOnEnterprisePlan(workspaceId)
if (!eligible) {
logger.warn('BYOK validate denied: workspace is not on an enterprise plan', { workspaceId })
return new NextResponse(null, { status: 403 })
}
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('BYOK validation failed', { error, workspaceId })
return NextResponse.json({ error: 'Failed to validate BYOK entitlement' }, { status: 500 })
}
})
@@ -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 })
}
})
+209
View File
@@ -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')
}
})
+15
View File
@@ -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',
})
})
})
+102
View File
@@ -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')
}
})
@@ -0,0 +1,240 @@
/**
* Tests for copilot chats list API route
*
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSelectDistinctOn, mockFrom, mockLeftJoin, mockWhere, mockOrderBy } = vi.hoisted(() => ({
mockSelectDistinctOn: vi.fn(),
mockFrom: vi.fn(),
mockLeftJoin: vi.fn(),
mockWhere: vi.fn(),
mockOrderBy: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
selectDistinctOn: mockSelectDistinctOn,
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })),
inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })),
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
sql: vi.fn(),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/workspaces/utils', () => ({
listAccessibleWorkspaceRowsForUser: vi
.fn()
.mockResolvedValue([
{ workspace: { id: 'workspace-123', createdAt: new Date() }, permissionType: 'admin' },
]),
}))
import { GET } from '@/app/api/copilot/chats/route'
describe('Copilot Chats List API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSelectDistinctOn.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ leftJoin: mockLeftJoin })
mockLeftJoin.mockReturnValue({ leftJoin: mockLeftJoin, where: mockWhere })
mockWhere.mockReturnValue({ orderBy: mockOrderBy })
mockOrderBy.mockResolvedValue([])
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return empty chats array when user has no chats', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
mockOrderBy.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
chats: [],
})
})
it('should return list of chats for authenticated user', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'chat-1',
title: 'First Chat',
workflowId: 'workflow-1',
updatedAt: new Date('2024-01-02'),
},
{
id: 'chat-2',
title: 'Second Chat',
workflowId: 'workflow-2',
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.chats).toHaveLength(2)
expect(responseData.chats[0].id).toBe('chat-1')
expect(responseData.chats[0].title).toBe('First Chat')
expect(responseData.chats[1].id).toBe('chat-2')
})
it('should return chats ordered by updatedAt descending', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'newest-chat',
title: 'Newest',
workflowId: 'workflow-1',
updatedAt: new Date('2024-01-10'),
},
{
id: 'older-chat',
title: 'Older',
workflowId: 'workflow-2',
updatedAt: new Date('2024-01-05'),
},
{
id: 'oldest-chat',
title: 'Oldest',
workflowId: 'workflow-3',
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.chats[0].id).toBe('newest-chat')
expect(responseData.chats[2].id).toBe('oldest-chat')
})
it('should handle chats with null workflowId', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'chat-no-workflow',
title: 'Chat without workflow',
workflowId: null,
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.chats[0].workflowId).toBeNull()
})
it('should handle database errors gracefully', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
mockOrderBy.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to fetch user chats')
})
it('should only return chats belonging to authenticated user', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockChats = [
{
id: 'my-chat',
title: 'My Chat',
workflowId: 'workflow-1',
updatedAt: new Date('2024-01-01'),
},
]
mockOrderBy.mockResolvedValueOnce(mockChats)
const request = new Request('http://localhost:3000/api/copilot/chats')
await GET(request as any)
expect(mockSelectDistinctOn).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
})
it('should return 401 when userId is null despite isAuthenticated being true', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: true,
})
const request = new Request('http://localhost:3000/api/copilot/chats')
const response = await GET(request as any)
expect(response.status).toBe(401)
})
})
})
+151
View File
@@ -0,0 +1,151 @@
import { db } from '@sim/db'
import { copilotChats, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createForbiddenResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
const logger = createLogger('CopilotChatsListAPI')
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6'
export const GET = withRouteHandler(async (_request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
// Active accessible workspaces (explicit + org-derived). Using the active
// scope keeps the archived-workspace exclusion the old join-based query had.
const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId)
const accessibleWorkspaceIds = accessibleRows.map((row) => row.workspace.id)
const inAccessibleWorkspace =
accessibleWorkspaceIds.length > 0
? or(
inArray(workflow.workspaceId, accessibleWorkspaceIds),
and(
isNull(copilotChats.workflowId),
inArray(copilotChats.workspaceId, accessibleWorkspaceIds)
)
)
: undefined
const visibleChats = await db
.selectDistinctOn([copilotChats.id], {
id: copilotChats.id,
title: copilotChats.title,
workflowId: copilotChats.workflowId,
workspaceId: copilotChats.workspaceId,
activeStreamId: copilotChats.conversationId,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.leftJoin(workflow, eq(copilotChats.workflowId, workflow.id))
.where(
and(
eq(copilotChats.userId, userId),
or(
and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)),
inAccessibleWorkspace
),
or(isNull(workflow.id), isNull(workflow.archivedAt))
)
)
.orderBy(copilotChats.id, desc(copilotChats.updatedAt))
const sorted = [...visibleChats].sort(
(a, b) => new Date(b.updatedAt!).getTime() - new Date(a.updatedAt!).getTime()
)
logger.info(`Retrieved ${sorted.length} chats for user ${userId}`)
return NextResponse.json({ success: true, chats: sorted })
} catch (error) {
logger.error('Error fetching user copilot chats:', error)
return createInternalServerErrorResponse('Failed to fetch user chats')
}
})
/**
* POST /api/copilot/chats
* Creates an empty workflow-scoped copilot chat (same lifecycle as {@link resolveOrCreateChat}).
* Matches mothership's POST /api/mothership/chats pattern so the client always selects a real row id.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
createWorkflowCopilotChatContract,
request,
{},
{
validationErrorResponse: (error) =>
validationErrorResponse(error, 'workspaceId and workflowId are required'),
}
)
if (!parsed.success) return parsed.response
const { workspaceId, workflowId } = parsed.data.body
await assertActiveWorkspaceAccess(workspaceId, userId)
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed || !authorization.workflow) {
return NextResponse.json(
{ success: false, error: authorization.message ?? 'Forbidden' },
{ status: authorization.status }
)
}
if (authorization.workflow.workspaceId !== workspaceId) {
return createBadRequestResponse('workflow does not belong to this workspace')
}
const result = await resolveOrCreateChat({
userId,
workflowId,
workspaceId,
model: DEFAULT_COPILOT_MODEL,
type: 'copilot',
})
if (!result.chatId) {
return createInternalServerErrorResponse('Failed to create chat')
}
chatPubSub?.publishStatusChanged({ workspaceId, chatId: result.chatId, type: 'created' })
return NextResponse.json({ success: true, id: result.chatId })
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
logger.error('Error creating workflow copilot chat:', error)
return createInternalServerErrorResponse('Failed to create chat')
}
})
@@ -0,0 +1,801 @@
/**
* Tests for copilot checkpoints revert API route
*
* @vitest-environment node
*/
import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSelect,
mockFrom,
mockWhere,
mockThen,
mockDelete,
mockDeleteWhere,
mockGetAccessibleCopilotChat,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockThen: vi.fn(),
mockDelete: vi.fn(),
mockDeleteWhere: vi.fn(),
mockGetAccessibleCopilotChat: vi.fn(),
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'),
getBaseDomain: vi.fn(() => 'localhost:3000'),
getEmailDomain: vi.fn(() => 'localhost:3000'),
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
delete: mockDelete,
},
}))
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/checkpoints/revert/route'
describe('Copilot Checkpoints Revert API Route', () => {
/** Queued results for successive `.then()` calls in the db select chain */
let thenResults: unknown[]
beforeEach(() => {
vi.clearAllMocks()
thenResults = []
authMockFns.mockGetSession.mockResolvedValue(null)
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
})
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ then: mockThen })
// Drizzle's .then() is a thenable: it receives a callback like (rows) => rows[0].
// We invoke the callback with our mock rows array so the route gets the expected value.
mockThen.mockImplementation((callback: (rows: unknown[]) => unknown) => {
const result = thenResults.shift()
if (result instanceof Error) {
return Promise.reject(result)
}
const rows = result === undefined ? [] : [result]
return Promise.resolve(callback(rows))
})
// Mock delete chain
mockDelete.mockReturnValue({ where: mockDeleteWhere })
mockDeleteWhere.mockResolvedValue(undefined)
mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' })
global.fetch = vi.fn()
vi.spyOn(Date, 'now').mockReturnValue(1640995200000)
const originalDate = Date
const buildDate = (args: any[]): Date => {
if (args.length === 0) {
return new originalDate('2024-01-01T00:00:00.000Z')
}
if (args.length === 1) {
return new originalDate(args[0])
}
return new originalDate(args[0], args[1], args[2], args[3], args[4], args[5], args[6])
}
vi.spyOn(global, 'Date').mockImplementation(
class {
constructor(...args: any[]) {
// biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning a real Date overrides the instance so `new Date(...)` yields a genuine Date the route can call .toISOString()/.getTime() on
return buildDate(args)
}
} as any
)
})
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
/** Helper to set authenticated state */
function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) {
authMockFns.mockGetSession.mockResolvedValue({ user })
}
/** Helper to set unauthenticated state */
function setUnauthenticated() {
authMockFns.mockGetSession.mockResolvedValue(null)
}
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
setUnauthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
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 checkpointId', async () => {
setAuthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(typeof responseData.error).toBe('string')
})
it('should return 400 for empty checkpointId', async () => {
setAuthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: '' }),
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(typeof responseData.error).toBe('string')
})
it('should return 404 when checkpoint is not found', async () => {
setAuthenticated()
// Mock checkpoint not found
thenResults.push(undefined)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'non-existent-checkpoint' }),
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Checkpoint not found or access denied')
})
it('should return 404 when checkpoint belongs to different user', async () => {
setAuthenticated()
// Mock checkpoint not found (due to user mismatch in query)
thenResults.push(undefined)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'other-user-checkpoint' }),
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Checkpoint not found or access denied')
})
it('should return 404 when workflow is not found', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c6',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(undefined) // Workflow not found
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(404)
const responseData = await response.json()
expect(responseData.error).toBe('Workflow not found')
})
it('should return 401 when workflow belongs to different user', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7',
userId: 'different-user',
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(mockWorkflow) // Workflow found but different user
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
allowed: false,
status: 403,
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should successfully revert checkpoint with basic workflow state', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
userId: 'user-123',
workflowState: {
blocks: { block1: { type: 'start' } },
edges: [{ from: 'block1', to: 'block2' }],
loops: {},
parallels: {},
isDeployed: true,
},
}
const mockWorkflow = {
id: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
userId: 'user-123',
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(mockWorkflow) // Workflow found
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session',
},
body: JSON.stringify({
checkpointId: 'checkpoint-123',
}),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8',
checkpointId: 'checkpoint-123',
revertedAt: '2024-01-01T00:00:00.000Z',
checkpoint: {
id: 'checkpoint-123',
workflowState: {
blocks: { block1: { type: 'start' } },
edges: [{ from: 'block1', to: 'block2' }],
loops: {},
parallels: {},
isDeployed: true,
lastSaved: 1640995200000,
},
},
})
// Verify fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/workflows/c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8/state',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session',
},
body: JSON.stringify({
blocks: { block1: { type: 'start' } },
edges: [{ from: 'block1', to: 'block2' }],
loops: {},
parallels: {},
isDeployed: true,
lastSaved: 1640995200000,
}),
}
)
})
it('should handle checkpoint state with valid deployedAt date', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-with-date',
workflowId: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9',
userId: 'user-123',
workflowState: {
blocks: {},
edges: [],
deployedAt: '2024-01-01T12:00:00.000Z',
isDeployed: true,
},
}
const mockWorkflow = {
id: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-with-date' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.checkpoint.workflowState.deployedAt).toBeDefined()
expect(responseData.checkpoint.workflowState.deployedAt).toEqual('2024-01-01T12:00:00.000Z')
})
it('should handle checkpoint state with invalid deployedAt date', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-invalid-date',
workflowId: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0',
userId: 'user-123',
workflowState: {
blocks: {},
edges: [],
deployedAt: 'invalid-date',
isDeployed: true,
},
}
const mockWorkflow = {
id: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-invalid-date' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
// Invalid date should be filtered out
expect(responseData.checkpoint.workflowState.deployedAt).toBeUndefined()
})
it('should handle checkpoint state with null/undefined values', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-null-values',
workflowId: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1',
userId: 'user-123',
workflowState: {
blocks: null,
edges: undefined,
loops: null,
parallels: undefined,
},
}
const mockWorkflow = {
id: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-null-values' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
// Null/undefined values should be replaced with defaults
expect(responseData.checkpoint.workflowState).toEqual({
blocks: {},
edges: [],
loops: {},
parallels: {},
isDeployed: false,
lastSaved: 1640995200000,
})
})
it('should return 500 when state API call fails', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: false,
text: () => Promise.resolve('State validation failed'),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert workflow to checkpoint')
})
it('should handle database errors during checkpoint lookup', async () => {
setAuthenticated()
// Mock database error
thenResults.push(new Error('Database connection failed'))
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should handle database errors during workflow lookup', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'b8c9d0e1-f2a3-4b45-a6d7-e8f9a0b1c2d3',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
thenResults.push(mockCheckpoint) // Checkpoint found
thenResults.push(new Error('Database error during workflow lookup')) // Workflow lookup fails
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should handle fetch network errors', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-123' }),
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to revert to checkpoint')
})
it('should handle JSON parsing errors in request body', async () => {
setAuthenticated()
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
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 revert to checkpoint')
})
it('should forward cookies to state API call', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session; auth=token123',
},
body: JSON.stringify({
checkpointId: 'checkpoint-123',
}),
})
await POST(req)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/workflows/d0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5/state',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: 'session=test-session; auth=token123',
},
body: expect.any(String),
}
)
})
it('should handle missing cookies gracefully', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-123',
workflowId: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6',
userId: 'user-123',
workflowState: { blocks: {}, edges: [] },
}
const mockWorkflow = {
id: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// No Cookie header
},
body: JSON.stringify({
checkpointId: 'checkpoint-123',
}),
})
const response = await POST(req)
expect(response.status).toBe(200)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/workflows/e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6/state',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: '', // Empty string when no cookies
},
body: expect.any(String),
}
)
})
it('should handle complex checkpoint state with all fields', async () => {
setAuthenticated()
const mockCheckpoint = {
id: 'checkpoint-complex',
workflowId: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7',
userId: 'user-123',
workflowState: {
blocks: {
start: { type: 'start', config: {} },
http: { type: 'http', config: { url: 'https://api.example.com' } },
end: { type: 'end', config: {} },
},
edges: [
{ from: 'start', to: 'http' },
{ from: 'http', to: 'end' },
],
loops: {
loop1: { condition: 'true', iterations: 3 },
},
parallels: {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deployedAt: '2024-01-01T10:00:00.000Z',
},
}
const mockWorkflow = {
id: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7',
userId: 'user-123',
}
thenResults.push(mockCheckpoint)
thenResults.push(mockWorkflow)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checkpointId: 'checkpoint-complex' }),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.checkpoint.workflowState).toEqual({
blocks: {
start: { type: 'start', config: {} },
http: { type: 'http', config: { url: 'https://api.example.com' } },
end: { type: 'end', config: {} },
},
edges: [
{ from: 'start', to: 'http' },
{ from: 'http', to: 'end' },
],
loops: {
loop1: { condition: 'true', iterations: 3 },
},
parallels: {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deployedAt: '2024-01-01T10:00:00.000Z',
lastSaved: 1640995200000,
})
})
})
})
@@ -0,0 +1,176 @@
import { db } from '@sim/db'
import { workflowCheckpoints, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot'
import type { CleanedWorkflowState } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createNotFoundResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isUuidV4 } from '@/executor/constants'
const logger = createLogger('CheckpointRevertAPI')
/**
* POST /api/copilot/checkpoints/revert
* Revert workflow to a specific checkpoint state
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
revertCopilotCheckpointContract,
request,
{},
{
invalidJson: 'throw',
}
)
if (!parsed.success) return parsed.response
const { checkpointId } = parsed.data.body
logger.info(`[${tracker.requestId}] Reverting to checkpoint ${checkpointId}`)
const checkpoint = await db
.select()
.from(workflowCheckpoints)
.where(and(eq(workflowCheckpoints.id, checkpointId), eq(workflowCheckpoints.userId, userId)))
.then((rows) => rows[0])
if (!checkpoint) {
return createNotFoundResponse('Checkpoint not found or access denied')
}
const chat = await getAccessibleCopilotChatAuth(checkpoint.chatId, userId)
if (!chat) {
return createNotFoundResponse('Checkpoint not found or access denied')
}
const workflowData = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, checkpoint.workflowId))
.then((rows) => rows[0])
if (!workflowData) {
return createNotFoundResponse('Workflow not found')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: checkpoint.workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return createUnauthorizedResponse()
}
const checkpointState: Record<string, unknown> =
checkpoint.workflowState && typeof checkpoint.workflowState === 'object'
? (checkpoint.workflowState as Record<string, unknown>)
: {}
const rawBlocks = checkpointState.blocks
const rawEdges = checkpointState.edges
const rawLoops = checkpointState.loops
const rawParallels = checkpointState.parallels
const rawDeployedAt = checkpointState.deployedAt
const parsedDeployedAt =
rawDeployedAt === null || rawDeployedAt === undefined
? null
: new Date(rawDeployedAt as string | number | Date)
const cleanedState: CleanedWorkflowState = {
blocks: (rawBlocks ?? {}) as Record<string, unknown>,
edges: (rawEdges ?? []) as unknown[],
loops: (rawLoops ?? {}) as Record<string, unknown>,
parallels: (rawParallels ?? {}) as Record<string, unknown>,
isDeployed: Boolean(checkpointState.isDeployed),
lastSaved: Date.now(),
...(parsedDeployedAt && !Number.isNaN(parsedDeployedAt.getTime())
? { deployedAt: parsedDeployedAt }
: {}),
}
logger.info(`[${tracker.requestId}] Applying cleaned checkpoint state`, {
blocksCount: Object.keys(cleanedState.blocks).length,
edgesCount: cleanedState.edges.length,
hasDeployedAt: !!cleanedState.deployedAt,
isDeployed: cleanedState.isDeployed,
})
if (!isUuidV4(checkpoint.workflowId)) {
logger.error(`[${tracker.requestId}] Invalid workflow ID format`)
return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 })
}
const stateResponse = await fetch(
`${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: request.headers.get('Cookie') || '',
},
body: JSON.stringify(cleanedState),
}
)
if (!stateResponse.ok) {
const errorData = await stateResponse.text()
logger.error(`[${tracker.requestId}] Failed to apply checkpoint state: ${errorData}`)
return NextResponse.json(
{ error: 'Failed to revert workflow to checkpoint' },
{ status: 500 }
)
}
const result = await stateResponse.json()
logger.info(
`[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}`
)
// Delete the checkpoint after successfully reverting to it
try {
await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId))
logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId })
} catch (deleteError) {
logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, {
checkpointId,
error: deleteError,
})
// Don't fail the request if deletion fails - the revert was successful
}
return NextResponse.json({
success: true,
workflowId: checkpoint.workflowId,
checkpointId,
revertedAt: new Date().toISOString(),
checkpoint: {
id: checkpoint.id,
workflowState: cleanedState,
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Error reverting to checkpoint:`, error)
return createInternalServerErrorResponse('Failed to revert to checkpoint')
}
})
@@ -0,0 +1,388 @@
/**
* Tests for copilot checkpoints API route
*
* @vitest-environment node
*/
import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSelect,
mockFrom,
mockWhere,
mockLimit,
mockOrderBy,
mockInsert,
mockValues,
mockReturning,
mockGetAccessibleCopilotChat,
} = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockLimit: vi.fn(),
mockOrderBy: vi.fn(),
mockInsert: vi.fn(),
mockValues: vi.fn(),
mockReturning: vi.fn(),
mockGetAccessibleCopilotChat: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
desc: vi.fn((field: unknown) => ({ field, type: 'desc' })),
}))
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChat: mockGetAccessibleCopilotChat,
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { GET, POST } from './route'
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/checkpoints', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('Copilot Checkpoints API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue(null)
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({
orderBy: mockOrderBy,
limit: mockLimit,
})
mockOrderBy.mockResolvedValue([])
mockLimit.mockResolvedValue([])
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
mockGetAccessibleCopilotChat.mockResolvedValue({
id: 'chat-123',
userId: 'user-123',
workflowId: 'workflow-123',
})
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
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', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(typeof responseData.error).toBe('string')
})
it('should return 400 when chat not found or unauthorized', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockGetAccessibleCopilotChat.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Chat not found or unauthorized')
})
it('should return 400 for invalid workflow state JSON', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: 'invalid-json',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('Invalid workflow state JSON')
})
it('should successfully create a checkpoint', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const checkpoint = {
id: 'checkpoint-123',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
mockReturning.mockResolvedValue([checkpoint])
const workflowState = { blocks: [], connections: [] }
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
workflowState: JSON.stringify(workflowState),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
checkpoint: {
id: 'checkpoint-123',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
})
expect(mockInsert).toHaveBeenCalled()
expect(mockValues).toHaveBeenCalledWith({
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-123',
workflowState: workflowState,
})
})
it('should create checkpoint without messageId', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const checkpoint = {
id: 'checkpoint-123',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: undefined,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
mockReturning.mockResolvedValue([checkpoint])
const workflowState = { blocks: [] }
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: JSON.stringify(workflowState),
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.checkpoint.messageId).toBeUndefined()
})
it('should handle database errors during checkpoint creation', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockReturning.mockRejectedValue(new Error('Database insert failed'))
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to create checkpoint')
})
it('should handle database errors during chat lookup', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockGetAccessibleCopilotChat.mockRejectedValueOnce(new Error('Database query failed'))
const req = createMockRequest('POST', {
workflowId: 'workflow-123',
chatId: 'chat-123',
workflowState: '{"blocks": []}',
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to create checkpoint')
})
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return 400 when chatId is missing', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints')
const response = await GET(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toBe('chatId is required')
})
it('should return checkpoints for authenticated user and chat', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
const mockCheckpoints = [
{
id: 'checkpoint-1',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-1',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
{
id: 'checkpoint-2',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-2',
createdAt: new Date('2024-01-02'),
updatedAt: new Date('2024-01-02'),
},
]
mockOrderBy.mockResolvedValue(mockCheckpoints)
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
checkpoints: [
{
id: 'checkpoint-1',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'checkpoint-2',
userId: 'user-123',
workflowId: 'workflow-123',
chatId: 'chat-123',
messageId: 'message-2',
createdAt: '2024-01-02T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
},
],
})
expect(mockSelect).toHaveBeenCalled()
expect(mockWhere).toHaveBeenCalled()
expect(mockOrderBy).toHaveBeenCalled()
})
it('should handle database errors when fetching checkpoints', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockOrderBy.mockRejectedValue(new Error('Database query failed'))
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to fetch checkpoints')
})
it('should return empty array when no checkpoints found', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
mockOrderBy.mockResolvedValue([])
const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123')
const response = await GET(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData).toEqual({
success: true,
checkpoints: [],
})
})
})
})
@@ -0,0 +1,192 @@
import { db } from '@sim/db'
import { workflowCheckpoints } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
createCopilotCheckpointContract,
listCopilotCheckpointsContract,
} from '@/lib/api/contracts/copilot'
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('WorkflowCheckpointsAPI')
/**
* POST /api/copilot/checkpoints
* Create a new checkpoint with JSON workflow state
*/
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(
createCopilotCheckpointContract,
req,
{},
{
validationErrorResponse: (error) =>
validationErrorResponse(
error,
getValidationErrorMessage(error, 'Invalid checkpoint payload')
),
}
)
if (!parsed.success) return parsed.response
const { workflowId, chatId, messageId, workflowState } = parsed.data.body
logger.info(`[${tracker.requestId}] Creating workflow checkpoint`, {
userId,
workflowId,
chatId,
messageId,
parsedData: { workflowId, chatId, messageId },
messageIdType: typeof messageId,
messageIdExists: !!messageId,
})
// Verify that the chat belongs to the user
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat) {
return createBadRequestResponse('Chat not found or unauthorized')
}
if (chat.workflowId !== workflowId) {
return createBadRequestResponse('Chat does not belong to the requested workflow')
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return createUnauthorizedResponse()
}
// Parse the workflow state to validate it's valid JSON
let parsedWorkflowState
try {
parsedWorkflowState = JSON.parse(workflowState)
} catch (error) {
return createBadRequestResponse('Invalid workflow state JSON')
}
// Create checkpoint with JSON workflow state
const [checkpoint] = await db
.insert(workflowCheckpoints)
.values({
userId,
workflowId,
chatId,
messageId,
workflowState: parsedWorkflowState, // Store as JSON object
})
.returning()
logger.info(`[${tracker.requestId}] Workflow checkpoint created successfully`, {
checkpointId: checkpoint.id,
savedData: {
checkpointId: checkpoint.id,
userId: checkpoint.userId,
workflowId: checkpoint.workflowId,
chatId: checkpoint.chatId,
messageId: checkpoint.messageId,
createdAt: checkpoint.createdAt,
},
})
return NextResponse.json({
success: true,
checkpoint: {
id: checkpoint.id,
userId: checkpoint.userId,
workflowId: checkpoint.workflowId,
chatId: checkpoint.chatId,
messageId: checkpoint.messageId,
createdAt: checkpoint.createdAt,
updatedAt: checkpoint.updatedAt,
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to create workflow checkpoint:`, error)
return createInternalServerErrorResponse('Failed to create checkpoint')
}
})
/**
* GET /api/copilot/checkpoints?chatId=xxx
* Retrieve workflow checkpoints for a chat
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
listCopilotCheckpointsContract,
req,
{},
{
validationErrorResponse: (error) =>
validationErrorResponse(error, getValidationErrorMessage(error)),
}
)
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.query
logger.info(`[${tracker.requestId}] Fetching workflow checkpoints for chat`, {
userId,
chatId,
})
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat) {
return createBadRequestResponse('Chat not found or unauthorized')
}
// Fetch checkpoints for this user and chat
const checkpoints = await db
.select({
id: workflowCheckpoints.id,
userId: workflowCheckpoints.userId,
workflowId: workflowCheckpoints.workflowId,
chatId: workflowCheckpoints.chatId,
messageId: workflowCheckpoints.messageId,
createdAt: workflowCheckpoints.createdAt,
updatedAt: workflowCheckpoints.updatedAt,
})
.from(workflowCheckpoints)
.where(and(eq(workflowCheckpoints.chatId, chatId), eq(workflowCheckpoints.userId, userId)))
.orderBy(desc(workflowCheckpoints.createdAt))
logger.info(`[${tracker.requestId}] Retrieved ${checkpoints.length} workflow checkpoints`)
return NextResponse.json({
success: true,
checkpoints,
})
} catch (error) {
logger.error(`[${tracker.requestId}] Failed to fetch workflow checkpoints:`, error)
return createInternalServerErrorResponse('Failed to fetch checkpoints')
}
})
@@ -0,0 +1,224 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
completeAsyncToolCall,
publishToolConfirmation,
} = vi.hoisted(() => ({
getAsyncToolCall: vi.fn(),
getRunSegment: vi.fn(),
upsertAsyncToolCall: vi.fn(),
completeAsyncToolCall: vi.fn(),
publishToolConfirmation: vi.fn(),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/copilot/async-runs/repository', () => ({
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
completeAsyncToolCall,
}))
vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({
publishToolConfirmation,
}))
import { POST } from './route'
describe('Copilot Confirm API Route', () => {
const existingRow = {
toolCallId: 'tool-call-123',
runId: 'run-1',
checkpointId: 'checkpoint-1',
toolName: 'client_tool',
args: { foo: 'bar' },
}
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
getAsyncToolCall.mockResolvedValue(existingRow)
getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1' })
upsertAsyncToolCall.mockResolvedValue(existingRow)
completeAsyncToolCall.mockResolvedValue(existingRow)
})
function createMockPostRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/confirm', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
it('returns 401 when the session is unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: null,
isAuthenticated: false,
})
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
})
)
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when the tool call row does not exist', async () => {
getAsyncToolCall.mockResolvedValue(null)
const response = await POST(
createMockPostRequest({
toolCallId: 'missing-tool',
status: 'success',
})
)
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'Tool call not found' })
})
it('returns 403 when the tool call belongs to a different user', async () => {
getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-2' })
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
})
)
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'Forbidden' })
})
it('persists terminal confirmations through completeAsyncToolCall', async () => {
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
message: 'Tool executed successfully',
data: { ok: true },
})
)
expect(response.status).toBe(200)
expect(completeAsyncToolCall).toHaveBeenCalledWith({
toolCallId: 'tool-call-123',
status: 'completed',
result: { ok: true },
error: null,
})
expect(upsertAsyncToolCall).not.toHaveBeenCalled()
expect(publishToolConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-123',
status: 'success',
data: { ok: true },
})
)
})
it('accepts primitive terminal confirmation data', async () => {
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
message: 'Tool executed successfully',
data: 'done',
})
)
expect(response.status).toBe(200)
expect(completeAsyncToolCall).toHaveBeenCalledWith({
toolCallId: 'tool-call-123',
status: 'completed',
result: 'done',
error: null,
})
expect(publishToolConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-123',
status: 'success',
data: 'done',
})
)
})
it('keeps background as a live pending detach confirmation', async () => {
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'background',
})
)
expect(response.status).toBe(200)
expect(upsertAsyncToolCall).not.toHaveBeenCalled()
expect(completeAsyncToolCall).not.toHaveBeenCalled()
expect(publishToolConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'tool-call-123',
status: 'background',
})
)
})
it('rejects unsupported accepted and rejected confirmation statuses', async () => {
const acceptedResponse = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'accepted',
})
)
expect(acceptedResponse.status).toBe(400)
expect(await acceptedResponse.json()).toMatchObject({
error: 'Invalid request data: Invalid notification status',
details: expect.any(Array),
})
const rejectedResponse = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'rejected',
})
)
expect(rejectedResponse.status).toBe(400)
expect(await rejectedResponse.json()).toMatchObject({
error: 'Invalid request data: Invalid notification status',
details: expect.any(Array),
})
})
it('returns 500 when the durable write fails before publish', async () => {
completeAsyncToolCall.mockRejectedValueOnce(new Error('db down'))
const response = await POST(
createMockPostRequest({
toolCallId: 'tool-call-123',
status: 'success',
})
)
expect(response.status).toBe(500)
expect(publishToolConfirmation).not.toHaveBeenCalled()
})
})
+217
View File
@@ -0,0 +1,217 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotConfirmContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
ASYNC_TOOL_STATUS,
type AsyncCompletionData,
type AsyncConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import {
completeAsyncToolCall,
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
} from '@/lib/copilot/async-runs/repository'
import { CopilotConfirmOutcome } 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 { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createNotFoundResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotConfirmAPI')
/**
* Persist terminal durable tool status, then publish a wakeup event.
*
* `background` remains a live detach signal in the current browser workflow
* runtime, so it should not rewrite the durable async row.
*/
async function updateToolCallStatus(
existing: NonNullable<Awaited<ReturnType<typeof getAsyncToolCall>>>,
status: AsyncConfirmationStatus,
message?: string,
data?: AsyncCompletionData
): Promise<boolean> {
const toolCallId = existing.toolCallId
if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
publishToolConfirmation({
toolCallId,
status,
message: message || undefined,
timestamp: new Date().toISOString(),
data,
})
return true
}
const durableStatus =
status === 'success'
? ASYNC_TOOL_STATUS.completed
: status === 'cancelled'
? ASYNC_TOOL_STATUS.cancelled
: status === 'error'
? ASYNC_TOOL_STATUS.failed
: ASYNC_TOOL_STATUS.pending
try {
if (
durableStatus === ASYNC_TOOL_STATUS.completed ||
durableStatus === ASYNC_TOOL_STATUS.failed ||
durableStatus === ASYNC_TOOL_STATUS.cancelled
) {
await completeAsyncToolCall({
toolCallId,
status: durableStatus,
result: data ?? null,
error: status === 'success' ? null : message || status,
})
} else if (existing.runId) {
await upsertAsyncToolCall({
runId: existing.runId,
checkpointId: existing.checkpointId ?? null,
toolCallId,
toolName: existing.toolName || 'client_tool',
args: (existing.args as Record<string, unknown> | null) ?? {},
status: durableStatus,
})
}
publishToolConfirmation({
toolCallId,
status,
message: message || undefined,
timestamp: new Date().toISOString(),
data,
})
return true
} catch (error) {
logger.error('Failed to update tool call status', {
toolCallId,
status,
error: toError(error).message,
})
return false
}
}
// POST /api/copilot/confirm — delivery path for client-executed tool
// results. Correlate via `toolCallId` when the awaiting chat stream
// stalls.
export const POST = withRouteHandler((req: NextRequest) => {
const tracker = createRequestTracker()
return withIncomingGoSpan(
req.headers,
TraceSpan.CopilotConfirmToolResult,
{ [TraceAttr.RequestId]: tracker.requestId },
async (span) => {
try {
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Unauthorized)
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
copilotConfirmContract,
req,
{},
{
validationErrorResponse: (error) => {
span.setAttribute(
TraceAttr.CopilotConfirmOutcome,
CopilotConfirmOutcome.ValidationError
)
return validationErrorResponse(
error,
`Invalid request data: ${error.issues.map((e) => e.message).join(', ')}`
)
},
}
)
if (!parsed.success) return parsed.response
const { toolCallId, status, message, data } = parsed.data.body
span.setAttributes({
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.ToolConfirmationStatus]: status,
[TraceAttr.UserId]: authenticatedUserId,
})
const existing = await getAsyncToolCall(toolCallId).catch((err) => {
logger.warn('Failed to fetch async tool call', {
toolCallId,
error: getErrorMessage(err),
})
return null
})
if (!existing) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound)
return createNotFoundResponse('Tool call not found')
}
if (existing.toolName) span.setAttribute(TraceAttr.ToolName, existing.toolName)
if (existing.runId) span.setAttribute(TraceAttr.RunId, existing.runId)
const run = await getRunSegment(existing.runId).catch((err) => {
logger.warn('Failed to fetch run segment', {
runId: existing.runId,
error: getErrorMessage(err),
})
return null
})
if (!run) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.RunNotFound)
return createNotFoundResponse('Tool call run not found')
}
if (run.userId !== authenticatedUserId) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Forbidden)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updated = await updateToolCallStatus(existing, status, message, data)
if (!updated) {
logger.error(`[${tracker.requestId}] Failed to update tool call status`, {
userId: authenticatedUserId,
toolCallId,
status,
internalStatus: status,
message,
})
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed)
// DB write failed — 500, not 400. 400 is a client-shape error.
return createInternalServerErrorResponse('Failed to update tool call status')
}
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered)
return NextResponse.json({
success: true,
message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`,
toolCallId,
status,
})
} catch (error) {
const duration = tracker.getDuration()
logger.error(`[${tracker.requestId}] Unexpected error:`, {
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
})
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.InternalError)
return createInternalServerErrorResponse(getErrorMessage(error, 'Internal server error'))
}
}
)
})
@@ -0,0 +1,35 @@
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotCredentialsContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { routeExecution } from '@/lib/copilot/tools/server/router'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
/**
* GET /api/copilot/credentials
* Returns connected OAuth credentials for the authenticated user.
* Used by the copilot store for credential masking.
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
const parsed = await parseRequest(copilotCredentialsContract, req, {})
if (!parsed.success) return parsed.response
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const result = await routeExecution('get_credentials', {}, { userId })
return NextResponse.json({ success: true, result })
} catch (error) {
return NextResponse.json(
{
success: false,
error: getErrorMessage(error, 'Failed to load credentials'),
},
{ status: 500 }
)
}
})
@@ -0,0 +1,405 @@
/**
* Tests for copilot feedback API route
*
* @vitest-environment node
*/
import {
copilotHttpMock,
copilotHttpMockFns,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
import { GET, POST } from '@/app/api/copilot/feedback/route'
function createMockRequest(method: string, body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/copilot/feedback', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('Copilot Feedback API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('POST', () => {
it('should return 401 when user is not authenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should successfully submit positive feedback', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const feedbackRecord = {
feedbackId: 'feedback-123',
userId: 'user-123',
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositive: true,
feedback: null,
workflowYaml: null,
createdAt: new Date('2024-01-01'),
}
dbChainMockFns.returning.mockResolvedValueOnce([feedbackRecord])
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedbackId).toBe('feedback-123')
expect(responseData.message).toBe('Feedback submitted successfully')
})
it('should successfully submit negative feedback with text', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const feedbackRecord = {
feedbackId: 'feedback-456',
userId: 'user-123',
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I deploy?',
agentResponse: 'Here is how to deploy...',
isPositive: false,
feedback: 'The response was not helpful',
workflowYaml: null,
createdAt: new Date('2024-01-01'),
}
dbChainMockFns.returning.mockResolvedValueOnce([feedbackRecord])
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I deploy?',
agentResponse: 'Here is how to deploy...',
isPositiveFeedback: false,
feedback: 'The response was not helpful',
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedbackId).toBe('feedback-456')
})
it('should successfully submit feedback with workflow YAML', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const workflowYaml = `
blocks:
- id: starter
type: starter
- id: agent
type: agent
edges:
- source: starter
target: agent
`
const feedbackRecord = {
feedbackId: 'feedback-789',
userId: 'user-123',
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'Build a simple agent workflow',
agentResponse: 'I created a workflow for you.',
isPositive: true,
feedback: null,
workflowYaml: workflowYaml,
createdAt: new Date('2024-01-01'),
}
dbChainMockFns.returning.mockResolvedValueOnce([feedbackRecord])
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'Build a simple agent workflow',
agentResponse: 'I created a workflow for you.',
isPositiveFeedback: true,
workflowYaml: workflowYaml,
})
const response = await POST(req)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
workflowYaml: workflowYaml,
})
)
})
it('should return 400 for invalid chatId format', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: 'not-a-uuid',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should return 400 for empty userQuery', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: '',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should return 400 for empty agentResponse', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: '',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should return 400 for missing isPositiveFeedback', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
})
const response = await POST(req)
expect(response.status).toBe(400)
const responseData = await response.json()
expect(responseData.error).toContain('Invalid request data')
})
it('should handle database errors gracefully', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database connection failed'))
const req = createMockRequest('POST', {
chatId: '550e8400-e29b-41d4-a716-446655440000',
userQuery: 'How do I create a workflow?',
agentResponse: 'You can create a workflow by...',
isPositiveFeedback: true,
})
const response = await POST(req)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to submit feedback')
})
it('should handle JSON parsing errors in request body', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const req = new NextRequest('http://localhost:3000/api/copilot/feedback', {
method: 'POST',
body: '{invalid-json',
headers: {
'Content-Type': 'application/json',
},
})
const response = await POST(req)
expect(response.status).toBe(500)
})
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({ error: 'Unauthorized' })
})
it('should return empty feedback array when no feedback exists', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.where.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toEqual([])
})
it('should only return feedback records for the authenticated user', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
const mockFeedback = [
{
feedbackId: 'feedback-1',
userId: 'user-123',
chatId: 'chat-1',
userQuery: 'Query 1',
agentResponse: 'Response 1',
isPositive: true,
feedback: null,
workflowYaml: null,
createdAt: new Date('2024-01-01'),
},
]
dbChainMockFns.where.mockResolvedValueOnce(mockFeedback)
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.success).toBe(true)
expect(responseData.feedback).toHaveLength(1)
expect(responseData.feedback[0].feedbackId).toBe('feedback-1')
expect(responseData.feedback[0].userId).toBe('user-123')
const { eq } = await import('drizzle-orm')
expect(dbChainMockFns.where).toHaveBeenCalled()
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
})
it('should handle database errors gracefully', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.where.mockRejectedValueOnce(new Error('Database connection failed'))
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(500)
const responseData = await response.json()
expect(responseData.error).toBe('Failed to retrieve feedback')
})
it('should return metadata with response', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: 'user-123',
isAuthenticated: true,
})
dbChainMockFns.where.mockResolvedValueOnce([])
const request = new Request('http://localhost:3000/api/copilot/feedback')
const response = await GET(request as any)
expect(response.status).toBe(200)
const responseData = await response.json()
expect(responseData.metadata).toBeDefined()
expect(responseData.metadata.requestId).toBeDefined()
expect(responseData.metadata.duration).toBeDefined()
})
})
})
+162
View File
@@ -0,0 +1,162 @@
import { db } from '@sim/db'
import { copilotFeedback } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { submitCopilotFeedbackContract } from '@/lib/api/contracts'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CopilotFeedbackAPI')
/**
* POST /api/copilot/feedback
* Submit feedback for a copilot interaction
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
// Authenticate user using the same pattern as other copilot routes
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
submitCopilotFeedbackContract,
req,
{},
{
invalidJson: 'throw',
validationErrorResponse: (error) => {
logger.error(`[${tracker.requestId}] Validation error:`, {
duration: tracker.getDuration(),
errors: error.issues,
})
return validationErrorResponse(error, 'Invalid request data')
},
}
)
if (!parsed.success) return parsed.response
const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } =
parsed.data.body
logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, {
userId: authenticatedUserId,
chatId,
isPositiveFeedback,
userQueryLength: userQuery.length,
agentResponseLength: agentResponse.length,
hasFeedback: !!feedback,
hasWorkflowYaml: !!workflowYaml,
workflowYamlLength: workflowYaml?.length || 0,
})
// Insert feedback into the database
const [feedbackRecord] = await db
.insert(copilotFeedback)
.values({
userId: authenticatedUserId,
chatId,
userQuery,
agentResponse,
isPositive: isPositiveFeedback,
feedback: feedback || null,
workflowYaml: workflowYaml || null,
})
.returning()
logger.info(`[${tracker.requestId}] Successfully saved copilot feedback`, {
feedbackId: feedbackRecord.feedbackId,
userId: authenticatedUserId,
isPositive: isPositiveFeedback,
duration: tracker.getDuration(),
})
captureServerEvent(authenticatedUserId, 'copilot_feedback_submitted', {
is_positive: isPositiveFeedback,
has_text_feedback: !!feedback,
has_workflow_yaml: !!workflowYaml,
})
return NextResponse.json({
success: true,
feedbackId: feedbackRecord.feedbackId,
message: 'Feedback submitted successfully',
metadata: {
requestId: tracker.requestId,
duration: tracker.getDuration(),
},
})
} catch (error) {
const duration = tracker.getDuration()
logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, {
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
})
return createInternalServerErrorResponse('Failed to submit feedback')
}
})
/**
* GET /api/copilot/feedback
* Get feedback records for the authenticated user
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
const tracker = createRequestTracker()
try {
// Authenticate user
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
// Get feedback records for the authenticated user only
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
userId: copilotFeedback.userId,
chatId: copilotFeedback.chatId,
userQuery: copilotFeedback.userQuery,
agentResponse: copilotFeedback.agentResponse,
isPositive: copilotFeedback.isPositive,
feedback: copilotFeedback.feedback,
workflowYaml: copilotFeedback.workflowYaml,
createdAt: copilotFeedback.createdAt,
})
.from(copilotFeedback)
.where(eq(copilotFeedback.userId, authenticatedUserId))
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
return NextResponse.json({
success: true,
feedback: feedbackRecords,
metadata: {
requestId: tracker.requestId,
duration: tracker.getDuration(),
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error)
return createInternalServerErrorResponse('Failed to retrieve feedback')
}
})
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('copilot methods route placeholder', () => {
it('loads test suite', () => {
expect(true).toBe(true)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotModelsContract } from '@/lib/api/contracts/copilot'
import { parseRequest } from '@/lib/api/server'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http'
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
interface AvailableModel {
id: string
friendlyName: string
provider: string
}
const logger = createLogger('CopilotModelsAPI')
interface RawAvailableModel {
id: string
friendlyName?: string
displayName?: string
provider?: string
}
function isRawAvailableModel(item: unknown): item is RawAvailableModel {
return (
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof (item as { id: unknown }).id === 'string'
)
}
export const GET = withRouteHandler(async (req: NextRequest) => {
const parsed = await parseRequest(copilotModelsContract, req, {})
if (!parsed.success) return parsed.response
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
try {
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const response = await fetchGo(`${mothershipBaseURL}/api/get-available-models`, {
method: 'GET',
headers,
cache: 'no-store',
spanName: 'sim → go /api/get-available-models',
operation: 'get_available_models',
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
logger.warn('Failed to fetch available models from copilot backend', {
status: response.status,
})
return NextResponse.json(
{
success: false,
error: payload?.error || 'Failed to fetch available models',
models: [],
},
{ status: response.status }
)
}
const rawModels = Array.isArray(payload?.models) ? payload.models : []
const models: AvailableModel[] = rawModels
.filter((item: unknown): item is RawAvailableModel => isRawAvailableModel(item))
.map((item: RawAvailableModel) => ({
id: item.id,
friendlyName: item.friendlyName || item.displayName || item.id,
provider: item.provider || 'unknown',
}))
return NextResponse.json({ success: true, models })
} catch (error) {
logger.error('Error fetching available models', {
error: toError(error).message,
})
return NextResponse.json(
{
success: false,
error: 'Failed to fetch available models',
models: [],
},
{ status: 500 }
)
}
})
@@ -0,0 +1,82 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotTrainingExampleContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotTrainingExamplesAPI')
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = checkInternalApiKey(request)
if (!auth.success) {
return createUnauthorizedResponse()
}
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')
return NextResponse.json({ error: 'Missing AGENT_INDEXER_URL env' }, { status: 500 })
}
const apiKey = env.AGENT_INDEXER_API_KEY
if (!apiKey) {
logger.error('Missing AGENT_INDEXER_API_KEY environment variable')
return NextResponse.json({ error: 'Missing AGENT_INDEXER_API_KEY env' }, { status: 500 })
}
try {
const parsed = await parseRequest(
copilotTrainingExampleContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid training example format', { errors: error.issues })
return validationErrorResponse(error, 'Invalid training example format')
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
logger.info('Sending workflow example to agent indexer', {
hasJsonField: typeof validatedData.json === 'string',
title: validatedData.title,
})
const upstream = await fetch(`${baseUrl}/examples/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify(validatedData),
})
if (!upstream.ok) {
const errorText = await upstream.text()
logger.error('Agent indexer rejected the example', {
status: upstream.status,
error: errorText,
})
return NextResponse.json({ error: errorText }, { status: upstream.status })
}
const data = await upstream.json()
logger.info('Successfully sent workflow example to agent indexer')
return NextResponse.json(data, {
headers: { 'content-type': 'application/json' },
})
} catch (err) {
const errorMessage = getErrorMessage(err, 'Failed to add example')
logger.error('Failed to send workflow example', { error: err })
return NextResponse.json({ error: errorMessage }, { status: 502 })
}
})
@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotTrainingDataContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotTrainingAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = checkInternalApiKey(request)
if (!auth.success) {
return createUnauthorizedResponse()
}
try {
const baseUrl = env.AGENT_INDEXER_URL
if (!baseUrl) {
logger.error('Missing AGENT_INDEXER_URL environment variable')
return NextResponse.json({ error: 'Agent indexer not configured' }, { status: 500 })
}
const apiKey = env.AGENT_INDEXER_API_KEY
if (!apiKey) {
logger.error('Missing AGENT_INDEXER_API_KEY environment variable')
return NextResponse.json(
{ error: 'Agent indexer authentication not configured' },
{ status: 500 }
)
}
const parsed = await parseRequest(
copilotTrainingDataContract,
request,
{},
{
validationErrorResponse: (error) => {
logger.warn('Invalid training data format', { errors: error.issues })
return validationErrorResponse(error, 'Invalid training data format')
},
}
)
if (!parsed.success) return parsed.response
const { title, prompt, input, output, operations } = parsed.data.body
logger.info('Sending training data to agent indexer', {
title,
operationsCount: operations.length,
})
const upstreamUrl = `${baseUrl}/operations/add`
const upstreamResponse = await fetch(upstreamUrl, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'content-type': 'application/json',
},
body: JSON.stringify({
title,
prompt,
input,
output,
operations: { operations },
}),
})
const responseData = await upstreamResponse.json()
if (!upstreamResponse.ok) {
logger.error('Agent indexer rejected the data', {
status: upstreamResponse.status,
response: responseData,
})
return NextResponse.json(responseData, { status: upstreamResponse.status })
}
logger.info('Successfully sent training data to agent indexer', {
title,
response: responseData,
})
return NextResponse.json(responseData)
} catch (error) {
logger.error('Failed to send training data to agent indexer', { error })
return NextResponse.json(
{
error: getErrorMessage(error, 'Failed to send training data'),
},
{ status: 502 }
)
}
})