cc-haha/src/server/__tests__/title-service.test.ts
程序员阿江(Relakkes) f060d20b3b fix: keep generated session titles in user language
Async title refresh runs after assistant output, so provider prompts can be biased by assistant language and English title examples. The title language now resolves from the first meaningful user message before consulting the response-language fallback, then retries once when a generated title violates that language.

Constraint: Desktop response-language settings still control assistant prose, not automatic title metadata when the first user message has a clear language
Rejected: Follow response-language setting for every generated title | Chinese sessions flip to English when replies are configured in English
Rejected: Persist mismatched generated titles after retry failure | preserves a bad UX over the safer first-message placeholder
Confidence: high
Scope-risk: moderate
Directive: Do not change automatic title language precedence without re-testing image/text first-turn sessions and English-response-language sessions
Tested: bun test src/server/__tests__/title-service.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts -t "refreshes the first-turn AI title"
Tested: git diff --check
Tested: real MiniMax-M3 /tmp session 58a47fcd-6f9d-4685-bc73-f9317e3f330e with temporary response language english; assistant replied English and final aiTitle was 标题语言验证
Not-tested: bun run check:server blocked before tests by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
2026-06-03 11:22:29 +08:00

415 lines
14 KiB
TypeScript

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 { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js'
import { ProviderService } from '../services/providerService.js'
import {
deriveTitle,
generateTitle,
parseGeneratedTitleText,
resolveTitleLanguagePreference,
saveAiTitle,
} from '../services/titleService.js'
import { sessionService } from '../services/sessionService.js'
import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js'
describe('titleService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalFetch: typeof globalThis.fetch
beforeEach(async () => {
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalFetch = globalThis.fetch
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'title-service-test-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
globalThis.fetch = originalFetch
hahaOpenAIOAuthService.dispose()
restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('sends disabled thinking for title generation by default', async () => {
let requestBody: Record<string, unknown> | null = null
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
requestBody = await req.json() as Record<string, unknown>
return Response.json({
content: [{ type: 'text', text: '{"title":"Trace ok"}' }],
})
},
})
try {
const providerId = 'zhipu-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: 'zhipuglm',
name: 'Zhipu GLM',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'glm-5.1',
haiku: 'glm-4.5-air',
sonnet: 'glm-5-turbo',
opus: 'glm-5.1',
},
},
],
}, null, 2),
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBody?.thinking).toEqual({ type: 'disabled' })
} finally {
server.stop(true)
}
})
test('retries title generation without thinking when the provider rejects it', async () => {
const requestBodies: Array<Record<string, unknown>> = []
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const requestBody = await req.json() as Record<string, unknown>
requestBodies.push(requestBody)
if (requestBody.thinking) {
return Response.json({ error: 'thinking is not supported' }, { status: 400 })
}
return Response.json({
content: [{ type: 'text', text: '{"title":"Trace ok"}' }],
})
},
})
try {
const providerId = 'fallback-thinking-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: 'custom',
name: 'Thinking Fallback',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'fallback-main',
haiku: 'fallback-haiku',
sonnet: 'fallback-main',
opus: 'fallback-main',
},
},
],
}, null, 2),
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBodies).toHaveLength(2)
expect(requestBodies[0].thinking).toEqual({ type: 'disabled' })
expect(requestBodies[1].thinking).toBeUndefined()
} finally {
server.stop(true)
}
})
test('sends disabled thinking for DeepSeek title generation', async () => {
let requestBody: Record<string, unknown> | null = null
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
requestBody = await req.json() as Record<string, unknown>
return Response.json({
content: [{ type: 'text', text: '{"title":"Trace ok"}' }],
})
},
})
try {
const providerId = 'deepseek-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: 'deepseek',
name: 'DeepSeek',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'deepseek-v4-pro',
haiku: 'deepseek-v4-pro',
sonnet: 'deepseek-v4-pro',
opus: 'deepseek-v4-pro',
},
},
],
}, null, 2),
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(requestBody?.thinking).toEqual({ type: 'disabled' })
} finally {
server.stop(true)
}
})
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')
const titlePrompt = String(requestBody?.messages?.[0]?.content ?? '')
expect(titlePrompt).toContain('/frontend-design @website 重新设计首页')
expect(titlePrompt).toContain('<conversation>')
expect(titlePrompt).not.toContain('<command-message>')
} finally {
server.stop(true)
}
})
test('keeps generated titles in the first user message language', async () => {
const requestBodies: Array<{
messages?: Array<{ content?: string }>
}> = []
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const requestBody = await req.json() as {
messages?: Array<{ content?: string }>
}
requestBodies.push(requestBody)
const title = requestBodies.length === 1
? 'Summarize recent changes'
: '总结最近变更'
return Response.json({
content: [{ type: 'text', text: JSON.stringify({ title }) }],
})
},
})
try {
const providerId = 'title-language-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: 'minimax',
name: 'MiniMax',
apiKey: 'test-key',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'minimax-main',
haiku: 'minimax-haiku',
sonnet: 'minimax-main',
opus: 'minimax-main',
},
},
],
}, null, 2),
)
const languagePreference = resolveTitleLanguagePreference(
'最近我们在最近 15 天做了很多的变更,你去看一下',
'english',
)
await expect(generateTitle(
[
'最近我们在最近 15 天做了很多的变更,你去看一下',
'The assistant answered in English and summarized desktop changes.',
].join('\n'),
undefined,
languagePreference,
)).resolves.toBe('总结最近变更')
expect(languagePreference).toEqual({
language: 'Chinese',
source: 'first-user-message',
})
expect(requestBodies).toHaveLength(2)
const firstPrompt = String(requestBodies[0]?.messages?.[0]?.content ?? '')
const retryPrompt = String(requestBodies[1]?.messages?.[0]?.content ?? '')
expect(firstPrompt).toContain('Return the title in Chinese.')
expect(retryPrompt).toContain('The title must be in Chinese.')
expect(firstPrompt).not.toContain('Return the title in English.')
} finally {
server.stop(true)
}
})
test('falls back to response language when the first user message language is ambiguous', () => {
expect(resolveTitleLanguagePreference('/tmp/2026-06-03', 'english')).toEqual({
language: 'English',
source: 'response-language',
})
})
test('generates titles when ChatGPT Official OAuth is active', async () => {
const providerService = new ProviderService()
await providerService.activateProvider('openai-official')
await hahaOpenAIOAuthService.saveTokens({
accessToken: 'access-for-title',
refreshToken: 'refresh-for-title',
expiresAt: Date.now() + 60 * 60_000,
accountId: 'acct_title',
email: 'title@example.com',
})
const upstreamCalls: Array<{
url: string
headers: Record<string, string>
body: Record<string, unknown>
}> = []
globalThis.fetch = (async (input, init) => {
const headers = new Headers(init?.headers)
upstreamCalls.push({
url: String(input),
headers: Object.fromEntries(headers.entries()),
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
})
return new Response([
'event: response.completed',
'data: {"response":{"id":"resp_title","object":"response","created_at":1779118000,"model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"{\\"title\\":\\"Trace ok\\"}"}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}',
'',
].join('\n'), {
headers: { 'Content-Type': 'text/event-stream' },
})
}) as typeof fetch
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(upstreamCalls).toHaveLength(1)
expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT)
expect(upstreamCalls[0].headers.authorization).toBe('Bearer access-for-title')
expect(upstreamCalls[0].headers['chatgpt-account-id']).toBe('acct_title')
expect(upstreamCalls[0].body.stream).toBe(true)
})
test('parses JSON title responses wrapped in markdown fences', () => {
expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```'))
.toBe('Write bash script')
})
test('parses escaped JSON title responses', () => {
expect(parseGeneratedTitleText('```json\n{\\"title\\":\\"Write bash script\\"}\n```'))
.toBe('Write bash script')
})
test('rejects incomplete JSON title fragments instead of using them as titles', () => {
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')
await expect(saveAiTitle(sessionId, 'Automatic topic')).resolves.toBe(false)
const detail = await sessionService.getSession(sessionId)
expect(detail?.title).toBe('My fixed name')
const found = await sessionService.findSessionFile(sessionId)
expect(found).not.toBeNull()
const content = await fs.readFile(found!.filePath, 'utf-8')
expect(content).not.toContain('"type":"ai-title"')
})
})
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}