Files
hermes-web-ui/tests/server/coding-agent-resume-config.test.ts
牧濑红莉栖(BOT) 0bfa9d2ed4 fix(coding-agent): 持久化会话 API 模式 (#1944)
* fix(coding-agent): persist API mode per session

* docs(chat-chain): document coding agent API mode persistence

---------

Co-authored-by: x1051445024 <你的GitHub注册邮箱>
2026-07-06 08:17:49 +08:00

385 lines
14 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getSessionMock = vi.fn()
const updateSessionMock = vi.fn()
const readConfigYamlForProfileMock = vi.fn()
const safeReadFileMock = vi.fn()
const startRunMock = vi.fn()
vi.doMock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: getSessionMock,
updateSession: updateSessionMock,
}))
vi.doMock('../../packages/server/src/services/config-helpers', () => ({
PROVIDER_ENV_MAP: {
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
},
readConfigYamlForProfile: readConfigYamlForProfileMock,
safeReadFile: safeReadFileMock,
}))
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getProfileDir: (profile: string) => `/tmp/hermes-profile/${profile}`,
}))
vi.doMock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({
codingAgentRunManager: {
start: startRunMock,
},
}))
const homes: string[] = []
function makeHome() {
const home = mkdtempSync(join(tmpdir(), 'hermes-coding-agent-resume-'))
homes.push(home)
process.env.HERMES_WEB_UI_HOME = home
return home
}
describe('coding agent resumed session config', () => {
beforeEach(() => {
vi.resetModules()
getSessionMock.mockReset()
updateSessionMock.mockReset()
readConfigYamlForProfileMock.mockReset()
safeReadFileMock.mockReset()
startRunMock.mockReset()
startRunMock.mockReturnValue({ runId: 'agent-session-1', pid: 0 })
})
afterEach(() => {
delete process.env.HERMES_WEB_UI_HOME
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
})
it('rebuilds Claude scoped proxy credentials from stored provider config after restart', async () => {
const home = makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'claude',
agent_session_id: 'agent-session-1',
provider: 'custom:corp-claude',
model: 'claude-sonnet-test',
})
readConfigYamlForProfileMock.mockResolvedValue({
custom_providers: [{
name: 'corp-claude',
base_url: 'https://provider.example/anthropic',
api_key: 'sk-upstream',
model: 'claude-sonnet-test',
api_mode: 'anthropic_messages',
}],
})
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await startCodingAgentRun('claude-code', { sessionId: 'session-1' })
expect(startRunMock).toHaveBeenCalledWith(expect.objectContaining({
agentSessionId: 'agent-session-1',
provider: 'custom:corp-claude',
model: 'claude-sonnet-test',
sessionId: 'session-1',
}))
const launch = startRunMock.mock.calls[0][0]
expect(launch.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/)
expect(launch.env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN')
expect(launch.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/)
const settings = JSON.parse(readFileSync(join(home, 'coding-agent', 'model', 'default', 'custom_corp-claude', 'claude-code', 'settings.json'), 'utf-8'))
expect(settings.env.ANTHROPIC_API_KEY).toBe(launch.env.ANTHROPIC_API_KEY)
})
it('recovers legacy sanitized custom provider keys from existing sessions', async () => {
const home = makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'claude',
agent_session_id: 'agent-session-1',
provider: 'custom_glm-coding-plan',
model: 'glm-5-turbo',
})
readConfigYamlForProfileMock.mockResolvedValue({
custom_providers: [{
name: 'glm-coding-plan',
base_url: 'https://api.z.ai/api/anthropic',
api_key: 'sk-glm',
model: 'glm-5-turbo',
}],
})
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
const result = await startCodingAgentRun('claude-code', { sessionId: 'session-1' })
expect(result.provider).toBe('custom:glm-coding-plan')
expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({
provider: 'custom:glm-coding-plan',
agent_native_session_id: expect.any(String),
}))
const launch = startRunMock.mock.calls[0][0]
expect(launch.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/)
expect(Buffer.from(launch.env.ANTHROPIC_BASE_URL.split('/').pop() || '', 'base64url').toString('utf8')).toContain('anthropic_messages')
const settings = JSON.parse(readFileSync(join(home, 'coding-agent', 'model', 'default', 'custom_glm-coding-plan', 'claude-code', 'settings.json'), 'utf-8'))
expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/)
})
it('preserves the stored workspace when a coding agent session switches provider and model', async () => {
const home = makeHome()
const originalWorkspace = join(home, 'coding-agent', 'workspace', 'default', 'openrouter')
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'codex',
agent_session_id: 'agent-session-1',
agent_native_session_id: 'old-native-thread',
provider: 'openrouter',
model: 'old-model',
workspace: originalWorkspace,
})
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await startCodingAgentRun('codex', {
sessionId: 'session-1',
provider: 'deepseek',
model: 'deepseek-reasoner',
baseUrl: 'https://api.deepseek.com/v1',
apiKey: 'sk-test',
apiMode: 'chat_completions',
})
const launch = startRunMock.mock.calls[0][0]
expect(launch.workspaceDir).toBe(originalWorkspace)
expect(launch.agentNativeSessionId).toBe('')
expect(launch.nativeResume).toBe(false)
expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({
provider: 'deepseek',
model: 'deepseek-reasoner',
agent_native_session_id: '',
workspace: originalWorkspace,
}))
})
it('ignores a stale builtin base URL when the requested provider changed', async () => {
const home = makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'codex',
agent_session_id: 'agent-session-1',
provider: 'deepseek',
model: 'deepseek-v4-pro',
})
readConfigYamlForProfileMock.mockResolvedValue({})
safeReadFileMock.mockResolvedValue('DEEPSEEK_API_KEY=sk-deepseek\n')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await startCodingAgentRun('codex', {
sessionId: 'session-1',
provider: 'deepseek',
model: 'deepseek-v4-pro',
baseUrl: 'https://api.xiaomimimo.com/v1',
apiKey: 'sk-xiaomi',
apiMode: 'chat_completions',
})
const config = readFileSync(join(home, 'coding-agent', 'model', 'default', 'deepseek', 'codex', 'config.toml'), 'utf-8')
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
const routeParts = Buffer.from(routeKey, 'base64url').toString('utf8').split('\0')
expect(routeParts).toEqual(expect.arrayContaining([
'deepseek',
'deepseek-v4-pro',
'chat_completions',
'https://api.deepseek.com',
]))
expect(routeParts).not.toContain('https://api.xiaomimimo.com/v1')
})
it('resumes Claude with a stored native session id after service restart', async () => {
makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'claude',
agent_session_id: 'agent-session-1',
agent_native_session_id: '11111111-1111-4111-8111-111111111111',
provider: 'custom:corp-claude',
model: 'claude-sonnet-test',
})
readConfigYamlForProfileMock.mockResolvedValue({
custom_providers: [{
name: 'corp-claude',
base_url: 'https://provider.example/anthropic',
api_key: 'sk-upstream',
model: 'claude-sonnet-test',
api_mode: 'anthropic_messages',
}],
})
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await startCodingAgentRun('claude-code', { sessionId: 'session-1' })
expect(startRunMock).toHaveBeenCalledWith(expect.objectContaining({
agentNativeSessionId: '11111111-1111-4111-8111-111111111111',
nativeResume: true,
}))
})
it('does not resume a stored scoped Codex native session when launching global mode', async () => {
makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'codex',
agent_mode: 'scoped',
agent_session_id: 'agent-session-1',
agent_native_session_id: 'codex-scoped-thread',
provider: 'custom:glm-coding-plan',
model: 'glm-5-turbo',
})
readConfigYamlForProfileMock.mockResolvedValue({})
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
const result = await startCodingAgentRun('codex', { sessionId: 'session-1', mode: 'global' })
expect(result).toEqual(expect.objectContaining({
mode: 'global',
provider: 'global',
model: '',
}))
expect(startRunMock).toHaveBeenCalledWith(expect.objectContaining({
agentSessionId: 'agent-session-1',
agentNativeSessionId: '',
nativeResume: false,
provider: 'global',
model: '',
env: {},
args: [],
}))
expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({
agent_mode: 'global',
agent_native_session_id: '',
provider: 'global',
model: '',
}))
})
it('prefers a stored coding-agent API mode when resuming without an explicit mode', async () => {
const home = makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'codex',
agent_session_id: 'agent-session-1',
provider: 'fun-codex',
model: 'gpt-5.5',
api_mode: 'chat_completions',
})
readConfigYamlForProfileMock.mockResolvedValue({})
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await startCodingAgentRun('codex', {
sessionId: 'session-1',
baseUrl: 'https://api.apikey.fun/v1',
apiKey: 'sk-test',
})
const launch = startRunMock.mock.calls[0][0]
expect(launch.apiMode).toBe('chat_completions')
const config = readFileSync(join(home, 'coding-agent', 'model', 'default', 'fun-codex', 'codex', 'config.toml'), 'utf-8')
const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || ''
expect(Buffer.from(routeKey, 'base64url').toString('utf8')).toContain('chat_completions')
expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({
api_mode: 'chat_completions',
}))
})
it('rejects OAuth/subscription providers for scoped coding-agent launches', async () => {
makeHome()
getSessionMock.mockReturnValue(null)
readConfigYamlForProfileMock.mockResolvedValue({})
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await expect(startCodingAgentRun('codex', {
sessionId: 'session-1',
mode: 'scoped',
profile: 'default',
provider: 'copilot',
model: 'gpt-5.5',
baseUrl: 'https://api.githubcopilot.com',
apiKey: 'oauth-token',
apiMode: 'codex_responses',
})).rejects.toThrow('does not support OAuth/subscription providers')
expect(startRunMock).not.toHaveBeenCalled()
})
it('fails clearly instead of launching Claude without scoped credentials', async () => {
makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'claude',
agent_session_id: 'agent-session-1',
provider: 'custom:missing',
model: 'claude-sonnet-test',
})
readConfigYamlForProfileMock.mockResolvedValue({ custom_providers: [] })
safeReadFileMock.mockResolvedValue('')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await expect(startCodingAgentRun('claude-code', { sessionId: 'session-1' }))
.rejects.toThrow('Coding agent provider credentials are missing')
expect(startRunMock).not.toHaveBeenCalled()
})
it('resolves custom-provider credentials from key_env on continuation', async () => {
const home = makeHome()
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
source: 'coding_agent',
agent: 'claude',
agent_session_id: 'agent-session-1',
provider: 'custom:sensenova',
model: 'deepseek-v4-flash',
})
readConfigYamlForProfileMock.mockResolvedValue({
custom_providers: [{
name: 'sensenova',
base_url: 'https://api.sensenova.cn/v1',
key_env: 'SENSENOVA_API_KEY',
model: 'deepseek-v4-flash',
}],
})
safeReadFileMock.mockResolvedValue('SENSENOVA_API_KEY=sk-from-env\n')
const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents')
await expect(startCodingAgentRun('claude-code', { sessionId: 'session-1' })).resolves.toEqual(
expect.objectContaining({
provider: 'custom:sensenova',
}),
)
expect(startRunMock).toHaveBeenCalled()
expect(home).toBeTruthy()
})
})