Skip to content

Commit 6d718cf

Browse files
committed
improvement(copilot): make copilot_messages the sole transcript store, remove JSONB dual-write
Stop writing/reading the legacy copilot_chats.messages JSONB column now that reads are cut over to copilot_messages. Make appendCopilotChatMessages the primary write (throws on failure instead of swallowing), repoint peripheral readers (workspace VFS, chat cleanup, data drains, fork, superuser import) to copilot_messages, and persist the assistant turn inside finalizeAssistantTurn's transaction so it commits atomically with the stream-marker clear. The column itself is dropped in a follow-up migration after this bakes.
1 parent e8f6485 commit 6d718cf

19 files changed

Lines changed: 629 additions & 666 deletions

File tree

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

Lines changed: 57 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,19 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { authMockFns } from '@sim/testing'
4+
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const {
9-
mockSelect,
10-
mockFrom,
11-
mockWhereSelect,
12-
mockLimit,
13-
mockForUpdate,
14-
mockUpdate,
15-
mockSet,
16-
mockWhereUpdate,
17-
mockReturning,
18-
mockPublishStatusChanged,
19-
mockSql,
20-
mockTransaction,
21-
} = vi.hoisted(() => {
22-
const mockSelect = vi.fn()
23-
const mockFrom = vi.fn()
24-
const mockWhereSelect = vi.fn()
25-
const mockLimit = vi.fn()
26-
const mockForUpdate = vi.fn()
27-
const mockUpdate = vi.fn()
28-
const mockSet = vi.fn()
29-
const mockWhereUpdate = vi.fn()
30-
const mockReturning = vi.fn()
31-
const mockPublishStatusChanged = vi.fn()
32-
const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
33-
strings,
34-
values,
35-
}))
36-
const mockTransaction = vi.fn(
37-
(callback: (tx: { select: typeof mockSelect; update: typeof mockUpdate }) => unknown) =>
38-
callback({ select: mockSelect, update: mockUpdate })
39-
)
40-
41-
return {
42-
mockSelect,
43-
mockFrom,
44-
mockWhereSelect,
45-
mockLimit,
46-
mockForUpdate,
47-
mockUpdate,
48-
mockSet,
49-
mockWhereUpdate,
50-
mockReturning,
51-
mockPublishStatusChanged,
52-
mockSql,
53-
mockTransaction,
54-
}
55-
})
8+
vi.mock('@sim/db', () => dbChainMock)
569

