From be51b4cbc805cb311c7a3629ccf0d0fc169aa85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 10:06:11 +0800 Subject: [PATCH] fix: generate session titles after assistant turns Desktop title generation previously upgraded the first sidebar title from the raw user prompt before assistant output existed. That made image-plus-text sessions vulnerable to keeping a title based only on the user's initial wording. This moves polished title generation to completed turns, reuses the CLI title prompt and transcript extractor, and routes title calls through the configured haiku model with a retry when disabled thinking is rejected. Constraint: The first user message still needs an immediate placeholder title so the sidebar is responsive. Rejected: Generate the AI title before assistant output | image-derived context is only available after the assistant turn completes. Confidence: high Scope-risk: moderate Directive: Keep polished automatic titles tied to completed transcript turns, not pre-response user prompts. Tested: bun test src/server/__tests__/title-service.test.ts Tested: bun test src/server/__tests__/conversations.test.ts -t "refreshes the first-turn AI title" Tested: git diff --check Tested: Real MiniMax-M3 /tmp session f89073ed-3467-492a-b63c-e1886c146a3a with image attachment wrote ai-title "Validate session title generation" Not-tested: bun run check:server is blocked by existing expired quarantine manifest entries --- src/server/__tests__/conversations.test.ts | 99 ++++++++++ src/server/__tests__/title-service.test.ts | 69 +++++-- src/server/services/titleService.ts | 93 ++++++--- src/server/ws/handler.ts | 216 ++++++++++++++++++--- src/utils/sessionTitle.ts | 2 +- 5 files changed, 408 insertions(+), 71 deletions(-) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 9a231652..8366a152 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -1185,6 +1185,105 @@ describe('WebSocket Chat Integration', () => { expect(titleIndex).toBeLessThan(completionIndex) }) + it('refreshes the first-turn AI title from the completed assistant transcript', async () => { + const providerConfigPath = path.join(tmpDir, 'cc-haha', 'providers.json') + const originalProviderConfig = await fs.readFile(providerConfigPath, 'utf-8').catch(() => null) + const upstreamInputs: string[] = [] + const titleModelServer = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const body = await req.json() as { + messages?: Array<{ content?: unknown }> + } + const input = String(body.messages?.[0]?.content ?? '') + upstreamInputs.push(input) + const title = input.includes('Echo: 看一下这个搜索结果') + ? 'Google 搜索企查查结果' + : 'Premature user title' + return Response.json({ + content: [{ type: 'text', text: JSON.stringify({ title }) }], + }) + }, + }) + + try { + await fs.mkdir(path.dirname(providerConfigPath), { recursive: true }) + await fs.writeFile( + providerConfigPath, + JSON.stringify({ + activeId: 'title-transcript-provider', + providers: [ + { + id: 'title-transcript-provider', + presetId: 'minimax', + name: 'Title Transcript Provider', + apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${titleModelServer.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'minimax-main', + haiku: 'minimax-haiku', + sonnet: 'minimax-main', + opus: 'minimax-main', + }, + }, + ], + }, null, 2), + 'utf-8', + ) + + const sessionId = `title-transcript-${crypto.randomUUID()}` + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Timed out waiting for transcript-backed session title')) + }, 8000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + if (msg.type === 'connected') { + ws.send(JSON.stringify({ + type: 'user_message', + content: '看一下这个搜索结果,请一条一条给我列出来', + })) + return + } + if (msg.type === 'session_title_updated' && msg.title === 'Google 搜索企查查结果') { + clearTimeout(timeout) + ws.close() + resolve() + } + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for title transcript session ${sessionId}`)) + } + }) + + const titleMessages = messages.filter((msg) => msg.type === 'session_title_updated') + expect(titleMessages[0]?.title).toBe('看一下这个搜索结果,请一条一条给我列出来') + expect(titleMessages.map((msg) => msg.title)).toContain('Google 搜索企查查结果') + expect(upstreamInputs.some((input) => input.includes('Echo: 看一下这个搜索结果'))).toBe(true) + } finally { + titleModelServer.stop(true) + if (originalProviderConfig === null) { + await fs.rm(providerConfigPath, { force: true }) + } else { + await fs.writeFile(providerConfigPath, originalProviderConfig, 'utf-8') + } + } + }, 10000) + it('uses the /goal objective for the derived session title', async () => { const sessionId = `title-goal-${crypto.randomUUID()}` const messages: any[] = [] diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index 541c2389..da9d3800 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -27,7 +27,7 @@ describe('titleService', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - test('sends disabled thinking for opted-in providers when desktop thinking is off', async () => { + test('sends disabled thinking for title generation by default', async () => { let requestBody: Record | null = null const server = Bun.serve({ hostname: '127.0.0.1', @@ -43,10 +43,6 @@ describe('titleService', () => { try { const providerId = 'zhipu-test' await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) - await fs.writeFile( - path.join(tmpDir, 'settings.json'), - JSON.stringify({ alwaysThinkingEnabled: false }, null, 2), - ) await fs.writeFile( path.join(tmpDir, 'cc-haha', 'providers.json'), JSON.stringify({ @@ -77,7 +73,59 @@ describe('titleService', () => { } }) - test('sends disabled thinking for DeepSeek title generation when desktop thinking is off', async () => { + test('retries title generation without thinking when the provider rejects it', async () => { + const requestBodies: Array> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const requestBody = await req.json() as Record + requestBodies.push(requestBody) + if (requestBody.thinking) { + return Response.json({ error: 'thinking is not supported' }, { status: 400 }) + } + return Response.json({ + content: [{ type: 'text', text: '{"title":"Trace ok"}' }], + }) + }, + }) + + try { + const providerId = 'fallback-thinking-test' + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ + activeId: providerId, + providers: [ + { + id: providerId, + presetId: 'custom', + name: 'Thinking Fallback', + apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${server.port}/anthropic`, + apiFormat: 'anthropic', + models: { + main: 'fallback-main', + haiku: 'fallback-haiku', + sonnet: 'fallback-main', + opus: 'fallback-main', + }, + }, + ], + }, null, 2), + ) + + await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok') + expect(requestBodies).toHaveLength(2) + expect(requestBodies[0].thinking).toEqual({ type: 'disabled' }) + expect(requestBodies[1].thinking).toBeUndefined() + } finally { + server.stop(true) + } + }) + + test('sends disabled thinking for DeepSeek title generation', async () => { let requestBody: Record | null = null const server = Bun.serve({ hostname: '127.0.0.1', @@ -93,10 +141,6 @@ describe('titleService', () => { try { const providerId = 'deepseek-test' await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) - await fs.writeFile( - path.join(tmpDir, 'settings.json'), - JSON.stringify({ alwaysThinkingEnabled: false }, null, 2), - ) await fs.writeFile( path.join(tmpDir, 'cc-haha', 'providers.json'), JSON.stringify({ @@ -186,7 +230,10 @@ describe('titleService', () => { '@website 重新设计首页', ].join('\n'))).resolves.toBe('Redesign website') - expect(requestBody?.messages?.[0]?.content).toBe('/frontend-design @website 重新设计首页') + const titlePrompt = String(requestBody?.messages?.[0]?.content ?? '') + expect(titlePrompt).toContain('/frontend-design @website 重新设计首页') + expect(titlePrompt).toContain('') + expect(titlePrompt).not.toContain('') } finally { server.stop(true) } diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index a815f9af..6947dd1b 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -7,7 +7,6 @@ */ import { ProviderService } from './providerService.js' -import { SettingsService } from './settingsService.js' import { sessionService } from './sessionService.js' import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' @@ -16,23 +15,49 @@ import { resolveOpenAICodexModel } from '../../services/openaiAuth/models.js' import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js' import { openaiResponsesStreamToAnthropicResponse } from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js' +import { extractConversationText, SESSION_TITLE_PROMPT } from '../../utils/sessionTitle.js' const TITLE_MAX_LEN = 50 const TITLE_MAX_OUTPUT_TOKENS = 100 +const TITLE_INPUT_MAX_LEN = 2000 -const TITLE_SYSTEM_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. +export type TitleConversationTurn = { + userText: string + assistantText?: string +} -Return JSON with a single "title" field. +export function buildConversationTitleInput(turns: TitleConversationTurn[]): string { + const messages = turns.flatMap((turn) => { + const entries: any[] = [ + { + type: 'user', + message: { content: turn.userText }, + }, + ] + const assistantText = turn.assistantText?.trim() + if (assistantText) { + entries.push({ + type: 'assistant', + message: { content: assistantText }, + }) + } + return entries + }) -Good examples: -{"title": "Fix login button on mobile"} -{"title": "Add OAuth authentication"} -{"title": "Debug failing CI tests"} -{"title": "Refactor API client error handling"} + return extractConversationText(messages as any) +} -Bad (too vague): {"title": "Code changes"} -Bad (too long): {"title": "Investigate and fix the issue where the login button does not respond on mobile devices"} -Bad (wrong case): {"title": "Fix Login Button On Mobile"}` +function buildTitleUserPrompt(trimmed: string): string { + return [ + 'Generate a title for the following conversation transcript.', + 'Do not answer, continue, or summarize the conversation itself.', + 'Return only JSON with a single "title" field.', + '', + '', + trimmed.slice(0, TITLE_INPUT_MAX_LEN), + '', + ].join('\n') +} /** * Quick placeholder title derived from user message text. @@ -87,25 +112,38 @@ export async function generateTitle( const model = resolvedProvider.models.haiku || resolvedProvider.models.main const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` - const shouldDisableThinking = await shouldDisableThinkingForTitle() + const userPrompt = buildTitleUserPrompt(trimmed) + const requestHeaders = { + 'Content-Type': 'application/json', + 'x-api-key': resolvedProvider.apiKey, + 'anthropic-version': '2023-06-01', + } + const requestBody = { + model, + max_tokens: TITLE_MAX_OUTPUT_TOKENS, + system: SESSION_TITLE_PROMPT, + messages: [{ role: 'user', content: userPrompt }], + } - const response = await fetch(url, { + let response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': resolvedProvider.apiKey, - 'anthropic-version': '2023-06-01', - }, + headers: requestHeaders, body: JSON.stringify({ - model, - max_tokens: 100, - system: TITLE_SYSTEM_PROMPT, - messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], - ...(shouldDisableThinking && { thinking: { type: 'disabled' } }), + ...requestBody, + thinking: { type: 'disabled' }, }), signal: AbortSignal.timeout(15_000), }) + if (!response.ok && response.status >= 400 && response.status < 500) { + response = await fetch(url, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(15_000), + }) + } + if (!response.ok) return null const body = (await response.json()) as { @@ -131,8 +169,8 @@ async function generateOpenAIOfficialTitle( const requestBody = anthropicToOpenaiResponses({ model: mappedModel, max_tokens: TITLE_MAX_OUTPUT_TOKENS, - system: TITLE_SYSTEM_PROMPT, - messages: [{ role: 'user', content: trimmed.slice(0, 2000) }], + system: SESSION_TITLE_PROMPT, + messages: [{ role: 'user', content: buildTitleUserPrompt(trimmed) }], stream: true, thinking: { type: 'disabled' }, }) @@ -236,11 +274,6 @@ function looksLikeStructuredTitleFragment(text: string): boolean { ) } -async function shouldDisableThinkingForTitle(): Promise { - const settings = await new SettingsService().getUserSettings() - return settings.alwaysThinkingEnabled === false -} - /** * Persist an AI-generated title to the session's JSONL file. * Returns false when a user custom title exists, because custom titles are diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 6abb0107..2396793d 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -19,7 +19,13 @@ import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js' import { diagnosticsService } from '../services/diagnosticsService.js' -import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' +import { + buildConversationTitleInput, + deriveTitle, + generateTitle, + saveAiTitle, + type TitleConversationTurn, +} from '../services/titleService.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { COMMAND_NAME_TAG, @@ -63,8 +69,10 @@ const sessionTitleState = new Map + completedTurns: TitleConversationTurn[] + activeTurn?: TitleConversationTurn & { count: number } + startedGenerationKeys: Set + generationSeq: number }>() const runtimeOverrides = new Map(), + completedTurns: [], + startedGenerationKeys: new Set(), + generationSeq: 0, } sessionTitleState.set(sessionId, titleState) } const titleInput = getTitleInputForUserMessage(message.content, desktopSlashCommand) + let titleTurnNumber: number | null = null if (titleInput) { titleState.userMessageCount++ - titleState.allUserMessages.push(titleInput) + titleTurnNumber = titleState.userMessageCount + titleState.activeTurn = { + count: titleTurnNumber, + userText: titleInput, + assistantText: '', + } if (titleState.userMessageCount === 1) { titleState.firstUserMessage = titleInput } - triggerTitleGeneration(ws, sessionId) + triggerTitleGeneration(ws, sessionId, 'user-message') } // 启动 CLI 子进程(如果还没有) @@ -359,6 +374,9 @@ async function handleUserMessage( let userMessageSent = false const shouldForwardCurrentTurnLocalCommand = createCurrentTurnLocalCommandForwarder(desktopSlashCommand) + const removeTitleOutputCallback = titleTurnNumber === null + ? null + : bindTitleSessionOutput(ws, sessionId, () => userMessageSent) bindAllClientSessionOutputs(sessionId, { shouldForward: (cliMsg) => { @@ -375,6 +393,8 @@ async function handleUserMessage( message.attachments ) if (!sent) { + removeTitleOutputCallback?.() + discardActiveTitleTurn(sessionId, titleTurnNumber) sendMessage(ws, { type: 'error', message: 'CLI process is not running. The session may have ended or the process crashed.', @@ -763,27 +783,30 @@ function handleStopGeneration(ws: ServerWebSocket) { // Title generation // ============================================================================ -function triggerTitleGeneration(ws: ServerWebSocket, sessionId: string): void { +type TitleGenerationPhase = 'user-message' | 'turn-complete' + +function triggerTitleGeneration( + ws: ServerWebSocket, + sessionId: string, + phase: TitleGenerationPhase, + completedTurnCount?: number, +): void { const state = sessionTitleState.get(sessionId) if (!state || state.hasCustomTitle) return - const count = state.userMessageCount + const count = phase === 'turn-complete' + ? completedTurnCount ?? state.userMessageCount + : state.userMessageCount - // Generate on count 1 (first response) and count 3 (with more context) - if (count !== 1 && count !== 3) return - if (state.startedGenerationCounts.has(count)) return - state.startedGenerationCounts.add(count) + if (phase === 'user-message') { + if (count !== 1) return + const key = 'placeholder:1' + if (state.startedGenerationKeys.has(key)) return + state.startedGenerationKeys.add(key) - const text = count === 1 - ? state.firstUserMessage - : state.allUserMessages.join('\n') - const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId - - // Fire-and-forget: derive quick title, then upgrade with AI - void (async () => { - try { - // Stage 1: quick placeholder (only on first message) - if (count === 1) { + void (async () => { + try { + const text = state.firstUserMessage const placeholder = deriveTitle(text) if (placeholder) { const saved = await saveAiTitle(sessionId, placeholder) @@ -791,19 +814,36 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s state.hasCustomTitle = true return } - sendMessage(ws, { type: 'session_title_updated', sessionId, title: placeholder }) + sendSessionTitleUpdated(ws, sessionId, placeholder) } + } catch (err) { + console.error(`[Title] Failed to derive title for ${sessionId}:`, err) } + })() + return + } - // Stage 2: AI-generated title + // Generate polished titles after assistant output completes on turn 1 and 3. + if (count !== 1 && count !== 3) return + const key = `complete:${count}` + if (state.startedGenerationKeys.has(key)) return + state.startedGenerationKeys.add(key) + + const text = buildConversationTitleInput(state.completedTurns) + const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId + const generationSeq = ++state.generationSeq + + void (async () => { + try { const aiTitle = await generateTitle(text, runtimeProviderId) + if (generationSeq !== state.generationSeq) return if (aiTitle) { const saved = await saveAiTitle(sessionId, aiTitle) if (!saved) { state.hasCustomTitle = true return } - sendMessage(ws, { type: 'session_title_updated', sessionId, title: aiTitle }) + sendSessionTitleUpdated(ws, sessionId, aiTitle) } } catch (err) { console.error(`[Title] Failed to generate title for ${sessionId}:`, err) @@ -811,6 +851,127 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s })() } +function sendSessionTitleUpdated( + fallbackWs: ServerWebSocket, + sessionId: string, + title: string, +): void { + const payload: ServerMessage = { type: 'session_title_updated', sessionId, title } + const clients = activeSessions.get(sessionId) + if (!clients?.size) { + sendMessage(fallbackWs, payload) + return + } + for (const client of clients) { + sendMessage(client, payload) + } +} + +function bindTitleSessionOutput( + ws: ServerWebSocket, + sessionId: string, + shouldProcess: () => boolean, +): () => void { + const callback = (cliMsg: any) => { + if (!shouldProcess() && !(cliMsg?.type === 'result' && cliMsg?.is_error)) { + return + } + + appendAssistantTextForTitle(sessionId, cliMsg) + + if (cliMsg?.type === 'result') { + conversationService.removeOutputCallback(sessionId, callback) + const completedTurnCount = completeActiveTitleTurn(sessionId) + if (!cliMsg.is_error) { + triggerTitleGeneration(ws, sessionId, 'turn-complete', completedTurnCount ?? undefined) + } + } + } + + conversationService.onOutput(sessionId, callback) + return () => conversationService.removeOutputCallback(sessionId, callback) +} + +function appendAssistantTextForTitle(sessionId: string, cliMsg: any): void { + const activeTurn = sessionTitleState.get(sessionId)?.activeTurn + if (!activeTurn) return + + const streamText = extractAssistantStreamTextForTitle(cliMsg) + if (streamText) { + activeTurn.assistantText = `${activeTurn.assistantText ?? ''}${streamText}` + return + } + + const assistantText = extractAssistantMessageTextForTitle(cliMsg) + if (assistantText) { + activeTurn.assistantText = activeTurn.assistantText + ? `${activeTurn.assistantText}\n${assistantText}` + : assistantText + return + } + + if ( + cliMsg?.type === 'result' && + !cliMsg.is_error && + !activeTurn.assistantText && + typeof cliMsg.result === 'string' + ) { + activeTurn.assistantText = cliMsg.result + } +} + +function extractAssistantStreamTextForTitle(cliMsg: any): string | null { + const event = cliMsg?.event + if ( + cliMsg?.type !== 'stream_event' || + event?.type !== 'content_block_delta' || + event.delta?.type !== 'text_delta' || + typeof event.delta.text !== 'string' + ) { + return null + } + return event.delta.text +} + +function extractAssistantMessageTextForTitle(cliMsg: any): string | null { + if (cliMsg?.type !== 'assistant') return null + const content = cliMsg.message?.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return null + const text = content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const typedBlock = block as { type?: unknown; text?: unknown } + return typedBlock.type === 'text' && typeof typedBlock.text === 'string' + ? [typedBlock.text] + : [] + }) + .join('\n') + .trim() + return text || null +} + +function completeActiveTitleTurn(sessionId: string): number | null { + const state = sessionTitleState.get(sessionId) + const activeTurn = state?.activeTurn + if (!state || !activeTurn) return null + + state.completedTurns.push({ + userText: activeTurn.userText, + assistantText: activeTurn.assistantText?.trim(), + }) + state.activeTurn = undefined + return activeTurn.count +} + +function discardActiveTitleTurn(sessionId: string, count: number | null): void { + if (count === null) return + const state = sessionTitleState.get(sessionId) + if (state?.activeTurn?.count === count) { + state.activeTurn = undefined + } +} + // ============================================================================ // CLI message translation // ============================================================================ @@ -1889,9 +2050,6 @@ function bindClientSessionOutput( sendMessage(ws, msg) } - if (cliMsg.type === 'result') { - triggerTitleGeneration(ws, sessionId) - } } clientOutputCallbacks.set(ws, { sessionId, callback }) diff --git a/src/utils/sessionTitle.ts b/src/utils/sessionTitle.ts index f97cae3c..013706cb 100644 --- a/src/utils/sessionTitle.ts +++ b/src/utils/sessionTitle.ts @@ -55,7 +55,7 @@ export function extractConversationText(messages: Message[]): string { : text } -const SESSION_TITLE_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. +export const SESSION_TITLE_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns. Return JSON with a single "title" field.