Merge session title sanitation into main

Bring the detached worktree fix onto local main after verifying the title-generation regression path and local quality checks.

Constraint: The worktree commit was based on an older main commit, while local main already contained later chat scroll fixes.
Confidence: high
Scope-risk: moderate
Directive: Keep title sanitation shared across CLI, server, and desktop read paths.
Tested: bun run check:server
Tested: bun run check:desktop
Tested: bun run verify (7 passed, 1 failed on existing aggregate agent-utils coverage baseline)
Not-tested: live model title-generation smoke
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 22:34:54 +08:00
commit da477c2b4b
10 changed files with 269 additions and 10 deletions

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { deriveSessionTitle } from './sessionTitle'
describe('deriveSessionTitle', () => {
it('uses slash command metadata without showing raw XML tags', () => {
const raw = [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website 重新设计首页</command-args>',
].join('\n')
expect(deriveSessionTitle(raw)).toBe('/frontend-design @website 重新设计首页')
})
})

View File

@ -6,8 +6,35 @@ const PLACEHOLDER_TITLES = new Set([
'Untitled Session',
])
const XML_TAG_BLOCK_PATTERN = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\n?/g
function decodeXmlText(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
function extractXmlTag(text: string, tag: string): string | undefined {
const match = text.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'))
const value = match?.[1]?.trim()
return value ? decodeXmlText(value) : undefined
}
function cleanSessionTitleSource(raw: string): string {
const commandName = extractXmlTag(raw, 'command-name')
const commandArgs = extractXmlTag(raw, 'command-args')
if (commandName) {
return [commandName, commandArgs].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
}
return raw.replace(XML_TAG_BLOCK_PATTERN, ' ').replace(/\s+/g, ' ').trim()
}
export function deriveSessionTitle(raw: string): string | null {
const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim()
const clean = cleanSessionTitleSource(raw)
const firstSentence = /^(.*?[.!?\u3002\uff01\uff1f])\s/.exec(clean)?.[1] ?? clean
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return null

View File

@ -1304,6 +1304,41 @@ describe('SessionService', () => {
expect(detail!.title).toBe('This is my first real question')
})
it('should derive a clean title from slash command breadcrumb metadata', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
makeUserEntry([
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website 重新设计首页</command-args>',
].join('\n')),
])
const detail = await service.getSession(sessionId)
expect(detail!.title).toBe('/frontend-design @website 重新设计首页')
})
it('should display stored AI titles without internal XML tags', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
makeUserEntry('fallback message'),
{
type: 'ai-title',
aiTitle: [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website</command-args>',
].join(' '),
timestamp: '2026-01-01T00:02:00.000Z',
},
])
const detail = await service.getSession(sessionId)
expect(detail!.title).toBe('/frontend-design @website')
})
it('should truncate long titles to 80 chars', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const longMessage = 'A'.repeat(120)

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, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js'
import { deriveTitle, generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js'
import { sessionService } from '../services/sessionService.js'
describe('titleService', () => {
@ -70,6 +70,71 @@ describe('titleService', () => {
}
})
test('derives slash-command titles from command metadata without raw XML tags', () => {
const raw = [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website 重新设计首页</command-args>',
].join('\n')
expect(deriveTitle(raw)).toBe('/frontend-design @website 重新设计首页')
})
test('sends cleaned slash-command text to the title model', async () => {
let requestBody: {
messages?: Array<{ content?: string }>
} | null = null
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
requestBody = await req.json() as {
messages?: Array<{ content?: string }>
}
return Response.json({
content: [{ type: 'text', text: '{"title":"Redesign website"}' }],
})
},
})
try {
const providerId = 'title-clean-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: 'anthropic',
name: 'Anthropic',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-7',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-7',
opus: 'claude-opus-4-7',
},
},
],
}, null, 2),
)
await expect(generateTitle([
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website 重新设计首页</command-args>',
].join('\n'))).resolves.toBe('Redesign website')
expect(requestBody?.messages?.[0]?.content).toBe('/frontend-design @website 重新设计首页')
} finally {
server.stop(true)
}
})
test('parses JSON title responses wrapped in markdown fences', () => {
expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```'))
.toBe('Write bash script')
@ -84,6 +149,14 @@ describe('titleService', () => {
expect(parseGeneratedTitleText('```json\n{\\"title\\":')).toBeNull()
})
test('normalizes XML-like title model output before persisting it', () => {
expect(parseGeneratedTitleText([
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website</command-args>',
].join(' '))).toBe('/frontend-design @website')
})
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

@ -24,6 +24,7 @@ import {
type CreateSessionRepositoryOptions,
type PreparedSessionWorkspace,
} from './repositoryLaunchService.js'
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
// ============================================================================
// Types
@ -734,7 +735,8 @@ export class SessionService {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!
if (e.type === 'ai-title' && e.aiTitle) {
return e.aiTitle as string
const title = cleanSessionTitleSource(String(e.aiTitle))
if (title) return title
}
}
@ -752,7 +754,8 @@ export class SessionService {
if (textBlock) text = textBlock.text as string
}
if (text) {
return text.length > 80 ? text.slice(0, 80) + '...' : text
const title = cleanSessionTitleSource(text)
if (title) return title.length > 80 ? title.slice(0, 80) + '...' : title
}
}
}

View File

