mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Desktop sessions could show MCP configuration in the UI while the CLI SDK child process still started cold on the first user message. Start eligible desktop chat sessions as soon as their websocket is connected, keep init metadata cached but muted, and wait briefly for MCP startup in SDK print mode so turn one sees the tools. Constraint: Desktop wraps the existing CLI SDK bridge rather than owning MCP startup directly. Rejected: Prewarm every restored tab | synthetic tabs such as settings would start unnecessary CLI subprocesses. Confidence: high Scope-risk: moderate Directive: Do not remove the synthetic-tab guard without validating restored settings and scheduled-task tabs in the browser. Tested: bun test src/server/__tests__/conversations.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/mcp.test.ts Tested: cd desktop && bun run test -- chatStore.test.ts mcpSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: agent-browser E2E new session prewarm plus first message reuse with mock CLI Not-tested: Full desktop Vitest suite still has pre-existing English-vs-Chinese copy assertion failures.
281 lines
11 KiB
TypeScript
281 lines
11 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 originalAuthToken: string | undefined
|
|
let originalBaseUrl: string | undefined
|
|
let originalModel: string | undefined
|
|
let originalEntrypoint: string | undefined
|
|
let originalOAuthToken: string | undefined
|
|
let originalProviderManagedByHost: string | undefined
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
|
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
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
|
|
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
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
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
|
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
|
|
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
|
|
|
|
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')
|
|
})
|
|
|
|
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 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]).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')
|
|
})
|
|
})
|