diff --git a/desktop/src/lib/sessionTitle.test.ts b/desktop/src/lib/sessionTitle.test.ts new file mode 100644 index 00000000..6ad217aa --- /dev/null +++ b/desktop/src/lib/sessionTitle.test.ts @@ -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 = [ + 'frontend-design', + '/frontend-design', + '@website 重新设计首页', + ].join('\n') + + expect(deriveSessionTitle(raw)).toBe('/frontend-design @website 重新设计首页') + }) +}) diff --git a/desktop/src/lib/sessionTitle.ts b/desktop/src/lib/sessionTitle.ts index 5e4280cb..031f4788 100644 --- a/desktop/src/lib/sessionTitle.ts +++ b/desktop/src/lib/sessionTitle.ts @@ -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(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/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 diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 0ed43cb5..3d333bd0 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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([ + 'frontend-design', + '/frontend-design', + '@website 重新设计首页', + ].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: [ + 'frontend-design', + '/frontend-design', + '@website', + ].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) diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index cca7c937..53b2cc5f 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -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 = [ + 'frontend-design', + '/frontend-design', + '@website 重新设计首页', + ].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([ + 'frontend-design', + '/frontend-design', + '@website 重新设计首页', + ].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([ + 'frontend-design', + '/frontend-design', + '@website', + ].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') diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index e889958a..ec3b0989 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -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 } } } diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index b91a9c6a..46c38b45 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -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 { - 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 } diff --git a/src/utils/__tests__/sessionTitle.test.ts b/src/utils/__tests__/sessionTitle.test.ts new file mode 100644 index 00000000..eb0ec305 --- /dev/null +++ b/src/utils/__tests__/sessionTitle.test.ts @@ -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 = [ + 'frontend-design', + '/frontend-design', + '@website redesign the homepage', + ].join('\n') + + expect(normalizeGeneratedSessionTitle(raw)).toBe('/frontend-design @website redesign the homepage') + }) + + test('rejects empty or oversized generated titles', () => { + expect(normalizeGeneratedSessionTitle('src/app.ts')).toBeNull() + expect(normalizeGeneratedSessionTitle('x'.repeat(81))).toBeNull() + }) + + test('skips title generation when internal XML metadata leaves no title source', async () => { + const title = await generateSessionTitle( + 'src/app.ts', + AbortSignal.timeout(1000), + ) + + expect(title).toBeNull() + }) +}) diff --git a/src/utils/__tests__/sessionTitleText.test.ts b/src/utils/__tests__/sessionTitleText.test.ts new file mode 100644 index 00000000..746245ea --- /dev/null +++ b/src/utils/__tests__/sessionTitleText.test.ts @@ -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 = [ + 'frontend-design', + '/frontend-design', + '@website 重新设计首页', + ].join('\n') + + expect(cleanSessionTitleSource(raw)).toBe('/frontend-design @website 重新设计首页') + }) + + test('strips non-command internal XML wrappers from title sources', () => { + expect(cleanSessionTitleSource('secret.ts\nFix login')) + .toBe('Fix login') + }) + +}) diff --git a/src/utils/sessionTitle.ts b/src/utils/sessionTitle.ts index 5a722c88..f97cae3c 100644 --- a/src/utils/sessionTitle.ts +++ b/src/utils/sessionTitle.ts @@ -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 { - 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 }) diff --git a/src/utils/sessionTitleText.ts b/src/utils/sessionTitleText.ts new file mode 100644 index 00000000..cc47ee72 --- /dev/null +++ b/src/utils/sessionTitleText.ts @@ -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(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/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) +}