57-
vi.mock('@sim/db/schema', () => ({
58-
copilotChats: {
59-
id: 'copilotChats.id',
60-
userId: 'copilotChats.userId',
61-
workspaceId: 'copilotChats.workspaceId',
62-
messages: 'copilotChats.messages',
63-
conversationId: 'copilotChats.conversationId',
64-
},
65-
}))
66-
67-
vi.mock('@sim/db', () => ({
68-
db: {
69-
transaction: mockTransaction,
70-
},
10+
const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({
11+
mockAppendCopilotChatMessages: vi.fn(),
12+
mockPublishStatusChanged: vi.fn(),
7113
}))
7214

73-
vi.mock('drizzle-orm', () => ({
74-
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
75-
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
76-
sql: mockSql,
15+
vi.mock('@/lib/copilot/chat/messages-store', () => ({
16+
appendCopilotChatMessages: mockAppendCopilotChatMessages,
7717
}))
7818

7919
vi.mock('@/lib/copilot/tasks', () => ({
@@ -92,81 +32,75 @@ function createRequest(body: Record<string, unknown>) {
9232
})
9333
}
9434

35+
/**
36+
* Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row
37+
* (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup
38+
* (both terminate on `.limit(1)`).
39+
*/
40+
function mockReads(opts: {
41+
chat: Record<string, unknown> | null
42+
last?: { messageId: string; role: string }
43+
}) {
44+
dbChainMockFns.limit.mockResolvedValueOnce(opts.chat ? [opts.chat] : [])
45+
dbChainMockFns.limit.mockResolvedValueOnce(opts.last ? [opts.last] : [])
46+
}
47+
9548
describe('copilot chat stop route', () => {
9649
beforeEach(() => {
9750
vi.clearAllMocks()
98-
51+
// Drain any `mockResolvedValueOnce` queue left by an early-return test
52+
// (clearAllMocks/resetDbChainMock don't clear the once-queue), then restore
53+
// the default chain implementations.
54+
dbChainMockFns.limit.mockReset()
55+
resetDbChainMock()
9956
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
100-
101-
mockLimit.mockResolvedValue([
102-
{
103-
workspaceId: 'ws-1',
104-
messages: [{ id: 'stream-1', role: 'user', content: 'hello' }],
105-
conversationId: 'stream-1',
106-
},
107-
])
108-
mockForUpdate.mockReturnValue({ limit: mockLimit })
109-
mockWhereSelect.mockReturnValue({ for: mockForUpdate })
110-
mockFrom.mockReturnValue({ where: mockWhereSelect })
111-
mockSelect.mockReturnValue({ from: mockFrom })
112-
113-
mockReturning.mockResolvedValue([{ workspaceId: 'ws-1' }])
114-
mockWhereUpdate.mockReturnValue({ returning: mockReturning })
115-
mockSet.mockReturnValue({ where: mockWhereUpdate })
116-
mockUpdate.mockReturnValue({ set: mockSet })
11757
})
11858

11959
it('returns 401 when unauthenticated', async () => {
12060
authMockFns.mockGetSession.mockResolvedValueOnce(null)
12161

12262
const response = await POST(
123-
createRequest({
124-
chatId: 'chat-1',
125-
streamId: 'stream-1',
126-
content: '',
127-
})
63+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
12864
)
12965

13066
expect(response.status).toBe(401)
13167
expect(await response.json()).toEqual({ error: 'Unauthorized' })
13268
})
13369

13470
it('is a no-op when the chat is missing', async () => {
135-
mockLimit.mockResolvedValueOnce([])
71+
mockReads({ chat: null })
13672

13773
const response = await POST(
138-
createRequest({
139-
chatId: 'missing-chat',
140-
streamId: 'stream-1',
141-
content: '',
142-
})
74+
createRequest({ chatId: 'missing-chat', streamId: 'stream-1', content: '' })
14375
)
14476

14577
expect(response.status).toBe(200)
14678
expect(await response.json()).toEqual({ success: true })
147-
expect(mockUpdate).not.toHaveBeenCalled()
79+
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
14880
})
14981

15082
it('appends a stopped assistant message even with no content', async () => {
83+
mockReads({
84+
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
85+
last: { messageId: 'stream-1', role: 'user' },
86+
})
87+
15188
const response = await POST(
152-
createRequest({
153-
chatId: 'chat-1',
154-
streamId: 'stream-1',
155-
content: '',
156-
})
89+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
15790
)
15891

15992
expect(response.status).toBe(200)
16093
expect(await response.json()).toEqual({ success: true })
16194

162-
const setArg = mockSet.mock.calls[0]?.[0]
163-
expect(setArg).toBeTruthy()
95+
// The stream marker is cleared and nothing is written to the JSONB column.
96+
const setArg = dbChainMockFns.set.mock.calls[0]?.[0] as Record<string, unknown>
16497
expect(setArg.conversationId).toBeNull()
165-
expect(setArg.messages).toBeTruthy()
98+
expect(Object.hasOwn(setArg, 'messages')).toBe(false)
16699

167-
const appendedPayload = JSON.parse(setArg.messages.values[1] as string)
168-
expect(appendedPayload).toHaveLength(1)
169-
expect(appendedPayload[0]).toMatchObject({
100+
// The stopped assistant turn is persisted to copilot_messages.
101+
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
102+
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
103+
expect(appended[0]).toMatchObject({
170104
role: 'assistant',
171105
content: '',
172106
contentBlocks: [{ type: 'complete', status: 'cancelled' }],
@@ -181,32 +115,21 @@ describe('copilot chat stop route', () => {
181115
})
182116

183117
it('appends a stopped assistant message if the stream marker was already cleared', async () => {
184-
mockLimit.mockResolvedValueOnce([
185-
{
186-
workspaceId: 'ws-1',
187-
messages: [{ id: 'stream-1', role: 'user', content: 'hello' }],
188-
conversationId: null,
189-
},
190-
])
118+
mockReads({
119+
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
120+
last: { messageId: 'stream-1', role: 'user' },
121+
})
191122

192123
const response = await POST(
193-
createRequest({
194-
chatId: 'chat-1',
195-
streamId: 'stream-1',
196-
content: 'partial',
197-
})
124+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
198125
)
199126

200127
expect(response.status).toBe(200)
201128
expect(await response.json()).toEqual({ success: true })
202129

203-
const setArg = mockSet.mock.calls[0]?.[0]
204-
expect(setArg.messages).toBeTruthy()
205-
const appendedPayload = JSON.parse(setArg.messages.values[1] as string)
206-
expect(appendedPayload[0]).toMatchObject({
207-
role: 'assistant',
208-
content: 'partial',
209-
})
130+
expect(mockAppendCopilotChatMessages).toHaveBeenCalledTimes(1)
131+
const [, appended] = mockAppendCopilotChatMessages.mock.calls[0]
132+
expect(appended[0]).toMatchObject({ role: 'assistant', content: 'partial' })
210133

211134
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
212135
workspaceId: 'ws-1',
@@ -217,28 +140,19 @@ describe('copilot chat stop route', () => {
217140
})
218141

219142
it('republishes completed status when the assistant was already persisted', async () => {
220-
mockLimit.mockResolvedValueOnce([
221-
{
222-
workspaceId: 'ws-1',
223-
messages: [
224-
{ id: 'stream-1', role: 'user', content: 'hello' },
225-
{ id: 'assistant-1', role: 'assistant', content: 'partial' },
226-
],
227-
conversationId: null,
228-
},
229-
])
143+
mockReads({
144+
chat: { workspaceId: 'ws-1', conversationId: null, model: null },
145+
last: { messageId: 'assistant-1', role: 'assistant' },
146+
})
230147

231148
const response = await POST(
232-
createRequest({
233-
chatId: 'chat-1',
234-
streamId: 'stream-1',
235-
content: 'partial',
236-
})
149+
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'partial' })
237150
)
238151

239152
expect(response.status).toBe(200)
240153
expect(await response.json()).toEqual({ success: true })
241-
expect(mockUpdate).not.toHaveBeenCalled()
154+
expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled()
155+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
242156
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
243157
workspaceId: 'ws-1',
244158
chatId: 'chat-1',

apps/sim/app/api/copilot/chat/update-messages/route.test.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
mockSet,
1717
mockUpdateWhere,
1818
mockReturning,
19+
mockReplaceCopilotChatMessages,
1920
} = vi.hoisted(() => ({
2021
mockSelect: vi.fn(),
2122
mockFrom: vi.fn(),
@@ -25,15 +26,23 @@ const {
2526
mockSet: vi.fn(),
2627
mockUpdateWhere: vi.fn(),
2728
mockReturning: vi.fn(),
29+
mockReplaceCopilotChatMessages: vi.fn(),
2830
}))
2931

3032
vi.mock('@sim/db', () => ({
3133
db: {
3234
select: mockSelect,
3335
update: mockUpdate,
36+
transaction: async (
37+
cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown
38+
) => cb({ update: mockUpdate, select: mockSelect }),
3439
},
3540
}))
3641

42+
vi.mock('@/lib/copilot/chat/messages-store', () => ({
43+
replaceCopilotChatMessages: mockReplaceCopilotChatMessages,
44+
}))
45+
3746
vi.mock('drizzle-orm', () => ({
3847
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
3948
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
@@ -257,10 +266,14 @@ describe('Copilot Chat Update Messages API Route', () => {
257266

258267
expect(mockSelect).toHaveBeenCalled()
259268
expect(mockUpdate).toHaveBeenCalled()
260-
expect(mockSet).toHaveBeenCalledWith({
269+
// The transcript is written to copilot_messages, not the chat row.
270+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
271+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
272+
'chat-123',
261273
messages,
262-
updatedAt: expect.any(Date),
263-
})
274+
{ chatModel: 'gpt-4' },
275+
expect.anything()
276+
)
264277
})
265278

266279
it('should successfully update chat messages with optional fields', async () => {
@@ -315,8 +328,12 @@ describe('Copilot Chat Update Messages API Route', () => {
315328
messageCount: 2,
316329
})
317330

318-
expect(mockSet).toHaveBeenCalledWith({
319-
messages: [
331+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
332+
// Messages are normalized (toolCalls folded into contentBlocks) before
333+
// being persisted to copilot_messages.
334+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
335+
'chat-456',
336+
[
320337
{
321338
id: 'msg-1',
322339
role: 'user',
@@ -345,8 +362,9 @@ describe('Copilot Chat Update Messages API Route', () => {
345362
],
346363
},
347364
],
348-
updatedAt: expect.any(Date),
349-
})
365+
{ chatModel: 'gpt-4' },
366+
expect.anything()
367+
)
350368
})
351369

352370
it('should handle empty messages array', async () => {
@@ -373,10 +391,13 @@ describe('Copilot Chat Update Messages API Route', () => {
373391
messageCount: 0,
374392
})
375393

376-
expect(mockSet).toHaveBeenCalledWith({
377-
messages: [],
378-
updatedAt: expect.any(Date),
379-
})
394+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
395+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
396+
'chat-789',
397+
[],
398+
{ chatModel: 'gpt-4' },
399+
expect.anything()
400+
)
380401
})
381402

382403
it('should handle database errors during chat lookup', async () => {
@@ -485,10 +506,13 @@ describe('Copilot Chat Update Messages API Route', () => {
485506
messageCount: 100,
486507
})
487508

488-
expect(mockSet).toHaveBeenCalledWith({
509+
expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) })
510+
expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith(
511+
'chat-large',
489512
messages,
490-
updatedAt: expect.any(Date),
491-
})
513+
{ chatModel: 'gpt-4' },
514+
expect.anything()
515+
)
492516
})
493517

494518
it('should handle messages with both user and assistant roles', async () => {

0 commit comments

Comments
 (0)