@ -11,6 +11,7 @@ import { SettingsService } from './settingsService.js'
import { sessionService } from './sessionService.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { cleanSessionTitleSource, hasSessionTitleMarkup } from '../../utils/sessionTitleText.js'
const TITLE_MAX_LEN = 50
@ -33,7 +34,7 @@ Bad (wrong case): {"title": "Fix Login Button On Mobile"}`
* Returns first sentence, collapsed to single line, max 50 chars.
*/
export function deriveTitle(raw: string): string | undefined {
const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim()
const clean = cleanSessionTitleSource(raw)
const firstSentence = /^(.*?[.!?。!?])\s/.exec(clean)?.[1] ?? clean
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return undefined
@ -50,7 +51,7 @@ export async function generateTitle(
conversationText: string,
providerId?: string | null,
): Promise<string | null> {
const trimmed = conversationText.trim()
const trimmed = cleanSessionTitleSource(conversationText)
if (!trimmed) return null
try {
@ -157,8 +158,13 @@ function parseTitleJson(candidate: string): string | null {
}
function normalizeTitle(title: string): string | null {
const clean = title.replace(/\s+/g, ' ').trim()
if (!clean || clean.length > 60 || looksLikeStructuredTitleFragment(clean)) return null
const clean = cleanSessionTitleSource(title)
if (
!clean ||
clean.length > 60 ||
looksLikeStructuredTitleFragment(clean) ||
hasSessionTitleMarkup(clean)
) return null
return clean
}

View File

@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test'
import { generateSessionTitle, normalizeGeneratedSessionTitle } from '../sessionTitle.js'
describe('sessionTitle', () => {
test('normalizes command XML emitted by title generation', () => {
const raw = [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website redesign the homepage</command-args>',
].join('\n')
expect(normalizeGeneratedSessionTitle(raw)).toBe('/frontend-design @website redesign the homepage')
})
test('rejects empty or oversized generated titles', () => {
expect(normalizeGeneratedSessionTitle('<ide_opened_file>src/app.ts</ide_opened_file>')).toBeNull()
expect(normalizeGeneratedSessionTitle('x'.repeat(81))).toBeNull()
})
test('skips title generation when internal XML metadata leaves no title source', async () => {
const title = await generateSessionTitle(
'<ide_opened_file>src/app.ts</ide_opened_file>',
AbortSignal.timeout(1000),
)
expect(title).toBeNull()
})
})

View File

@ -0,0 +1,20 @@
import { describe, expect, test } from 'bun:test'
import { cleanSessionTitleSource } from '../sessionTitleText.js'
describe('sessionTitleText', () => {
test('converts slash command XML metadata into a user-facing title source', () => {
const raw = [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>@website 重新设计首页</command-args>',
].join('\n')
expect(cleanSessionTitleSource(raw)).toBe('/frontend-design @website 重新设计首页')
})
test('strips non-command internal XML wrappers from title sources', () => {
expect(cleanSessionTitleSource('<ide_opened_file>secret.ts</ide_opened_file>\nFix login'))
.toBe('Fix login')
})
})

View File

@ -21,9 +21,11 @@ import { logForDebugging } from './debug.js'
import { safeParseJSON } from './json.js'
import { lazySchema } from './lazySchema.js'
import { extractTextContent } from './messages.js'
import { cleanSessionTitleSource, hasSessionTitleMarkup } from './sessionTitleText.js'
import { asSystemPrompt } from './systemPromptType.js'
const MAX_CONVERSATION_TEXT = 1000
const MAX_GENERATED_TITLE_LENGTH = 80
/**
* Flatten a message array into a single text string for Haiku title input.
@ -69,6 +71,14 @@ Bad (wrong case): {"title": "Fix Login Button On Mobile"}`
const titleSchema = lazySchema(() => z.object({ title: z.string() }))
export function normalizeGeneratedSessionTitle(title: string): string | null {
const cleaned = cleanSessionTitleSource(title)
if (!cleaned) return null
if (cleaned.length > MAX_GENERATED_TITLE_LENGTH) return null
if (hasSessionTitleMarkup(cleaned)) return null
return cleaned
}
/**
* Generate a sentence-case session title from a description or first message.
* Returns null on error or if Haiku returns an unparseable response.
@ -80,7 +90,7 @@ export async function generateSessionTitle(
description: string,
signal: AbortSignal,
): Promise<string | null> {
const trimmed = description.trim()
const trimmed = cleanSessionTitleSource(description)
if (!trimmed) return null
try {
@ -114,7 +124,9 @@ export async function generateSessionTitle(
const text = extractTextContent(result.message.content)
const parsed = titleSchema().safeParse(safeParseJSON(text))
const title = parsed.success ? parsed.data.title.trim() || null : null
const title = parsed.success
? normalizeGeneratedSessionTitle(parsed.data.title)
: null
logEvent('tengu_session_title_generated', { success: title !== null })

View File

@ -0,0 +1,41 @@
const XML_TAG_BLOCK_PATTERN = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\n?/g
function decodeXmlText(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
function extractXmlTag(text: string, tag: string): string | undefined {
const match = text.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'))
const value = match?.[1]?.trim()
return value ? decodeXmlText(value) : undefined
}
function normalizeTitleWhitespace(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/**
* Convert system-injected XML wrappers into user-facing text before using a
* message as a session title source. Slash commands are stored in transcripts
* as command-name/command-args breadcrumbs; titles should show the command the
* user typed, not the internal XML transport.
*/
export function cleanSessionTitleSource(raw: string): string {
const commandName = extractXmlTag(raw, 'command-name')
const commandArgs = extractXmlTag(raw, 'command-args')
if (commandName) {
return normalizeTitleWhitespace([commandName, commandArgs].filter(Boolean).join(' '))
}
const stripped = raw.replace(XML_TAG_BLOCK_PATTERN, ' ')
return normalizeTitleWhitespace(stripped)
}
export function hasSessionTitleMarkup(raw: string): boolean {
return /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/.test(raw)
}