Files
simstudioai--sim/apps/sim/lib/copilot/chat/post.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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',
})
})
})