cc-haha/desktop/src/lib/sessionTitle.ts
程序员阿江(Relakkes) 179f79b794 fix: sanitize generated session titles
Slash-command and skill prompts can enter the title-generation path as internal XML breadcrumbs. The async title request previously treated that transport metadata as user prose, so a generated ai-title could persist raw command tags and override the normal session title.

This routes title sources and generated title output through a shared sanitizer, preserving user-visible command names and arguments while dropping unrelated internal XML metadata. Desktop fallback title rendering now applies the same cleanup so existing transcripts with bad ai-title entries recover on read.

Constraint: Session titles can be produced by both desktop server logic and the SDK generate_session_title control request.
Rejected: Only clean desktop session display | leaves CLI and remote title generation able to persist bad titles again
Confidence: high
Scope-risk: moderate
Directive: Keep generated-title input and persisted-title readback on the shared sanitizer; do not add a title path that reads command XML directly.
Tested: bun test src/utils/__tests__/sessionTitle.test.ts src/utils/__tests__/sessionTitleText.test.ts
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
2026-05-09 22:34:27 +08:00

49 lines
1.5 KiB
TypeScript

const TITLE_MAX_LEN = 50
const PLACEHOLDER_TITLES = new Set([
'',
'New Session',
'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 = cleanSessionTitleSource(raw)
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())
}