Skip to content

Commit c0bc62c

Browse files
Merge pull request #4190 from simstudioai/staging
v0.6.46: mothership streaming fixes, brightdata integration
2 parents 010435c + 377712c commit c0bc62c

File tree

20 files changed

+1572
-196
lines changed

20 files changed

+1572
-196
lines changed

apps/docs/content/docs/en/tools/agiloft.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
77

88
<BlockInfoCard
99
type="agiloft"
10-
color="#263A5C"
10+
color="#FFFFFF"
1111
/>
1212

1313
{/* MANUAL-CONTENT-START:intro */}

apps/sim/app/api/copilot/chat/queries.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { createLogger } from '@sim/logger'
44
import { and, desc, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
7+
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
78
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
9+
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
810
import {
911
authenticateCopilotRequestSessionOnly,
1012
createBadRequestResponse,
@@ -113,11 +115,23 @@ export async function GET(req: NextRequest) {
113115
}
114116
}
115117

118+
const normalizedMessages = Array.isArray(chat.messages)
119+
? chat.messages
120+
.filter((message): message is Record<string, unknown> => Boolean(message))
121+
.map(normalizeMessage)
122+
: []
123+
const effectiveMessages = buildEffectiveChatTranscript({
124+
messages: normalizedMessages,
125+
activeStreamId: chat.conversationId || null,
126+
...(streamSnapshot ? { streamSnapshot } : {}),
127+
})
128+
116129
logger.info(`Retrieved chat ${chatId}`)
117130
return NextResponse.json({
118131
success: true,
119132
chat: {
120133
...transformChat(chat),
134+
messages: effectiveMessages,
121135
...(streamSnapshot ? { streamSnapshot } : {}),
122136
},
123137
})
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockGetSession,
9+
mockSelect,
10+
mockFrom,
11+
mockWhereSelect,
12+
mockLimit,
13+
mockUpdate,
14+
mockSet,
15+
mockWhereUpdate,
16+
mockReturning,
17+
mockPublishStatusChanged,
18+
mockSql,
19+
} = vi.hoisted(() => ({
20+
mockGetSession: vi.fn(),
21+
mockSelect: vi.fn(),
22+
mockFrom: vi.fn(),
23+
mockWhereSelect: vi.fn(),
24+
mockLimit: vi.fn(),
25+
mockUpdate: vi.fn(),
26+
mockSet: vi.fn(),
27+
mockWhereUpdate: vi.fn(),
28+
mockReturning: vi.fn(),
29+
mockPublishStatusChanged: vi.fn(),
30+
mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
31+
}))
32+
33+
vi.mock('@/lib/auth', () => ({
34+
getSession: mockGetSession,
35+
}))
36+
37+
vi.mock('@sim/db', () => ({
38+
db: {
39+
select: mockSelect,
40+
update: mockUpdate,
41+
},
42+
}))
43+
44+
vi.mock('@sim/db/schema', () => ({
45+
copilotChats: {
46+
id: 'id',
47+
userId: 'userId',
48+
workspaceId: 'workspaceId',
49+
messages: 'messages',
50+
conversationId: 'conversationId',
51+
},
52+
}))
53+
54+
vi.mock('drizzle-orm', () => ({
55+
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
56+
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
57+
sql: mockSql,
58+
}))
59+
60+
vi.mock('@/lib/copilot/tasks', () => ({
61+
taskPubSub: {
62+
publishStatusChanged: mockPublishStatusChanged,
63+
},
64+
}))
65+
66+
import { POST } from '@/app/api/copilot/chat/stop/route'
67+
68+
function createRequest(body: Record<string, unknown>) {
69+
return new NextRequest('http://localhost:3000/api/copilot/chat/stop', {
70+
method: 'POST',
71+
body: JSON.stringify(body),
72+
headers: { 'Content-Type': 'application/json' },
73+
})
74+
}
75+
76+
describe('copilot chat stop route', () => {
77+
beforeEach(() => {
78+
vi.clearAllMocks()
79+
80+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
81+
82+
mockLimit.mockResolvedValue([
83+
{
84+
workspaceId: 'ws-1',
85+
messages: [{ id: 'stream-1', role: 'user', content: 'hello' }],
86+
},
87+
])
88+
mockWhereSelect.mockReturnValue({ limit: mockLimit })
89+
mockFrom.mockReturnValue({ where: mockWhereSelect })
90+
mockSelect.mockReturnValue({ from: mockFrom })
91+
92+
mockReturning.mockResolvedValue([{ workspaceId: 'ws-1' }])
93+
mockWhereUpdate.mockReturnValue({ returning: mockReturning })
94+
mockSet.mockReturnValue({ where: mockWhereUpdate })
95+
mockUpdate.mockReturnValue({ set: mockSet })
96+
})
97+
98+
it('returns 401 when unauthenticated', async () => {
99+
mockGetSession.mockResolvedValueOnce(null)
100+
101+
const response = await POST(
102+
createRequest({
103+
chatId: 'chat-1',
104+
streamId: 'stream-1',
105+
content: '',
106+
})
107+
)
108+
109+
expect(response.status).toBe(401)
110+
expect(await response.json()).toEqual({ error: 'Unauthorized' })
111+
})
112+
113+
it('is a no-op when the chat is missing', async () => {
114+
mockLimit.mockResolvedValueOnce([])
115+
116+
const response = await POST(
117+
createRequest({
118+
chatId: 'missing-chat',
119+
streamId: 'stream-1',
120+
content: '',
121+
})
122+
)
123+
124+
expect(response.status).toBe(200)
125+
expect(await response.json()).toEqual({ success: true })
126+
expect(mockUpdate).not.toHaveBeenCalled()
127+
})
128+
129+
it('appends a stopped assistant message even with no content', async () => {
130+
const response = await POST(
131+
createRequest({
132+
chatId: 'chat-1',
133+
streamId: 'stream-1',
134+
content: '',
135+
})
136+
)
137+
138+
expect(response.status).toBe(200)
139+
expect(await response.json()).toEqual({ success: true })
140+
141+
const setArg = mockSet.mock.calls[0]?.[0]
142+
expect(setArg).toBeTruthy()
143+
expect(setArg.conversationId).toBeNull()
144+
expect(setArg.messages).toBeTruthy()
145+
146+
const appendedPayload = JSON.parse(setArg.messages.values[1] as string)
147+
expect(appendedPayload).toHaveLength(1)
148+
expect(appendedPayload[0]).toMatchObject({
149+
role: 'assistant',
150+
content: '',
151+
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
152+
})
153+
154+
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
155+
workspaceId: 'ws-1',
156+
chatId: 'chat-1',
157+
type: 'completed',
158+
})
159+
})
160+
})

