Files
hermes-ui/tests/server/coding-agent-resume-config.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +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()
})
})