mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
commit
da477c2b4b
14
desktop/src/lib/sessionTitle.test.ts
Normal file
14
desktop/src/lib/sessionTitle.test.ts
Normal 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 重新设计首页')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
28
src/utils/__tests__/sessionTitle.test.ts
Normal file
28
src/utils/__tests__/sessionTitle.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
20
src/utils/__tests__/sessionTitleText.test.ts
Normal file
20
src/utils/__tests__/sessionTitleText.test.ts
Normal 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')
|
||||
})
|
||||
|
||||
})
|
||||
@ -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 })
|
||||
|
||||
|
||||
41
src/utils/sessionTitleText.ts
Normal file
41
src/utils/sessionTitleText.ts
Normal 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(/</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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user