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)
+}