Skip to content

Commit a03ff62

Browse files
committed
feat(mothership): duplicate a chat via whole-chat fork mode
1 parent 834dc2e commit a03ff62

21 files changed

Lines changed: 652 additions & 86 deletions

File tree

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

Lines changed: 191 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
mockSelectRows,
1111
mockCheckStorageQuota,
1212
mockListForkableChatFiles,
13+
mockListDuplicableChatFiles,
1314
mockPlanChatFileCopies,
1415
mockExecuteChatFileBlobCopies,
1516
mockLoadCopilotChatMessages,
@@ -23,6 +24,7 @@ const {
2324
mockSelectRows: vi.fn(),
2425
mockCheckStorageQuota: vi.fn(),
2526
mockListForkableChatFiles: vi.fn(),
27+
mockListDuplicableChatFiles: vi.fn(),
2628
mockPlanChatFileCopies: vi.fn(),
2729
mockExecuteChatFileBlobCopies: vi.fn(),
2830
mockLoadCopilotChatMessages: vi.fn(),
@@ -73,6 +75,7 @@ vi.mock('@/lib/billing/storage', () => ({
7375

7476
vi.mock('@/lib/copilot/chat/fork-chat-files', () => ({
7577
listForkableChatFiles: mockListForkableChatFiles,
78+
listDuplicableChatFiles: mockListDuplicableChatFiles,
7679
planChatFileCopies: mockPlanChatFileCopies,
7780
executeChatFileBlobCopies: mockExecuteChatFileBlobCopies,
7881
}))
@@ -148,15 +151,26 @@ const threeMessages = [
148151
},
149152
]
150153

154+
/** Chat rows inserted through the mock transaction, captured for title assertions. */
155+
let insertedChatRows: Array<Record<string, unknown>> = []
156+
/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */
157+
let updatedChatRows: Array<Record<string, unknown>> = []
158+
151159
function makeTx() {
152160
return {
153161
insert: () => ({
154-
values: () => ({
155-
returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }],
156-
}),
162+
values: (v: Record<string, unknown>) => {
163+
insertedChatRows.push(v)
164+
return {
165+
returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }],
166+
}
167+
},
157168
}),
158169
update: () => ({
159-
set: vi.fn().mockReturnValue({ where: async () => undefined }),
170+
set: (v: Record<string, unknown>) => {
171+
updatedChatRows.push(v)
172+
return { where: async () => undefined }
173+
},
160174
}),
161175
}
162176
}
@@ -176,12 +190,15 @@ function makeContext(chatId: string) {
176190
describe('POST /api/mothership/chats/[chatId]/fork', () => {
177191
beforeEach(() => {
178192
vi.clearAllMocks()
193+
insertedChatRows = []
194+
updatedChatRows = []
179195
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
180196
userId: 'user-1',
181197
isAuthenticated: true,
182198
})
183199
mockSelectRows.mockResolvedValue([parentRow])
184200
mockListForkableChatFiles.mockResolvedValue([])
201+
mockListDuplicableChatFiles.mockResolvedValue([])
185202
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
186203
mockLoadCopilotChatMessages.mockResolvedValue(threeMessages)
187204
mockPlanChatFileCopies.mockResolvedValue({
@@ -220,8 +237,8 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
220237
expect(res.status).toBe(404)
221238
})
222239

223-
it('400s when upToMessageId is missing', async () => {
224-
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
240+
it('400s when upToMessageId is an empty string', async () => {
241+
const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1'))
225242
expect(res.status).toBe(400)
226243
expect(mockTransaction).not.toHaveBeenCalled()
227244
})
@@ -321,14 +338,181 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
321338
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
322339
'user-1',
323340
'task_forked',
324-
{ workspace_id: 'ws-1', source_chat_id: 'chat-1' },
341+
{ workspace_id: 'ws-1', source_chat_id: 'chat-1', whole_chat: false },
325342
{ groups: { workspace: 'ws-1' } }
326343
)
344+
345+
// Branch forks are titled "Fork | <name>".
346+
expect(insertedChatRows[0].title).toBe('Fork | Generate Logs')
327347
})
328348

329349
it('still succeeds when the copilot-service clone fails (best-effort)', async () => {
330350
mockFetchGo.mockRejectedValue(new Error('mothership unreachable'))
331351
const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
332352
expect(res.status).toBe(200)
333353
})
354+
355+
it('drops ghost resources on a branch fork: chat-owned files that were not copied', async () => {
356+
// The source chat generated two outputs (apple pre-cut, banana post-cut)
357+
// and has one upload + one shared workspace-file resource. A branch fork
358+
// copies only the kept upload; BOTH outputs stay behind, so both output
359+
// resources must be dropped — not left pointing at the source chat.
360+
mockSelectRows.mockResolvedValue([
361+
{
362+
...parentRow,
363+
resources: [
364+
{ type: 'file', id: OLD_FILE_ID, title: 'cat.png' },
365+
{ type: 'file', id: 'wf_apple_output', title: 'apple.png' },
366+
{ type: 'file', id: 'wf_banana_output', title: 'banana.png' },
367+
{ type: 'file', id: 'wf_shared', title: 'shared.pdf' },
368+
{ type: 'workflow', id: 'wflow-1', title: 'My flow' },
369+
],
370+
},
371+
])
372+
// Every chat-owned file of the source chat (uploads + outputs, no cut).
373+
mockListDuplicableChatFiles.mockResolvedValue([
374+
{ id: OLD_FILE_ID },
375+
{ id: 'wf_apple_output' },
376+
{ id: 'wf_banana_output' },
377+
])
378+
// The branch cut keeps (and the plan copies) only the upload.
379+
mockListForkableChatFiles.mockResolvedValue([{ id: OLD_FILE_ID, size: 100 }])
380+
mockPlanChatFileCopies.mockResolvedValue({
381+
idMap: new Map([[OLD_FILE_ID, NEW_FILE_ID]]),
382+
keyMap: new Map(),
383+
blobTasks: [],
384+
})
385+
386+
const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
387+
388+
expect(res.status).toBe(200)
389+
expect(updatedChatRows).toHaveLength(1)
390+
expect(updatedChatRows[0].resources).toEqual([
391+
{ type: 'file', id: NEW_FILE_ID, title: 'cat.png' },
392+
{ type: 'file', id: 'wf_shared', title: 'shared.pdf' },
393+
{ type: 'workflow', id: 'wflow-1', title: 'My flow' },
394+
])
395+
})
396+
397+
it('drops ghosts even when the fork copies no files at all', async () => {
398+
// Fork cut before any upload, but the source chat has an output: the
399+
// old guard skipped the resources update entirely when idMap was empty,
400+
// leaving the ghost in place.
401+
mockSelectRows.mockResolvedValue([
402+
{
403+
...parentRow,
404+
resources: [{ type: 'file', id: 'wf_banana_output', title: 'banana.png' }],
405+
},
406+
])
407+
mockListDuplicableChatFiles.mockResolvedValue([{ id: 'wf_banana_output' }])
408+
mockListForkableChatFiles.mockResolvedValue([])
409+
410+
const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
411+
412+
expect(res.status).toBe(200)
413+
expect(updatedChatRows).toHaveLength(1)
414+
expect(updatedChatRows[0].resources).toEqual([])
415+
})
416+
417+
describe('whole-chat duplicate (no upToMessageId)', () => {
418+
it('keeps every message and copies files with no timeline cut', async () => {
419+
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
420+
const body = await res.json()
421+
422+
expect(res.status).toBe(200)
423+
expect(body.success).toBe(true)
424+
425+
// The whole-chat lister runs (uploads AND outputs, no message cut) — the
426+
// branch lister never does.
427+
expect(mockListDuplicableChatFiles).toHaveBeenCalledTimes(1)
428+
expect(mockListDuplicableChatFiles.mock.calls[0][1]).toBe('chat-1')
429+
expect(mockListForkableChatFiles).not.toHaveBeenCalled()
430+
431+
// Every message is appended — including msg-3, which a branch would cut.
432+
const appended = mockAppendCopilotChatMessages.mock.calls[0]
433+
expect(appended[1].map((m: { id: string }) => m.id)).toEqual(['msg-1', 'msg-2', 'msg-3'])
434+
})
435+
436+
it('titles the copy "<name> (Copy)"', async () => {
437+
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
438+
expect(res.status).toBe(200)
439+
expect(insertedChatRows[0].title).toBe('Generate Logs (Copy)')
440+
})
441+
442+
it('asks the copilot service for whole-chat mode (no upToMessageId in the body)', async () => {
443+
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
444+
const body = await res.json()
445+
expect(res.status).toBe(200)
446+
447+
const goBody = JSON.parse(mockFetchGo.mock.calls[0][1].body)
448+
expect(goBody).toEqual({
449+
sourceChatId: 'chat-1',
450+
newChatId: body.id,
451+
userId: 'user-1',
452+
})
453+
expect('upToMessageId' in goBody).toBe(false)
454+
455+
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
456+
'user-1',
457+
'task_forked',
458+
{ workspace_id: 'ws-1', source_chat_id: 'chat-1', whole_chat: true },
459+
{ groups: { workspace: 'ws-1' } }
460+
)
461+
})
462+
463+
it('gates the quota on the full upload + output byte total', async () => {
464+
mockListDuplicableChatFiles.mockResolvedValue([
465+
{ size: 700, context: 'mothership' },
466+
{ size: 500, context: 'output' },
467+
])
468+
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
469+
470+
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
471+
472+
expect(res.status).toBe(400)
473+
expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 1200)
474+
expect(mockTransaction).not.toHaveBeenCalled()
475+
})
476+
477+
it('duplicates an empty chat cleanly', async () => {
478+
mockLoadCopilotChatMessages.mockResolvedValue([])
479+
480+
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
481+
482+
expect(res.status).toBe(200)
483+
expect(mockAppendCopilotChatMessages.mock.calls[0][1]).toEqual([])
484+
})
485+
486+
it('keeps every resource: all chat-owned files are copied, so none are ghosts', async () => {
487+
mockSelectRows.mockResolvedValue([
488+
{
489+
...parentRow,
490+
resources: [
491+
{ type: 'file', id: OLD_FILE_ID, title: 'cat.png' },
492+
{ type: 'file', id: 'wf_banana_output', title: 'banana.png' },
493+
],
494+
},
495+
])
496+
mockListDuplicableChatFiles.mockResolvedValue([
497+
{ id: OLD_FILE_ID, size: 100 },
498+
{ id: 'wf_banana_output', size: 200 },
499+
])
500+
mockPlanChatFileCopies.mockResolvedValue({
501+
idMap: new Map([
502+
[OLD_FILE_ID, NEW_FILE_ID],
503+
['wf_banana_output', 'wf_banana_copy'],
504+
]),
505+
keyMap: new Map(),
506+
blobTasks: [],
507+
})
508+
509+
const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
510+
511+
expect(res.status).toBe(200)
512+
expect(updatedChatRows[0].resources).toEqual([
513+
{ type: 'file', id: NEW_FILE_ID, title: 'cat.png' },
514+
{ type: 'file', id: 'wf_banana_copy', title: 'banana.png' },
515+
])
516+
})
517+
})
334518
})

0 commit comments

Comments
 (0)