@@ -67,9 +67,15 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => {
6767import type { ApiClientRequest } from '@/lib/api/client/request'
6868import type { AnyApiRouteContract } from '@/lib/api/contracts'
6969import type { CopilotChatAbortBody , CopilotChatStopBody } from '@/lib/api/contracts/copilot'
70- import { resetDeploymentShape } from '@/lib/core/config/deployment-shape'
70+ import {
71+ resetDeploymentShape ,
72+ resolveDeploymentShape ,
73+ seedDeploymentShape ,
74+ } from '@/lib/core/config/deployment-shape'
7175import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
7276import type { MothershipStreamV1EventEnvelope } from '@/lib/mothership/generated/mothership-stream-v1'
77+ import { getChatResourceSelectionId } from '@/lib/mothership/resources/types'
78+ import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
7379import {
7480 readQueuedSendHandoffState ,
7581 writeQueuedSendHandoffState ,
@@ -497,6 +503,131 @@ describe('useChat remount send recovery', () => {
497503 resetDeploymentShape ( )
498504 } )
499505
506+ it . each ( [ false , true ] ) (
507+ 'keeps Search citations in the answer without replacing user panels (existing search: %s)' ,
508+ async ( hasSearchPanel ) => {
509+ const shape = resolveDeploymentShape ( )
510+ seedDeploymentShape ( {
511+ ...shape ,
512+ features : { ...shape . features , liveEnterpriseSearch : true } ,
513+ } )
514+ const history : MothershipChatHistory = {
515+ id : 'chat-cited-search' ,
516+ title : 'Search' ,
517+ messages : [ ] ,
518+ activeStreamId : null ,
519+ resources : hasSearchPanel
520+ ? [
521+ {
522+ type : 'search' ,
523+ id : 'search:workspace:ws-1' ,
524+ title : 'Search results' ,
525+ workspaceId : 'ws-1' ,
526+ search : { query : 'policy' , scope : { kind : 'workspace' , workspaceId : 'ws-1' } } ,
527+ } ,
528+ {
529+ type : 'sources' ,
530+ id : 'cited-sources' ,
531+ title : 'Sources' ,
532+ sources : { messageId : 'previous-answer' } ,
533+ } ,
534+ ]
535+ : [ ] ,
536+ }
537+ vi . stubGlobal ( 'fetch' , async ( input : RequestInfo | URL , init ?: RequestInit ) => {
538+ if ( String ( input ) !== '/api/mothership/chat' || init ?. method !== 'POST' ) {
539+ return fetchStub ( input , init )
540+ }
541+ const sent = JSON . parse ( String ( init . body ) )
542+ const envelope = { v : 1 as const , ts : '' , stream : { streamId : sent . userMessageId } }
543+ const events : MothershipStreamV1EventEnvelope [ ] = [
544+ {
545+ ...envelope ,
546+ seq : 1 ,
547+ type : 'tool' ,
548+ payload : {
549+ phase : 'call' ,
550+ executor : 'go' ,
551+ mode : 'sync' ,
552+ toolName : 'search_workspace' ,
553+ toolCallId : 'search-policy' ,
554+ arguments : { query : 'policy' } ,
555+ } ,
556+ } ,
557+ {
558+ ...envelope ,
559+ seq : 2 ,
560+ type : 'tool' ,
561+ payload : {
562+ phase : 'result' ,
563+ toolName : 'search_workspace' ,
564+ toolCallId : 'search-policy' ,
565+ success : true ,
566+ output : {
567+ results : [
568+ {
569+ citationId : 'policy' ,
570+ citationUrl : 'https://example.com/policy' ,
571+ documentName : 'Policy' ,
572+ content : 'Policy evidence' ,
573+ } ,
574+ ] ,
575+ } ,
576+ } ,
577+ } ,
578+ {
579+ ...envelope ,
580+ seq : 3 ,
581+ type : 'text' ,
582+ payload : {
583+ channel : 'assistant' ,
584+ text : 'Here is the policy. <source>{"id":"policy"}</source>' ,
585+ } ,
586+ } ,
587+ {
588+ ...envelope ,
589+ seq : 4 ,
590+ type : 'resource' ,
591+ payload : {
592+ op : 'upsert' ,
593+ resource : {
594+ type : 'search' ,
595+ id : 'search:workspace:ws-1' ,
596+ title : 'Search results' ,
597+ workspaceId : 'ws-1' ,
598+ search : { query : 'policy' , scope : { kind : 'workspace' , workspaceId : 'ws-1' } } ,
599+ } ,
600+ } ,
601+ } ,
602+ { ...envelope , seq : 5 , type : 'complete' , payload : { status : 'complete' } } ,
603+ ]
604+ return new Response ( events . map ( ( event ) => `data: ${ JSON . stringify ( event ) } \n\n` ) . join ( '' ) , {
605+ headers : { 'Content-Type' : 'text/event-stream' , 'x-mothership-chat-id' : history . id } ,
606+ } )
607+ } )
608+ const selectedId = history . resources [ 0 ]
609+ ? getChatResourceSelectionId ( history . resources [ 0 ] )
610+ : undefined
611+ const { getResult } = renderUseChatInChat (
612+ history . id ,
613+ history ,
614+ undefined ,
615+ selectedId ,
616+ 'assistant'
617+ )
618+ expect ( getResult ( ) . resources ) . toEqual ( history . resources )
619+ await act ( async ( ) => {
620+ await getResult ( ) . sendMessage ( 'Find the policy' )
621+ } )
622+ const answer = getResult ( ) . messages . find ( ( message ) => message . role === 'assistant' )
623+ const sources = collectCitedMessageSources ( answer ?. contentBlocks ?? [ ] , answer ?. content ?? '' )
624+ expect ( sources . map ( ( source ) => source . url ) ) . toEqual ( [ 'https://example.com/policy' ] )
625+ expect ( getResult ( ) . resources ) . toEqual ( history . resources )
626+ expect ( getResult ( ) . activeResourceId ) . toBe ( selectedId ?? null )
627+ expect ( getResult ( ) . isSending ) . toBe ( false )
628+ }
629+ )
630+
500631 it . each ( [ 'workspace' , 'organization' ] as const ) (
501632 'preserves a %s send stopped during preparation and keeps the next send in that chat' ,
502633 async ( scope ) => {
0 commit comments