cc-haha/src/utils/sessionTitleText.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

42 lines
1.4 KiB
TypeScript

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