@@ -12,8 +12,8 @@ import {
1212 type ChatSessionDetailDto ,
1313 type ChatSessionSummaryDto ,
1414} from './chat.model.js' ;
15- import { buildChatPrompt } from './chat.prompts.js' ;
16- import { eventsByRun , toMessageDto } from './chat.utility.js' ;
15+ import { buildChatPrompt , buildTitlePrompt } from './chat.prompts.js' ;
16+ import { cleanTitle , eventsByRun , toMessageDto } from './chat.utility.js' ;
1717
1818/**
1919 * Owns interactive chat sessions. Reuses HermesAgentService (CHAT phase) and the Langfuse
@@ -131,6 +131,8 @@ export class ChatService {
131131 throw new NotFoundException ( `Chat session ${ sessionId } not found` ) ;
132132 }
133133
134+ const priorCount = await this . prisma . chatMessage . count ( { where : { sessionId } } ) ;
135+
134136 await this . prisma . chatMessage . create ( { data : { sessionId, role : 'user' , content } } ) ;
135137 await this . touch ( sessionId ) ;
136138
@@ -146,6 +148,11 @@ export class ChatService {
146148 branchName : `chat-${ sessionId . slice ( 0 , 8 ) } ` ,
147149 } ) ;
148150
151+ // First message of an untitled session → auto-generate a title in the background.
152+ if ( priorCount === 0 && session . title === DEFAULT_CHAT_TITLE ) {
153+ void this . generateTitle ( sessionId , ws . dir , content ) ;
154+ }
155+
149156 const prompt = buildChatPrompt ( { repoUrl : session . repoUrl , history } ) ;
150157
151158 const runId = await new Promise < string > ( ( resolve , reject ) => {
@@ -195,6 +202,42 @@ export class ChatService {
195202 }
196203 }
197204
205+ /**
206+ * Best-effort auto-title from the first user message via a short, tool-less agent run on the
207+ * auxiliary model (falling back to the primary when no auxiliary is configured). Runs under the
208+ * concurrency semaphore and is unowned (no jobId/sessionId), so it never shows as chat activity
209+ * or as the session's active run. A failure or empty result leaves the default title in place.
210+ */
211+ private async generateTitle ( sessionId : string , cwd : string , firstMessage : string ) : Promise < void > {
212+ await this . acquire ( ) ;
213+
214+ try {
215+ const res = await this . agent . run ( {
216+ phase : 'TITLE' ,
217+ cwd,
218+ prompt : buildTitlePrompt ( firstMessage ) ,
219+ model : this . config . get ( 'HERMES_AUXILIARY_MODEL' ) || this . config . get ( 'HERMES_PRIMARY_MODEL' ) ,
220+ provider :
221+ this . config . get ( 'HERMES_AUXILIARY_PROVIDER' ) ||
222+ this . config . get ( 'HERMES_PRIMARY_PROVIDER' ) ,
223+ } ) ;
224+
225+ const title = res . status === 'SUCCEEDED' ? cleanTitle ( res . stdout ) : '' ;
226+
227+ // Only overwrite if still the default — a user/explicit title set meanwhile wins.
228+ if ( title . length > 0 ) {
229+ await this . prisma . chatSession . updateMany ( {
230+ where : { id : sessionId , title : DEFAULT_CHAT_TITLE } ,
231+ data : { title } ,
232+ } ) ;
233+ }
234+ } catch ( e ) {
235+ this . logger . warn ( `chat title generation failed: ${ ( e as Error ) . message } ` ) ;
236+ } finally {
237+ this . release ( ) ;
238+ }
239+ }
240+
198241 private authFor ( session : ChatSession ) : RemoteAuth {
199242 return session . repoUrl ? { kind : 'ssh' , url : session . repoUrl } : { kind : 'none' } ;
200243 }
0 commit comments