d25d482dc2
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
450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
import {
|
|
authMockFns,
|
|
permissionsMock,
|
|
permissionsMockFns,
|
|
workflowsUtilsMock,
|
|
workflowsUtilsMockFns,
|
|
} from '@sim/testing'
|
|
import { NextRequest } from 'next/server'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser
|
|
const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
|
|
|
|
const {
|
|
getEffectiveDecryptedEnv,
|
|
generateWorkspaceSnapshot,
|
|
processContextsServer,
|
|
resolveActiveResourceContext,
|
|
buildCopilotRequestPayload,
|
|
createSSEStream,
|
|
acquirePendingChatStream,
|
|
getPendingChatStreamId,
|
|
releasePendingChatStream,
|
|
resolveOrCreateChat,
|
|
finalizeAssistantTurn,
|
|
appendCopilotChatMessages,
|
|
mockPublishStatusChanged,
|
|
} = vi.hoisted(() => ({
|
|
getEffectiveDecryptedEnv: vi.fn(),
|
|
generateWorkspaceSnapshot: vi.fn(),
|
|
processContextsServer: vi.fn(),
|
|
resolveActiveResourceContext: vi.fn(),
|
|
buildCopilotRequestPayload: vi.fn(),
|
|
createSSEStream: vi.fn(),
|
|
acquirePendingChatStream: vi.fn(),
|
|
getPendingChatStreamId: vi.fn(),
|
|
releasePendingChatStream: vi.fn(),
|
|
resolveOrCreateChat: vi.fn(),
|
|
finalizeAssistantTurn: vi.fn(),
|
|
appendCopilotChatMessages: vi.fn(),
|
|
mockPublishStatusChanged: vi.fn(),
|
|
}))
|
|
|
|
const getSession = authMockFns.mockGetSession
|
|
|
|
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
|
|
|
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
|
|
|
vi.mock('@/lib/environment/utils', () => ({
|
|
getEffectiveDecryptedEnv,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat/workspace-context', () => ({
|
|
generateWorkspaceSnapshot,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat/process-contents', () => ({
|
|
processContextsServer,
|
|
resolveActiveResourceContext,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat/payload', () => ({
|
|
buildCopilotRequestPayload,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/request/lifecycle/start', () => ({
|
|
createSSEStream,
|
|
SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' },
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/request/session', () => ({
|
|
acquirePendingChatStream,
|
|
getPendingChatStreamId,
|
|
releasePendingChatStream,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
|
|
resolveOrCreateChat,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat/terminal-state', () => ({
|
|
finalizeAssistantTurn,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat/messages-store', () => ({
|
|
appendCopilotChatMessages,
|
|
}))
|
|
|
|
vi.mock('@/lib/copilot/chat-status', () => ({
|
|
chatPubSub: {
|
|
publishStatusChanged: mockPublishStatusChanged,
|
|
},
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => {
|
|
const update = vi.fn(() => ({
|
|
set: vi.fn(() => ({
|
|
where: vi.fn(() => ({
|
|
returning: vi.fn().mockResolvedValue([]),
|
|
})),
|
|
})),
|
|
}))
|
|
const select = vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => ({
|
|
limit: vi.fn().mockResolvedValue([{ permissionType: 'write' }]),
|
|
})),
|
|
})),
|
|
}))
|
|
return {
|
|
db: {
|
|
update,
|
|
select,
|
|
transaction: async (cb: (tx: { update: typeof update; select: typeof select }) => unknown) =>
|
|
cb({ update, select }),
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('drizzle-orm', () => ({
|
|
and: vi.fn(() => ({})),
|
|
eq: vi.fn(() => ({})),
|
|
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }),
|
|
}))
|
|
|
|
import { handleUnifiedChatPost } from './post'
|
|
|
|
describe('handleUnifiedChatPost', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
getSession.mockResolvedValue({ user: { id: 'user-1' } })
|
|
resolveWorkflowIdForUser.mockResolvedValue({
|
|
status: 'resolved',
|
|
workflowId: 'wf-1',
|
|
workspaceId: 'ws-1',
|
|
workflowName: 'Workflow One',
|
|
})
|
|
getUserEntityPermissions.mockResolvedValue('write')
|
|
getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' })
|
|
generateWorkspaceSnapshot.mockResolvedValue({
|
|
markdown: 'workspace context',
|
|
snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] },
|
|
})
|
|
processContextsServer.mockResolvedValue([])
|
|
resolveActiveResourceContext.mockResolvedValue(null)
|
|
buildCopilotRequestPayload.mockImplementation(async (params: Record<string, unknown>) => params)
|
|
createSSEStream.mockReturnValue(new ReadableStream())
|
|
acquirePendingChatStream.mockResolvedValue(true)
|
|
getPendingChatStreamId.mockResolvedValue(null)
|
|
releasePendingChatStream.mockResolvedValue(undefined)
|
|
resolveOrCreateChat.mockResolvedValue({
|
|
chatId: 'chat-1',
|
|
chat: { id: 'chat-1' },
|
|
conversationHistory: [],
|
|
isNew: true,
|
|
})
|
|
finalizeAssistantTurn.mockResolvedValue({
|
|
found: true,
|
|
updated: true,
|
|
appendedAssistant: true,
|
|
workspaceId: 'ws-1',
|
|
outcome: 'appended_assistant',
|
|
})
|
|
})
|
|
|
|
it('routes workflow-attached chat requests through the copilot backend path', async () => {
|
|
const response = await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workflowId: 'wf-1',
|
|
workspaceId: 'ws-1',
|
|
}),
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(generateWorkspaceSnapshot).toHaveBeenCalledWith('ws-1', 'user-1')
|
|
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: 'claude-opus-4-8',
|
|
workspaceContext: 'workspace context',
|
|
// Regression guard: the branch must forward the typed snapshot, not drop it.
|
|
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
|
|
}),
|
|
{ selectedModel: 'claude-opus-4-8' }
|
|
)
|
|
expect(createSSEStream).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
titleModel: 'claude-opus-4-8',
|
|
workspaceId: 'ws-1',
|
|
orchestrateOptions: expect.objectContaining({
|
|
workflowId: 'wf-1',
|
|
goRoute: '/api/copilot',
|
|
executionContext: expect.objectContaining({
|
|
userId: 'user-1',
|
|
workflowId: 'wf-1',
|
|
workspaceId: 'ws-1',
|
|
requestMode: 'agent',
|
|
}),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('routes workspace chat requests through the mothership backend path', async () => {
|
|
const response = await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workspaceId: 'ws-1',
|
|
createNewChat: true,
|
|
}),
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
workspaceId: 'ws-1',
|
|
workspaceContext: 'workspace context',
|
|
// Regression guard: the branch must forward the typed snapshot, not drop it.
|
|
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
|
|
}),
|
|
{ selectedModel: '' }
|
|
)
|
|
expect(createSSEStream).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
titleModel: 'claude-opus-4-8',
|
|
workspaceId: 'ws-1',
|
|
orchestrateOptions: expect.objectContaining({
|
|
workspaceId: 'ws-1',
|
|
goRoute: '/api/mothership',
|
|
executionContext: expect.objectContaining({
|
|
userId: 'user-1',
|
|
workflowId: '',
|
|
workspaceId: 'ws-1',
|
|
requestMode: 'agent',
|
|
}),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('accepts tagged skill contexts and forwards them to context resolution', async () => {
|
|
const response = await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workspaceId: 'ws-1',
|
|
createNewChat: true,
|
|
contexts: [{ kind: 'skill', skillId: 'sk-1', label: 'my-skill' }],
|
|
}),
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(processContextsServer).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ kind: 'skill', skillId: 'sk-1', label: 'my-skill' }),
|
|
]),
|
|
'user-1',
|
|
'Hello',
|
|
'ws-1',
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('persists cancelled partial responses from the server lifecycle', async () => {
|
|
await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workspaceId: 'ws-1',
|
|
createNewChat: true,
|
|
}),
|
|
})
|
|
)
|
|
|
|
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
|
const onComplete = streamArgs?.orchestrateOptions?.onComplete
|
|
expect(onComplete).toBeTypeOf('function')
|
|
|
|
await onComplete({
|
|
success: false,
|
|
cancelled: true,
|
|
content: 'partial answer',
|
|
contentBlocks: [],
|
|
toolCalls: [],
|
|
chatId: 'chat-1',
|
|
requestId: 'request-1',
|
|
})
|
|
|
|
expect(finalizeAssistantTurn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
chatId: 'chat-1',
|
|
userMessageId: expect.any(String),
|
|
streamMarkerPolicy: 'active-or-cleared',
|
|
assistantMessage: expect.objectContaining({
|
|
role: 'assistant',
|
|
content: 'partial answer',
|
|
contentBlocks: expect.arrayContaining([
|
|
expect.objectContaining({ type: 'complete', status: 'cancelled' }),
|
|
]),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('persists partial responses when the server lifecycle throws (onError)', async () => {
|
|
await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workspaceId: 'ws-1',
|
|
createNewChat: true,
|
|
}),
|
|
})
|
|
)
|
|
|
|
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
|
const onError = streamArgs?.orchestrateOptions?.onError
|
|
expect(onError).toBeTypeOf('function')
|
|
|
|
await onError(new Error('bedrock overloaded'), {
|
|
success: false,
|
|
cancelled: false,
|
|
content: 'partial answer',
|
|
contentBlocks: [],
|
|
toolCalls: [],
|
|
chatId: 'chat-1',
|
|
requestId: 'request-1',
|
|
})
|
|
|
|
expect(finalizeAssistantTurn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
chatId: 'chat-1',
|
|
userMessageId: expect.any(String),
|
|
streamMarkerPolicy: 'active-or-cleared',
|
|
assistantMessage: expect.objectContaining({
|
|
role: 'assistant',
|
|
content: 'partial answer',
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('clears the stream marker without an assistant message when nothing streamed before the throw', async () => {
|
|
await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workspaceId: 'ws-1',
|
|
createNewChat: true,
|
|
}),
|
|
})
|
|
)
|
|
|
|
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
|
const onError = streamArgs?.orchestrateOptions?.onError
|
|
expect(onError).toBeTypeOf('function')
|
|
|
|
await onError(new Error('immediate failure'), {
|
|
success: false,
|
|
cancelled: false,
|
|
content: '',
|
|
contentBlocks: [],
|
|
toolCalls: [],
|
|
chatId: 'chat-1',
|
|
requestId: 'request-1',
|
|
})
|
|
|
|
const lastCall = finalizeAssistantTurn.mock.calls.at(-1)?.[0]
|
|
expect(lastCall).toMatchObject({
|
|
chatId: 'chat-1',
|
|
streamMarkerPolicy: 'active-or-cleared',
|
|
})
|
|
expect(lastCall?.assistantMessage).toBeUndefined()
|
|
})
|
|
|
|
it('republishes completed status when cancelled lifecycle persistence already ran', async () => {
|
|
await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
workspaceId: 'ws-1',
|
|
createNewChat: true,
|
|
}),
|
|
})
|
|
)
|
|
|
|
const streamArgs = createSSEStream.mock.calls[0]?.[0]
|
|
const onComplete = streamArgs?.orchestrateOptions?.onComplete
|
|
expect(onComplete).toBeTypeOf('function')
|
|
|
|
finalizeAssistantTurn.mockResolvedValueOnce({
|
|
found: true,
|
|
updated: false,
|
|
appendedAssistant: false,
|
|
workspaceId: 'ws-1',
|
|
outcome: 'assistant_already_persisted',
|
|
})
|
|
|
|
await onComplete({
|
|
success: false,
|
|
cancelled: true,
|
|
content: 'partial answer',
|
|
contentBlocks: [],
|
|
toolCalls: [],
|
|
chatId: 'chat-1',
|
|
requestId: 'request-1',
|
|
})
|
|
|
|
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
|
|
workspaceId: 'ws-1',
|
|
chatId: 'chat-1',
|
|
type: 'completed',
|
|
streamId: streamArgs?.streamId,
|
|
})
|
|
})
|
|
|
|
it('rejects requests that have neither workflow nor workspace attachment', async () => {
|
|
const response = await handleUnifiedChatPost(
|
|
new NextRequest('http://localhost/api/copilot/chat', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
message: 'Hello',
|
|
}),
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(400)
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: 'workspaceId is required when workflowId is not provided',
|
|
})
|
|
})
|
|
})
|