@@ -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
7476vi . 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+
151159function 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) {
176190describe ( '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