apps/sim/app/api/copilot/chat/stop/route.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
99
import { taskPubSub } from '@/lib/copilot/tasks'
10+
import { generateId } from '@/lib/core/utils/uuid'
1011

1112
const logger = createLogger('CopilotChatStopAPI')
1213

@@ -70,7 +71,6 @@ export async function POST(req: NextRequest) {
7071
}
7172

7273
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())
73-
7474
const [row] = await db
7575
.select({
7676
workspaceId: copilotChats.workspaceId,
@@ -106,14 +106,18 @@ export async function POST(req: NextRequest) {
106106

107107
const hasContent = content.trim().length > 0
108108
const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0
109-
110-
if ((hasContent || hasBlocks) && canAppendAssistant) {
109+
const synthesizedStoppedBlocks = hasBlocks
110+
? contentBlocks
111+
: hasContent
112+
? [{ type: 'text', channel: 'assistant', content }, { type: 'stopped' }]
113+
: [{ type: 'stopped' }]
114+
if (canAppendAssistant) {
111115
const normalized = normalizeMessage({
112-
id: crypto.randomUUID(),
116+
id: generateId(),
113117
role: 'assistant',
114118
content,
115119
timestamp: new Date().toISOString(),
116-
...(hasBlocks ? { contentBlocks } : {}),
120+
contentBlocks: synthesizedStoppedBlocks,
117121
})
118122
const assistantMessage: PersistedMessage = normalized
119123
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`

apps/sim/app/api/mothership/chats/[chatId]/route.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { and, eq, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
8+
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
89
import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle'
10+
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
911
import {
1012
authenticateCopilotRequestSessionOnly,
1113
createBadRequestResponse,
@@ -93,12 +95,23 @@ export async function GET(
9395
}
9496
}
9597

98+
const normalizedMessages = Array.isArray(chat.messages)
99+
? chat.messages
100+
.filter((message): message is Record<string, unknown> => Boolean(message))
101+
.map(normalizeMessage)
102+
: []
103+
const effectiveMessages = buildEffectiveChatTranscript({
104+
messages: normalizedMessages,
105+
activeStreamId: chat.conversationId || null,
106+
...(streamSnapshot ? { streamSnapshot } : {}),
107+
})
108+
96109
return NextResponse.json({
97110
success: true,
98111
chat: {
99112
id: chat.id,
100113
title: chat.title,
101-
messages: Array.isArray(chat.messages) ? chat.messages : [],
114+
messages: effectiveMessages,
102115
conversationId: chat.conversationId || null,
103116
resources: Array.isArray(chat.resources) ? chat.resources : [],
104117
createdAt: chat.createdAt,

0 commit comments

Comments
 (0)