fix: prevent malformed generated session titles

Session titles need to appear immediately, but the async AI title pass can return fenced or escaped JSON fragments. Keep the first-message title path fast, preserve optimistic client titles during background refresh, and only accept structured title responses that parse cleanly.

Constraint: Desktop sessions should stop showing Untitled/New Session after the first user turn.
Rejected: Accept short raw model output as a fallback | malformed JSON fragments are short enough to leak into the UI.
Confidence: high
Scope-risk: moderate
Directive: Do not loosen generated title parsing without regression coverage for fenced, escaped, and truncated JSON responses.
Tested: bun test src/server/__tests__/title-service.test.ts
Tested: bun run check:server
Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/stores/sessionStore.test.ts src/pages/EmptySession.test.tsx
Tested: agent-browser smoke on http://127.0.0.1:1420/ confirmed sidebar and tab titles update without JSON fragments.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-07 16:28:08 +08:00
parent a340e41046
commit 7fbce35be6
10 changed files with 307 additions and 41 deletions

View File

@ -0,0 +1,21 @@
const TITLE_MAX_LEN = 50
const PLACEHOLDER_TITLES = new Set([
'',
'New Session',
'Untitled Session',
])
export function deriveSessionTitle(raw: string): string | null {
const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim()
const firstSentence = /^(.*?[.!?\u3002\uff01\uff1f])\s/.exec(clean)?.[1] ?? clean
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return null
return flat.length > TITLE_MAX_LEN
? `${flat.slice(0, TITLE_MAX_LEN - 1)}\u2026`
: flat
}
export function isPlaceholderSessionTitle(title: string | null | undefined): boolean {
return PLACEHOLDER_TITLES.has((title ?? '').trim())
}

View File

