@@ -120,6 +120,30 @@ const runBackgroundKnowledgeGapReviewMock = mock((async () => ({
120120 status : "skipped" as const ,
121121 reason : "no_candidate_gap" as const ,
122122} ) ) as ( ...args : unknown [ ] ) => Promise < any > ) ;
123+ const createModelMock = mock ( ( modelId : string ) => modelId ) ;
124+ const generateTextMock = mock ( ( async ( ) => ( {
125+ output : {
126+ title : "Visitor asked for help" ,
127+ confidence : 0.9 ,
128+ reason : "Fallback title for a generic help request." ,
129+ } ,
130+ } ) ) as ( ...args : unknown [ ] ) => Promise < any > ) ;
131+ const resolveModelForExecutionMock = mock ( ( modelId : string ) => ( {
132+ modelIdResolved : modelId ,
133+ } ) ) ;
134+ const loadCurrentConversationMock = mock ( async ( ) => ( {
135+ id : "conv-1" ,
136+ visitorId : "visitor-1" ,
137+ title : null as string | null ,
138+ titleSource : null as "ai" | "user" | null ,
139+ visitorLanguage : "es" ,
140+ } ) ) ;
141+ const realtimeEmitMock = mock ( async ( ) => { } ) ;
142+ const createTimelineItemMock = mock ( async ( ) => { } ) ;
143+ const syncConversationVisitorTitleMock = mock ( async ( ) => ( {
144+ visitorTitle : null ,
145+ visitorTitleLanguage : null ,
146+ } ) ) ;
123147
124148mock . module ( "../logger" , ( ) => ( {
125149 logAiPipeline : logAiPipelineMock ,
@@ -154,6 +178,36 @@ mock.module("./knowledge-gap-review", () => ({
154178 runBackgroundKnowledgeGapReview : runBackgroundKnowledgeGapReviewMock ,
155179} ) ) ;
156180
181+ mock . module ( "@api/lib/ai" , ( ) => ( {
182+ createModel : createModelMock ,
183+ generateText : generateTextMock ,
184+ Output : {
185+ object : ( params : unknown ) => params ,
186+ } ,
187+ } ) ) ;
188+
189+ mock . module ( "@api/lib/ai-credits/config" , ( ) => ( {
190+ resolveModelForExecution : resolveModelForExecutionMock ,
191+ } ) ) ;
192+
193+ mock . module ( "../shared/actions/load-current-conversation" , ( ) => ( {
194+ loadCurrentConversation : loadCurrentConversationMock ,
195+ } ) ) ;
196+
197+ mock . module ( "@api/realtime/emitter" , ( ) => ( {
198+ realtime : {
199+ emit : realtimeEmitMock ,
200+ } ,
201+ } ) ) ;
202+
203+ mock . module ( "@api/utils/timeline-item" , ( ) => ( {
204+ createTimelineItem : createTimelineItemMock ,
205+ } ) ) ;
206+
207+ mock . module ( "@api/lib/translation" , ( ) => ( {
208+ syncConversationVisitorTitle : syncConversationVisitorTitleMock ,
209+ } ) ) ;
210+
157211mock . module ( "../shared/events" , ( ) => ( {
158212 emitPipelineProcessingCompleted : emitPipelineProcessingCompletedMock ,
159213 emitPipelineProcessingCompletedSafely : emitPipelineProcessingCompletedMock ,
@@ -178,6 +232,34 @@ const baseInput = {
178232 jobId : "job-1" ,
179233} ;
180234
235+ function createDbMock ( ) {
236+ const returningMock = mock ( async ( ) => [
237+ {
238+ id : "conv-1" ,
239+ visitorId : "visitor-1" ,
240+ titleSource : "ai" ,
241+ } ,
242+ ] ) ;
243+ const whereMock = mock ( ( ) => ( {
244+ returning : returningMock ,
245+ } ) ) ;
246+ const setMock = mock ( ( ) => ( {
247+ where : whereMock ,
248+ } ) ) ;
249+ const updateMock = mock ( ( ) => ( {
250+ set : setMock ,
251+ } ) ) ;
252+
253+ return {
254+ db : {
255+ update : updateMock ,
256+ } ,
257+ updateMock,
258+ returningMock,
259+ setMock,
260+ } ;
261+ }
262+
181263describe ( "runBackgroundPipeline" , ( ) => {
182264 beforeEach ( ( ) => {
183265 logAiPipelineMock . mockClear ( ) ;
@@ -189,6 +271,13 @@ describe("runBackgroundPipeline", () => {
189271 emitPipelineProcessingCompletedMock . mockReset ( ) ;
190272 runGenerationRuntimeMock . mockReset ( ) ;
191273 runBackgroundKnowledgeGapReviewMock . mockReset ( ) ;
274+ createModelMock . mockReset ( ) ;
275+ generateTextMock . mockReset ( ) ;
276+ resolveModelForExecutionMock . mockReset ( ) ;
277+ loadCurrentConversationMock . mockReset ( ) ;
278+ realtimeEmitMock . mockReset ( ) ;
279+ createTimelineItemMock . mockReset ( ) ;
280+ syncConversationVisitorTitleMock . mockReset ( ) ;
192281
193282 getAiAgentByIdMock . mockResolvedValue ( {
194283 id : "ai-1" ,
@@ -281,6 +370,30 @@ describe("runBackgroundPipeline", () => {
281370 status : "skipped" ,
282371 reason : "no_candidate_gap" ,
283372 } ) ;
373+ createModelMock . mockImplementation ( ( modelId : string ) => modelId ) ;
374+ generateTextMock . mockResolvedValue ( {
375+ output : {
376+ title : "Visitor asked for help" ,
377+ confidence : 0.9 ,
378+ reason : "Fallback title for a generic help request." ,
379+ } ,
380+ } ) ;
381+ resolveModelForExecutionMock . mockImplementation ( ( modelId : string ) => ( {
382+ modelIdResolved : modelId ,
383+ } ) ) ;
384+ loadCurrentConversationMock . mockResolvedValue ( {
385+ id : "conv-1" ,
386+ visitorId : "visitor-1" ,
387+ title : null ,
388+ titleSource : null ,
389+ visitorLanguage : "es" ,
390+ } ) ;
391+ realtimeEmitMock . mockResolvedValue ( undefined ) ;
392+ createTimelineItemMock . mockResolvedValue ( undefined ) ;
393+ syncConversationVisitorTitleMock . mockResolvedValue ( {
394+ visitorTitle : null ,
395+ visitorTitleLanguage : null ,
396+ } ) ;
284397 } ) ;
285398
286399 it ( "skips when no background analysis capabilities are enabled" , async ( ) => {
@@ -302,6 +415,7 @@ describe("runBackgroundPipeline", () => {
302415 expect ( result . status ) . toBe ( "skipped" ) ;
303416 expect ( result . reason ) . toBe ( "No background analysis capabilities enabled" ) ;
304417 expect ( runGenerationRuntimeMock ) . not . toHaveBeenCalled ( ) ;
418+ expect ( generateTextMock ) . not . toHaveBeenCalled ( ) ;
305419 expect ( emitPipelineProcessingCompletedMock ) . toHaveBeenCalledWith (
306420 expect . objectContaining ( {
307421 status : "skipped" ,
@@ -338,15 +452,11 @@ describe("runBackgroundPipeline", () => {
338452 allowPublicMessages : false ,
339453 hasLaterHumanMessage : false ,
340454 hasLaterAiMessage : false ,
341- toolAllowlist : [
342- "updateConversationTitle" ,
343- "updateSentiment" ,
344- "setPriority" ,
345- "skip" ,
346- ] ,
455+ toolAllowlist : [ "updateSentiment" , "setPriority" , "skip" ] ,
347456 availableViews : [ ] ,
348457 } )
349458 ) ;
459+ expect ( generateTextMock ) . toHaveBeenCalledTimes ( 1 ) ;
350460 expect ( emitPipelineProcessingCompletedMock ) . toHaveBeenCalledWith (
351461 expect . objectContaining ( {
352462 status : "success" ,
@@ -425,6 +535,14 @@ describe("runBackgroundPipeline", () => {
425535 } ) ;
426536
427537 it ( "returns skipped when the analysis run makes no metadata mutation" , async ( ) => {
538+ getBehaviorSettingsMock . mockReturnValue ( {
539+ autoGenerateTitle : false ,
540+ autoAnalyzeSentiment : true ,
541+ canSetPriority : false ,
542+ autoCategorize : false ,
543+ canCategorize : false ,
544+ canRequestKnowledgeClarification : false ,
545+ } ) ;
428546 runGenerationRuntimeMock . mockResolvedValueOnce ( {
429547 status : "completed" ,
430548 action : {
@@ -457,6 +575,95 @@ describe("runBackgroundPipeline", () => {
457575 ) ;
458576 } ) ;
459577
578+ it ( "returns completed when the dedicated title review updates the title even if generic analysis skips" , async ( ) => {
579+ generateTextMock . mockResolvedValueOnce ( {
580+ output : {
581+ title : "Invoice export issue" ,
582+ confidence : 0.93 ,
583+ reason : "Clear billing export topic" ,
584+ } ,
585+ } ) ;
586+ runGenerationRuntimeMock . mockResolvedValueOnce ( {
587+ status : "completed" ,
588+ action : {
589+ action : "skip" ,
590+ reasoning : "No generic metadata update" ,
591+ confidence : 1 ,
592+ } ,
593+ publicMessagesSent : 0 ,
594+ toolCallsByName : {
595+ skip : 1 ,
596+ } ,
597+ mutationToolCallsByName : { } ,
598+ totalToolCalls : 1 ,
599+ } ) ;
600+
601+ const { runBackgroundPipeline } = await modulePromise ;
602+ const { db } = createDbMock ( ) ;
603+ const result = await runBackgroundPipeline ( {
604+ db : db as never ,
605+ input : baseInput ,
606+ } ) ;
607+
608+ expect ( result . status ) . toBe ( "completed" ) ;
609+ expect ( emitPipelineProcessingCompletedMock ) . toHaveBeenCalledWith (
610+ expect . objectContaining ( {
611+ status : "success" ,
612+ action : "updateConversationTitle" ,
613+ reason : "Clear billing export topic" ,
614+ workflowRunId : "wf-1" ,
615+ audience : "dashboard" ,
616+ } )
617+ ) ;
618+ } ) ;
619+
620+ it ( "schedules dedicated title review when title generation is the only enabled capability" , async ( ) => {
621+ getBehaviorSettingsMock . mockReturnValue ( {
622+ autoGenerateTitle : true ,
623+ autoAnalyzeSentiment : false ,
624+ canSetPriority : false ,
625+ autoCategorize : false ,
626+ canCategorize : false ,
627+ canRequestKnowledgeClarification : false ,
628+ } ) ;
629+ generateTextMock . mockResolvedValueOnce ( {
630+ output : {
631+ title : "Invoice export issue" ,
632+ confidence : 0.93 ,
633+ reason : "Clear topic" ,
634+ } ,
635+ } ) ;
636+ runGenerationRuntimeMock . mockResolvedValueOnce ( {
637+ status : "completed" ,
638+ action : {
639+ action : "skip" ,
640+ reasoning : "No generic metadata update" ,
641+ confidence : 1 ,
642+ } ,
643+ publicMessagesSent : 0 ,
644+ toolCallsByName : {
645+ skip : 1 ,
646+ } ,
647+ mutationToolCallsByName : { } ,
648+ totalToolCalls : 1 ,
649+ } ) ;
650+
651+ const { runBackgroundPipeline } = await modulePromise ;
652+ const { db } = createDbMock ( ) ;
653+ const result = await runBackgroundPipeline ( {
654+ db : db as never ,
655+ input : baseInput ,
656+ } ) ;
657+
658+ expect ( result . status ) . toBe ( "completed" ) ;
659+ expect ( generateTextMock ) . toHaveBeenCalledTimes ( 1 ) ;
660+ expect ( runGenerationRuntimeMock ) . toHaveBeenCalledWith (
661+ expect . objectContaining ( {
662+ toolAllowlist : [ "skip" ] ,
663+ } )
664+ ) ;
665+ } ) ;
666+
460667 it ( "loads active views and enables categorizeConversation when categorization is available" , async ( ) => {
461668 getBehaviorSettingsMock . mockReturnValue ( {
462669 autoGenerateTitle : false ,
0 commit comments