cc-haha/src/server/__tests__/conversation-service.test.ts
程序员阿江(Relakkes) b40ffb0714 feat: align desktop repository sessions with CLI worktrees
Desktop repository launches now defer isolated worktree creation until the
first user turn so the CLI owns worktree setup, cwd initialization, and
session metadata. The chat UI surfaces the pre-startup Git phase so users see
when a session is creating a worktree or switching a branch before model
output begins.

Constraint: Desktop must preserve the selected source checkout until a user actually sends a message
Constraint: CLI setup is the canonical owner for worktree creation and cwd initialization
Rejected: Create the worktree eagerly in the desktop session picker | it diverges from CLI session startup and creates worktrees before a conversation exists
Confidence: high
Scope-risk: moderate
Directive: Keep repository session startup routed through CLI worktree flags; do not reintroduce eager desktop worktree creation without testing transcript cwd and cleanup behavior
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/conversation-service.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts
Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "worktree startup status"
Tested: cd desktop && bun run test -- src/pages/EmptySession.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: git diff --check
Tested: CLI init-only native worktree smoke from feature/rail
Tested: agent-browser UI flow with MiniMax-M2.7-highspeed in a /tmp repository
2026-05-08 16:08:58 +08:00

375 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { ConversationService } from '../services/conversationService.js'
import { ProviderService } from '../services/providerService.js'
describe('ConversationService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalApiKey: string | undefined
let originalAuthToken: string | undefined
let originalBaseUrl: string | undefined
let originalModel: string | undefined
let originalEntrypoint: string | undefined
let originalOAuthToken: string | undefined
let originalProviderManagedByHost: string | undefined
let originalDiagnosticsFile: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalApiKey = process.env.ANTHROPIC_API_KEY
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
originalModel = process.env.ANTHROPIC_MODEL
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.ANTHROPIC_API_KEY = 'stale-parent-api-key'
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
process.env.ANTHROPIC_MODEL = 'test-model'
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'inherited-parent-oauth-token'
// Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether
// buildChildEnv injects it or not without interference from the shell env.
delete process.env.CLAUDE_CODE_ENTRYPOINT
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
else process.env.ANTHROPIC_API_KEY = originalApiKey
if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN
else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken
if (originalBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL
else process.env.ANTHROPIC_BASE_URL = originalBaseUrl
if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL
else process.env.ANTHROPIC_MODEL = originalModel
if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT
else process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint
if (originalOAuthToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken
if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost
if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('keeps inherited provider env when no desktop provider config exists', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
expect(env.ANTHROPIC_MODEL).toBe('test-model')
expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBe(path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'))
await expect(fs.stat(path.dirname(env.CLAUDE_CODE_DIAGNOSTICS_FILE))).resolves.toBeTruthy()
})
test('strips inherited provider env when desktop provider config exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'providers.json'),
JSON.stringify({ activeId: null, providers: [] }),
'utf-8',
)
const service = new ConversationService() as any
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBeUndefined()
})
test('buildChildEnv injects CLAUDE_CODE_OAUTH_TOKEN when official mode + haha oauth token exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: {} }),
'utf-8',
)
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'haha-fresh-token',
refreshToken: 'haha-refresh-xxx',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('haha-fresh-token')
})
test('buildChildEnv does NOT inject CLAUDE_CODE_OAUTH_TOKEN when not official mode', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }),
'utf-8',
)
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'haha-token-should-not-be-used',
refreshToken: null,
expiresAt: null,
scopes: [],
subscriptionType: null,
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
})
test('buildChildEnv injects explicit provider runtime env for session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Packy',
apiKey: 'provider-key',
baseUrl: 'https://api.packy.example',
apiFormat: 'openai_chat',
models: {
main: 'kimi-k2.6',
haiku: '',
sonnet: '',
opus: '',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_MODEL).toBe('kimi-k2.6')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
})
test('buildChildEnv uses the session-selected model for session-scoped providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'custom',
name: 'Switchable',
apiKey: 'provider-key',
baseUrl: 'https://api.switchable.example',
apiFormat: 'openai_chat',
models: {
main: 'old-provider-main',
haiku: 'new-provider-haiku',
sonnet: 'new-provider-sonnet',
opus: 'new-provider-opus',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
model: 'new-provider-sonnet',
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
expect(env.ANTHROPIC_MODEL).toBe('new-provider-sonnet')
})
test('buildChildEnv clears stale api key for bearer-token providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'jiekouai',
name: 'Jiekou',
apiKey: 'provider-key',
baseUrl: 'https://api.jiekou.ai/anthropic',
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: provider.id,
model: 'claude-sonnet-4-6',
})) as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('provider-key')
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none')
})
test('buildChildEnv can force official auth even when a custom default provider exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }),
'utf-8',
)
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
await hahaOAuthService.saveTokens({
accessToken: 'forced-official-token',
refreshToken: 'forced-official-refresh',
expiresAt: Date.now() + 30 * 60_000,
scopes: ['user:inference'],
subscriptionType: 'max',
})
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: null,
})) as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token')
})
test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'settings.json'),
JSON.stringify({ env: {} }),
'utf-8',
)
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
})
test('buildChildEnv injects desktop Computer Use host bundle id for sdk sessions', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID).toBe(
'com.claude-code-haha.desktop',
)
expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe('1')
})
test('uses bun entrypoint fallback on Windows dev mode', () => {
const service = new ConversationService() as any
const args = service.resolveCliArgs(['--print'])
if (process.platform === 'win32') {
expect(args[0]).toBe(process.execPath)
expect(args[1]).toBe('--preload')
expect(args[2]).toContain('preload.ts')
expect(args[3]).toContain(path.join('src', 'entrypoints', 'cli.tsx'))
} else {
expect(args[0]).toContain(path.join('bin', 'claude-haha'))
}
})
test('buildSessionCliArgs enables partial assistant messages for desktop streaming', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
{ permissionMode: 'bypassPermissions' },
) as string[]
expect(args).toContain('--include-partial-messages')
expect(args).toContain('--sdk-url')
expect(args).toContain('--replay-user-messages')
})
test('buildChildEnv asks desktop SDK sessions to wait briefly for MCP tools', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP).toBe('1')
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS).toBe('5000')
})
test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
{
model: 'model-b-opus',
effort: 'max',
},
) as string[]
expect(args).toContain('--model')
expect(args).toContain('model-b-opus')
expect(args).toContain('--effort')
expect(args).toContain('max')
})
test('buildSessionCliArgs starts pending desktop worktrees through the native CLI flag', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(
'123e4567-e89b-12d3-a456-426614174000',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
false,
undefined,
{
requestedWorkDir: '/tmp/source-repo',
repoRoot: '/tmp/source-repo',
branch: 'feature/rail',
worktree: true,
baseRef: 'feature/rail',
worktreeSlug: 'desktop-feature-rail-123e4567',
},
) as string[]
expect(args).toContain('--worktree')
expect(args).toContain('desktop-feature-rail-123e4567')
expect(args).toContain('--worktree-base-ref')
expect(args).toContain('feature/rail')
})
})