@ -180,7 +180,7 @@ describe('EmptySession', () => {
expect(useTabStore.getState().activeTabId).toBe('draft-session')
expect(useTabStore.getState().tabs).toEqual([
{ sessionId: 'draft-session', title: 'New Session', type: 'session', status: 'idle' },
{ sessionId: 'draft-session', title: 'draft question', type: 'session', status: 'idle' },
])
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'draft-session',

View File

@ -16,6 +16,10 @@ const {
resetCompletedTasksMock,
refreshTasksMock,
notifyDesktopMock,
updateTabTitleMock,
updateTabStatusMock,
updateSessionTitleMock,
sessionStoreSnapshot,
cliTaskStoreSnapshot,
} = vi.hoisted(() => ({
sendMock: vi.fn(),
@ -31,6 +35,21 @@ const {
resetCompletedTasksMock: vi.fn(async () => {}),
refreshTasksMock: vi.fn(),
notifyDesktopMock: vi.fn(),
updateTabTitleMock: vi.fn(),
updateTabStatusMock: vi.fn(),
updateSessionTitleMock: vi.fn(),
sessionStoreSnapshot: {
sessions: [] as Array<{
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}>,
},
cliTaskStoreSnapshot: {
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
sessionId: null as string | null,
@ -73,8 +92,8 @@ vi.mock('./teamStore', () => ({
vi.mock('./tabStore', () => ({
useTabStore: {
getState: () => ({
updateTabStatus: vi.fn(),
updateTabTitle: vi.fn(),
updateTabStatus: updateTabStatusMock,
updateTabTitle: updateTabTitleMock,
}),
},
}))
@ -82,7 +101,8 @@ vi.mock('./tabStore', () => ({
vi.mock('./sessionStore', () => ({
useSessionStore: {
getState: () => ({
updateSessionTitle: vi.fn(),
sessions: sessionStoreSnapshot.sessions,
updateSessionTitle: updateSessionTitleMock,
}),
},
}))
@ -120,6 +140,10 @@ describe('chatStore history mapping', () => {
resetCompletedTasksMock.mockReset()
refreshTasksMock.mockReset()
notifyDesktopMock.mockReset()
updateTabTitleMock.mockReset()
updateTabStatusMock.mockReset()
updateSessionTitleMock.mockReset()
sessionStoreSnapshot.sessions = []
cliTaskStoreSnapshot.tasks = []
cliTaskStoreSnapshot.sessionId = null
useSessionRuntimeStore.setState({ selections: {} })
@ -1407,4 +1431,27 @@ describe('chatStore history mapping', () => {
expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
})
it('optimistically titles a new placeholder session from the first user message', () => {
sessionStoreSnapshot.sessions = [{
id: TEST_SESSION_ID,
title: 'New Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 0,
projectPath: '',
workDir: '/workspace/project',
workDirExists: true,
}]
useChatStore.getState().sendMessage(TEST_SESSION_ID, '开始优化UI')
expect(updateSessionTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
expect(updateTabTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'user_message',
content: '开始优化UI',
attachments: undefined,
})
})
})

View File

@ -8,6 +8,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import { notifyDesktop } from '../lib/desktopNotifications'
import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
@ -325,6 +326,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
void taskStore.resetCompletedTasks()
}
if (!isMemberSession) {
updateOptimisticSessionTitle(sessionId, userFacingContent)
}
set((s) => {
const session = s.sessions[sessionId] ?? createDefaultSessionState()
if (flushTimer) {
@ -924,6 +929,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}))
function updateOptimisticSessionTitle(sessionId: string, content: string): void {
const title = deriveSessionTitle(content)
if (!title) return
const session = useSessionStore.getState().sessions.find((item) => item.id === sessionId)
if (!session || session.messageCount > 0 || !isPlaceholderSessionTitle(session.title)) return
useSessionStore.getState().updateSessionTitle(sessionId, title)
useTabStore.getState().updateTabTitle(sessionId, title)
}
// ─── History mapping helpers (unchanged from original) ─────────
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }

View File

@ -22,6 +22,14 @@ function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('sessionStore', () => {
beforeEach(() => {
createMock.mockReset()
@ -64,6 +72,45 @@ describe('sessionStore', () => {
expect(listMock).toHaveBeenCalledOnce()
})
it('keeps an optimistic local title when a background refresh still returns a placeholder', async () => {
const refresh = createDeferred<{
sessions: Array<{
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}>
total: number
}>()
createMock.mockResolvedValue({ sessionId: 'session-title-1', workDir: '/workspace/project' })
listMock.mockReturnValue(refresh.promise)
await useSessionStore.getState().createSession('/workspace/project')
useSessionStore.getState().updateSessionTitle('session-title-1', '开始优化UI')
refresh.resolve({
sessions: [{
id: 'session-title-1',
title: 'Untitled Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:01.000Z',
messageCount: 0,
projectPath: '',
workDir: '/workspace/project',
workDirExists: true,
}],
total: 1,
})
await refresh.promise
await delay(0)
expect(useSessionStore.getState().sessions[0]?.title).toBe('开始优化UI')
})
it('forwards direct branch switch repository options when creating a session', async () => {
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
listMock.mockResolvedValue({ sessions: [], total: 0 })

View File

@ -2,6 +2,7 @@ import { create } from 'zustand'
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import type { SessionListItem } from '../types/session'
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
type CreateSessionOptions = {
repository?: CreateSessionRepositoryOptions
@ -36,17 +37,22 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
set({ isLoading: true, error: null })
try {
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
// Deduplicate by session ID — keep the most recently modified entry
const byId = new Map<string, SessionListItem>()
for (const s of raw) {
const existing = byId.get(s.id)
if (!existing || new Date(s.modifiedAt) > new Date(existing.modifiedAt)) {
byId.set(s.id, s)
set((state) => {
const currentById = new Map(state.sessions.map((session) => [session.id, session]))
// Deduplicate by session ID - keep the most recently modified entry.
const byId = new Map<string, SessionListItem>()
for (const s of raw) {
const current = currentById.get(s.id)
const candidate = preserveLocalTitle(current, s)
const existing = byId.get(s.id)
if (!existing || new Date(candidate.modifiedAt) > new Date(existing.modifiedAt)) {
byId.set(s.id, candidate)
}
}
}
const sessions = [...byId.values()]
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
set({ sessions, availableProjects, isLoading: false })
const sessions = [...byId.values()]
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
return { sessions, availableProjects, isLoading: false }
})
} catch (err) {
set({ error: (err as Error).message, isLoading: false })
}
@ -109,3 +115,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
setActiveSession: (id) => set({ activeSessionId: id }),
setSelectedProjects: (projects) => set({ selectedProjects: projects }),
}))
function preserveLocalTitle(
current: SessionListItem | undefined,
incoming: SessionListItem,
): SessionListItem {
if (!current) return incoming
if (isPlaceholderSessionTitle(incoming.title) && !isPlaceholderSessionTitle(current.title)) {
return { ...incoming, title: current.title }
}
return incoming
}

View File

@ -817,6 +817,47 @@ describe('WebSocket Chat Integration', () => {
expect(statusMsgs[0].state).toBe('thinking')
})
it('emits the derived session title before the first response completes', async () => {
const sessionId = `title-fast-${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 derived session title'))
}, 5000)
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: '开始优化UI',
}))
return
}
if (msg.type === 'message_complete') {
clearTimeout(timeout)
ws.close()
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error(`WebSocket error for title session ${sessionId}`))
}
})
const titleIndex = messages.findIndex((msg) => msg.type === 'session_title_updated')
const completionIndex = messages.findIndex((msg) => msg.type === 'message_complete')
expect(titleIndex).toBeGreaterThan(-1)
expect(completionIndex).toBeGreaterThan(-1)
expect(messages[titleIndex].title).toBe('开始优化UI')
expect(titleIndex).toBeLessThan(completionIndex)
})
it('should start desktop sessions with disabled thinking when configured', async () => {
const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}`
const originalStartSession = conversationService.startSession.bind(conversationService)

View File

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { generateTitle, saveAiTitle } from '../services/titleService.js'
import { generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js'
import { sessionService } from '../services/sessionService.js'
describe('titleService', () => {
@ -70,6 +70,20 @@ describe('titleService', () => {
}
})
test('parses JSON title responses wrapped in markdown fences', () => {
expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```'))
.toBe('Write bash script')
})
test('parses escaped JSON title responses', () => {
expect(parseGeneratedTitleText('```json\n{\\"title\\":\\"Write bash script\\"}\n```'))
.toBe('Write bash script')
})
test('rejects incomplete JSON title fragments instead of using them as titles', () => {
expect(parseGeneratedTitleText('```json\n{\\"title\\":')).toBeNull()
})
test('does not persist automatic titles over a user custom title', async () => {
const { sessionId } = await sessionService.createSession(os.tmpdir())
await sessionService.renameSession(sessionId, 'My fixed name')

View File

@ -99,20 +99,78 @@ export async function generateTitle(
const text = body.content?.find((b) => b.type === 'text')?.text
if (!text) return null
// Parse JSON response
const match = text.match(/\{[^}]*"title"\s*:\s*"([^"]+)"[^}]*\}/)
if (match?.[1]) return match[1].trim()
// Fallback: if model returned plain text instead of JSON
const plain = text.trim()
if (plain.length > 0 && plain.length <= 60) return plain
return null
return parseGeneratedTitleText(text)
} catch {
return null
}
}
export function parseGeneratedTitleText(text: string): string | null {
const trimmed = text.trim()
if (!trimmed) return null
const parsed = parseTitleFromStructuredText(trimmed)
if (parsed) return normalizeTitle(parsed)
if (looksLikeStructuredTitleFragment(trimmed)) return null
return normalizeTitle(trimmed)
}
function parseTitleFromStructuredText(text: string): string | null {
const candidates = new Set<string>([text])
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim()
if (fenced) candidates.add(fenced)
const firstBrace = text.indexOf('{')
const lastBrace = text.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
candidates.add(text.slice(firstBrace, lastBrace + 1))
}
for (const candidate of [...candidates]) {
const unescaped = candidate.replace(/\\"/g, '"').replace(/\\n/g, '\n')
if (unescaped !== candidate) candidates.add(unescaped)
}
for (const candidate of candidates) {
const title = parseTitleJson(candidate)
if (title) return title
}
return null
}
function parseTitleJson(candidate: string): string | null {
try {
const parsed = JSON.parse(candidate)
if (typeof parsed === 'string') {
return parseTitleFromStructuredText(parsed)
}
if (parsed && typeof parsed === 'object' && typeof (parsed as { title?: unknown }).title === 'string') {
return (parsed as { title: string }).title
}
} catch {
return null
}
return null
}
function normalizeTitle(title: string): string | null {
const clean = title.replace(/\s+/g, ' ').trim()
if (!clean || clean.length > 60 || looksLikeStructuredTitleFragment(clean)) return null
return clean
}
function looksLikeStructuredTitleFragment(text: string): boolean {
return (
text.includes('```') ||
text.includes('{') ||
text.includes('}') ||
/\\?"title\\?"\s*:/.test(text)
)
}
async function shouldDisableThinkingForTitle(presetId: string): Promise<boolean> {
const settings = await new SettingsService().getUserSettings()
if (settings.alwaysThinkingEnabled !== false) return false

View File

@ -53,6 +53,7 @@ const sessionTitleState = new Map<string, {
hasCustomTitle: boolean
firstUserMessage: string
allUserMessages: string[]
startedGenerationCounts: Set<number>
}>()
const runtimeOverrides = new Map<string, {
@ -271,6 +272,25 @@ async function handleUserMessage(
}
}
// Track and emit the first placeholder title before CLI startup/streaming.
let titleState = sessionTitleState.get(sessionId)
if (!titleState) {
titleState = {
userMessageCount: 0,
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
firstUserMessage: '',
allUserMessages: [],
startedGenerationCounts: new Set<number>(),
}
sessionTitleState.set(sessionId, titleState)
}
titleState.userMessageCount++
titleState.allUserMessages.push(message.content)
if (titleState.userMessageCount === 1) {
titleState.firstUserMessage = message.content
}
triggerTitleGeneration(ws, sessionId)
// 启动 CLI 子进程(如果还没有)
try {
await ensureCliSessionStarted(ws, sessionId, 'user_message')
@ -290,23 +310,6 @@ async function handleUserMessage(
return
}
// Track user message for title generation
let titleState = sessionTitleState.get(sessionId)
if (!titleState) {
titleState = {
userMessageCount: 0,
hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)),
firstUserMessage: '',
allUserMessages: [],
}
sessionTitleState.set(sessionId, titleState)
}
titleState.userMessageCount++
titleState.allUserMessages.push(message.content)
if (titleState.userMessageCount === 1) {
titleState.firstUserMessage = message.content
}
// Register the callback before sending the turn so startup errors are not lost.
// Keep output muted until the current user turn is enqueued to avoid forwarding
// any pre-turn SDK chatter as fresh chat history.
@ -627,6 +630,8 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: s
// 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)
const text = count === 1
? state.firstUserMessage