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.
385 lines
14 KiB
TypeScript
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()
|
|
})
|
|
})
|