cc-haha/src/server/__tests__/title-service.test.ts
2026-07-16 17:33:33 +08:00

485 lines
16 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'
import { SYSTEM_PROXY_URL_ENV } from '../services/networkSettings.js'
describe('titleService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalSystemProxyUrl: string | undefined
let originalFetch: typeof globalThis.fetch
beforeEach(async () => {
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalSystemProxyUrl = process.env[SYSTEM_PROXY_URL_ENV]
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)
restoreEnv(SYSTEM_PROXY_URL_ENV, originalSystemProxyUrl)
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 systemProxyUrl = 'http://127.0.0.1:17890'
process.env[SYSTEM_PROXY_URL_ENV] = systemProxyUrl
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
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>
proxy?: string
}> = []
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>,
proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy,
})
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)
expect(upstreamCalls[0].proxy).toBe(systemProxyUrl)
})
test('honors the configured provider auth strategy for title generation', async () => {
const upstreamCalls: Array<{ headers: Record<string, string> }> = []
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
upstreamCalls.push({
headers: Object.fromEntries(req.headers.entries()),
})
return Response.json({
content: [{ type: 'text', text: '{"title":"Trace ok"}' }],
})
},
})
try {
const providerId = 'auth-token-title-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: 'Bearer Provider',
apiKey: 'bearer-title-key',
authStrategy: 'auth_token',
baseUrl: `http://127.0.0.1:${server.port}/anthropic`,
apiFormat: 'anthropic',
models: {
main: 'bearer-main',
haiku: 'bearer-haiku',
sonnet: 'bearer-main',
opus: 'bearer-main',
},
},
],
}, null, 2),
)
await expect(generateTitle('请只回复 trace-ok')).resolves.toBe('Trace ok')
expect(upstreamCalls).toHaveLength(1)
expect(upstreamCalls[0].headers.authorization).toBe('Bearer bearer-title-key')
expect(upstreamCalls[0].headers['x-api-key']).toBeUndefined()
} 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')
})
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
}
}