Skip to content

Commit 1100bfd

Browse files
authored
feat(multi-select): simplified chat to always return readable stream, can select multiple outputs and get response streamed back in chat panel & deployed chat (#507)
* improvement: all workflow executions return ReadableStream & use sse to support multiple streamed outputs in chats * fixed build * remove extraneous comments * general improvemetns * ack PR comments * fixed built
1 parent 941b26a commit 1100bfd

File tree

17 files changed

+1718
-2095
lines changed

17 files changed

+1718
-2095
lines changed

apps/sim/app/api/chat/[subdomain]/route.test.ts

Lines changed: 172 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { createMockRequest } from '@/app/api/__test-utils__/utils'
88

99
describe('Chat Subdomain API Route', () => {
10-
const mockWorkflowSingleOutput = {
11-
id: 'response-id',
12-
content: 'Test response',
13-
timestamp: new Date().toISOString(),
14-
type: 'workflow',
10+
const createMockStream = () => {
11+
return new ReadableStream({
12+
start(controller) {
13+
controller.enqueue(
14+
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":"Hello"}\n\n')
15+
)
16+
controller.enqueue(
17+
new TextEncoder().encode('data: {"blockId":"agent-1","chunk":" world"}\n\n')
18+
)
19+
controller.enqueue(
20+
new TextEncoder().encode('data: {"event":"final","data":{"success":true}}\n\n')
21+
)
22+
controller.close()
23+
},
24+
})
1525
}
1626

17-
// Mock functions
1827
const mockAddCorsHeaders = vi.fn().mockImplementation((response) => response)
1928
const mockValidateChatAuth = vi.fn().mockResolvedValue({ authorized: true })
2029
const mockSetChatAuthCookie = vi.fn()
21-
const mockExecuteWorkflowForChat = vi.fn().mockResolvedValue(mockWorkflowSingleOutput)
30+
const mockExecuteWorkflowForChat = vi.fn().mockResolvedValue(createMockStream())
2231

23-
// Mock database return values
2432
const mockChatResult = [
2533
{
2634
id: 'chat-id',
@@ -41,13 +49,24 @@ describe('Chat Subdomain API Route', () => {
4149
const mockWorkflowResult = [
4250
{
4351
isDeployed: true,
52+
state: {
53+
blocks: {},
54+
edges: [],
55+
loops: {},
56+
parallels: {},
57+
},
58+
deployedState: {
59+
blocks: {},
60+
edges: [],
61+
loops: {},
62+
parallels: {},
63+
},
4464
},
4565
]
4666

4767
beforeEach(() => {
4868
vi.resetModules()
4969

50-
// Mock chat API utils
5170
vi.doMock('../utils', () => ({
5271
addCorsHeaders: mockAddCorsHeaders,
5372
validateChatAuth: mockValidateChatAuth,
@@ -56,7 +75,6 @@ describe('Chat Subdomain API Route', () => {
5675
executeWorkflowForChat: mockExecuteWorkflowForChat,
5776
}))
5877

59-
// Mock logger
6078
vi.doMock('@/lib/logs/console-logger', () => ({
6179
createLogger: vi.fn().mockReturnValue({
6280
debug: vi.fn(),
@@ -66,32 +84,35 @@ describe('Chat Subdomain API Route', () => {
6684
}),
6785
}))
6886

69-
// Mock database
7087
vi.doMock('@/db', () => {
71-
const mockLimitChat = vi.fn().mockReturnValue(mockChatResult)
72-
const mockWhereChat = vi.fn().mockReturnValue({ limit: mockLimitChat })
73-
74-
const mockLimitWorkflow = vi.fn().mockReturnValue(mockWorkflowResult)
75-
const mockWhereWorkflow = vi.fn().mockReturnValue({ limit: mockLimitWorkflow })
76-
77-
const mockFrom = vi.fn().mockImplementation((table) => {
78-
// Check which table is being queried
79-
if (table === 'workflow') {
80-
return { where: mockWhereWorkflow }
88+
const mockSelect = vi.fn().mockImplementation((fields) => {
89+
if (fields && fields.isDeployed !== undefined) {
90+
return {
91+
from: vi.fn().mockReturnValue({
92+
where: vi.fn().mockReturnValue({
93+
limit: vi.fn().mockReturnValue(mockWorkflowResult),
94+
}),
95+
}),
96+
}
97+
}
98+
return {
99+
from: vi.fn().mockReturnValue({
100+
where: vi.fn().mockReturnValue({
101+
limit: vi.fn().mockReturnValue(mockChatResult),
102+
}),
103+
}),
81104
}
82-
return { where: mockWhereChat }
83105
})
84106

85-
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
86-
87107
return {
88108
db: {
89109
select: mockSelect,
90110
},
111+
chat: {},
112+
workflow: {},
91113
}
92114
})
93115

94-
// Mock API response helpers
95116
vi.doMock('@/app/api/workflows/utils', () => ({
96117
createErrorResponse: vi.fn().mockImplementation((message, status, code) => {
97118
return new Response(
@@ -277,37 +298,47 @@ describe('Chat Subdomain API Route', () => {
277298
})
278299

279300
it('should return 503 when workflow is not available', async () => {
301+
// Override the default workflow result to return non-deployed
280302
vi.doMock('@/db', () => {
281-
const mockLimitChat = vi.fn().mockReturnValue([
282-
{
283-
id: 'chat-id',
284-
workflowId: 'unavailable-workflow',
285-
isActive: true,
286-
authType: 'public',
287-
},
288-
])
289-
const mockWhereChat = vi.fn().mockReturnValue({ limit: mockLimitChat })
290-
291-
// Second call returns non-deployed workflow
292-
const mockLimitWorkflow = vi.fn().mockReturnValue([
293-
{
294-
isDeployed: false,
295-
},
296-
])
297-
const mockWhereWorkflow = vi.fn().mockReturnValue({ limit: mockLimitWorkflow })
298-
299-
// Mock from function to return different where implementations
300-
const mockFrom = vi
301-
.fn()
302-
.mockImplementationOnce(() => ({ where: mockWhereChat })) // First call (chat)
303-
.mockImplementationOnce(() => ({ where: mockWhereWorkflow })) // Second call (workflow)
303+
// Track call count to return different results
304+
let callCount = 0
305+
306+
const mockLimit = vi.fn().mockImplementation(() => {
307+
callCount++
308+
if (callCount === 1) {
309+
// First call - chat query
310+
return [
311+
{
312+
id: 'chat-id',
313+
workflowId: 'unavailable-workflow',
314+
userId: 'user-id',
315+
isActive: true,
316+
authType: 'public',
317+
outputConfigs: [{ blockId: 'block-1', path: 'output' }],
318+
},
319+
]
320+
}
321+
if (callCount === 2) {
322+
// Second call - workflow query
323+
return [
324+
{
325+
isDeployed: false,
326+
},
327+
]
328+
}
329+
return []
330+
})
304331

332+
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
333+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
305334
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
306335

307336
return {
308337
db: {
309338
select: mockSelect,
310339
},
340+
chat: {},
341+
workflow: {},
311342
}
312343
})
313344

@@ -325,6 +356,48 @@ describe('Chat Subdomain API Route', () => {
325356
expect(data).toHaveProperty('message', 'Chat workflow is not available')
326357
})
327358

359+
it('should return streaming response for valid chat messages', async () => {
360+
const req = createMockRequest('POST', { message: 'Hello world', conversationId: 'conv-123' })
361+
const params = Promise.resolve({ subdomain: 'test-chat' })
362+
363+
const { POST } = await import('./route')
364+
365+
const response = await POST(req, { params })
366+
367+
expect(response.status).toBe(200)
368+
expect(response.headers.get('Content-Type')).toBe('text/event-stream')
369+
expect(response.headers.get('Cache-Control')).toBe('no-cache')
370+
expect(response.headers.get('Connection')).toBe('keep-alive')
371+
372+
// Verify executeWorkflowForChat was called with correct parameters
373+
expect(mockExecuteWorkflowForChat).toHaveBeenCalledWith('chat-id', 'Hello world', 'conv-123')
374+
})
375+
376+
it('should handle streaming response body correctly', async () => {
377+
const req = createMockRequest('POST', { message: 'Hello world' })
378+
const params = Promise.resolve({ subdomain: 'test-chat' })
379+
380+
const { POST } = await import('./route')
381+
382+
const response = await POST(req, { params })
383+
384+
expect(response.status).toBe(200)
385+
expect(response.body).toBeInstanceOf(ReadableStream)
386+
387+
// Test that we can read from the response stream
388+
if (response.body) {
389+
const reader = response.body.getReader()
390+
const { value, done } = await reader.read()
391+
392+
if (!done && value) {
393+
const chunk = new TextDecoder().decode(value)
394+
expect(chunk).toMatch(/^data: /)
395+
}
396+
397+
reader.releaseLock()
398+
}
399+
})
400+
328401
it('should handle workflow execution errors gracefully', async () => {
329402
const originalExecuteWorkflow = mockExecuteWorkflowForChat.getMockImplementation()
330403
mockExecuteWorkflowForChat.mockImplementationOnce(async () => {
@@ -338,15 +411,64 @@ describe('Chat Subdomain API Route', () => {
338411

339412
const response = await POST(req, { params })
340413

341-
expect(response.status).toBe(503)
414+
expect(response.status).toBe(500)
342415

343416
const data = await response.json()
344417
expect(data).toHaveProperty('error')
345-
expect(data).toHaveProperty('message', 'Chat workflow is not available')
418+
expect(data).toHaveProperty('message', 'Execution failed')
346419

347420
if (originalExecuteWorkflow) {
348421
mockExecuteWorkflowForChat.mockImplementation(originalExecuteWorkflow)
349422
}
350423
})
424+
425+
it('should handle invalid JSON in request body', async () => {
426+
// Create a request with invalid JSON
427+
const req = {
428+
method: 'POST',
429+
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
430+
} as any
431+
432+
const params = Promise.resolve({ subdomain: 'test-chat' })
433+
434+
const { POST } = await import('./route')
435+
436+
const response = await POST(req, { params })
437+
438+
expect(response.status).toBe(400)
439+
440+
const data = await response.json()
441+
expect(data).toHaveProperty('error')
442+
expect(data).toHaveProperty('message', 'Invalid request body')
443+
})
444+
445+
it('should pass conversationId to executeWorkflowForChat when provided', async () => {
446+
const req = createMockRequest('POST', {
447+
message: 'Hello world',
448+
conversationId: 'test-conversation-123',
449+
})
450+
const params = Promise.resolve({ subdomain: 'test-chat' })
451+
452+
const { POST } = await import('./route')
453+
454+
await POST(req, { params })
455+
456+
expect(mockExecuteWorkflowForChat).toHaveBeenCalledWith(
457+
'chat-id',
458+
'Hello world',
459+
'test-conversation-123'
460+
)
461+
})
462+
463+
it('should handle missing conversationId gracefully', async () => {
464+
const req = createMockRequest('POST', { message: 'Hello world' })
465+
const params = Promise.resolve({ subdomain: 'test-chat' })
466+
467+
const { POST } = await import('./route')
468+
469+
await POST(req, { params })
470+
471+
expect(mockExecuteWorkflowForChat).toHaveBeenCalledWith('chat-id', 'Hello world', undefined)
472+
})
351473
})
352474
})

0 commit comments

Comments
 (0)