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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 10:06:11 +08:00
parent 950590ca5d
commit be51b4cbc8
5 changed files with 408 additions and 71 deletions

View File

@ -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<void>((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[] = []

View File

@ -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<string, unknown> | 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<Record<string, unknown>> = []
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const requestBody = await req.json() as Record<string, unknown>
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<string, unknown> | 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', () => {
'<command-args>@website 重新设计首页</command-args>',
].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('<conversation>')
expect(titlePrompt).not.toContain('<command-message>')
} finally {
server.stop(true)
}

View File

@ -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.',
'',
'<conversation>',
trimmed.slice(0, TITLE_INPUT_MAX_LEN),
'</conversation>',
].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<boolean> {
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

View File

@ -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<string, {
userMessageCount: number
hasCustomTitle: boolean
firstUserMessage: string
allUserMessages: string[]
startedGenerationCounts: Set<number>
completedTurns: TitleConversationTurn[]
activeTurn?: TitleConversationTurn & { count: number }
startedGenerationKeys: Set<string>
generationSeq: number
}>()
const runtimeOverrides = new Map<string, {
@ -310,19 +318,26 @@ async function handleUserMessage(
userMessageCount: 0,
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
firstUserMessage: '',
allUserMessages: [],
startedGenerationCounts: new Set<number>(),
completedTurns: [],
startedGenerationKeys: new Set<string>(),
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<WebSocketData>) {
// Title generation
// ============================================================================
function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: string): void {
type TitleGenerationPhase = 'user-message' | 'turn-complete'
function triggerTitleGeneration(
ws: ServerWebSocket<WebSocketData>,
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<WebSocketData>, 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<WebSocketData>, sessionId: s
})()
}
function sendSessionTitleUpdated(
fallbackWs: ServerWebSocket<WebSocketData>,
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<WebSocketData>,
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 })

View File

@ -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.