Phase 003: remove the coding-agent and ekko-agent runtimes

Every chat session now runs through the Hermes bridge, so the three chat
chains become feature-equivalent instead of three partially-maintained
dispatch paths. Keeping the coding-agent and in-house agent runtimes alive
meant every bridge change had to be mirrored into two more runtimes that
had already drifted.

Historical rows are read, not migrated: sessions stored with the retired
source normalize to cli on load, workflow graphs naming the retired agents
keep loading and run on the bridge, and the visible-source check plus
SOURCE_LABELS still recognize the old value so those sessions stay listed.

The public surface follows the code: the OpenAPI generator and the MCP
chat-run tool schema drop the six retired entry fields so external callers
are not told about runtimes that no longer exist.

harness-check gains a RETIRED_RUNTIME scan so the residual check is a
mechanical gate instead of a manual grep, with the two surviving read-side
compat sites recorded with reasons.

Refs: docs/phases/phase_003.md
This commit is contained in:
2026-10-16 13:27:50 +08:00
parent c93e1fe1bd
commit 8c1ddf415f
123 changed files with 567 additions and 23599 deletions
+2 -2
View File
@@ -173,13 +173,13 @@ describe('API Client', () => {
status: 500,
text: () => Promise.resolve(JSON.stringify({
error: {
message: 'spawn claude ENOENT',
message: 'bridge connect ENOENT',
code: 'ENOENT',
},
})),
})
await expect(request('/api/coding-agents/runs/session-1/input')).rejects.toThrow('API Error 500: spawn claude ENOENT')
await expect(request('/api/hermes/sessions/session-1/input')).rejects.toThrow('API Error 500: bridge connect ENOENT')
})
it('returns parsed JSON on success', async () => {
+4 -11
View File
@@ -154,11 +154,8 @@ describe('ChatInput draft persistence', () => {
expect((wrapper.get('textarea').element as HTMLTextAreaElement).style.height).not.toBe('180px')
})
it('shows context usage for coding-agent sessions', async () => {
const wrapper = mountForSession('session-codex', {
source: 'coding_agent',
agent: 'codex',
codingAgentId: 'codex',
it('shows context usage for the active session', async () => {
const wrapper = mountForSession('session-context', {
inputTokens: 1200,
outputTokens: 800,
contextTokens: 2000,
@@ -170,12 +167,8 @@ describe('ChatInput draft persistence', () => {
expect(wrapper.find('.context-bar').exists()).toBe(true)
})
it('shows reasoning effort selector for coding-agent sessions', async () => {
const wrapper = mountForSession('session-codex', {
source: 'coding_agent',
agent: 'codex',
codingAgentId: 'codex',
})
it('shows the reasoning effort selector for the active session', async () => {
const wrapper = mountForSession('session-reasoning-effort')
await nextTick()
expect(wrapper.find('.n-popselect-stub').exists()).toBe(true)
+7 -14
View File
@@ -9,27 +9,20 @@ describe('ChatPanel session clicks', () => {
expect(source).toContain('await chatStore.switchSession(sessionId)')
})
it('allows session model switching for coding agent sessions', () => {
it('switches the session model without a protocol mode modal', () => {
const source = readFileSync('packages/client/src/components/hermes/chat/ChatPanel.vue', 'utf8')
expect(source).toContain('contextSession.value?.source === "coding_agent"')
expect(source).toContain('isSessionModelScopedCodingAgent')
expect(source).toContain('!isCodingAgentAuthProvider(group.provider)')
expect(source).toContain('showSessionModelModeModal')
expect(source).toContain('pendingSessionModelSwitch')
expect(source).toContain('chatStore.switchSessionModel(model, provider, sessionModelSessionId.value, apiMode)')
expect(source).toContain('chatStore.switchSessionModel(model, provider, sessionModelSessionId.value)')
expect(source).not.toContain('showSessionModelModeModal')
expect(source).not.toContain('pendingSessionModelSwitch')
expect(source).not.toContain('header-model-button--readonly')
expect(source).not.toContain('if (isActiveSessionCodingAgent.value) return')
})
it('uses codingAgentId when deciding whether session model switches need an API mode', () => {
it('does not gate session model switching on a coding agent id', () => {
const source = readFileSync('packages/client/src/components/hermes/chat/ChatPanel.vue', 'utf8')
expect(source).toContain('const sessionModelCodingAgentId = computed<ChatCodingAgentId | undefined>')
expect(source).toContain('sessionModelSession.value?.codingAgentId ||')
expect(source).toContain('sessionModelSession.value?.agent === "claude"')
expect(source).toContain('sessionModelCodingAgentId.value === "claude-code"')
expect(source).not.toContain('sessionModelSession.value?.agent === "claude-code"')
expect(source).not.toContain('sessionModelCodingAgentId')
expect(source).not.toContain('isSessionModelScopedCodingAgent')
})
it('uses the active sidebar model as the new chat default for the active profile', () => {
@@ -294,12 +294,9 @@ describe('chat store reasoning/tool boundaries', () => {
])
})
it('settles running coding-agent tools when the run completes without a tool.completed event', async () => {
it('settles running tools when the run completes without a tool.completed event', async () => {
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'claude'
session.codingAgentId = 'claude-code'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
@@ -351,49 +348,6 @@ describe('chat store reasoning/tool boundaries', () => {
].join('\n'))
})
it('queues active coding agent follow-up messages without command styling', async () => {
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'claude'
session.codingAgentId = 'claude-code'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
await store.sendMessage('first input')
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void
onEvent({
event: 'agent.event',
session_id: 'session-1',
source: 'coding_agent',
kind: 'status',
text: 'Input sent to coding agent.',
})
expect(store.messages).toHaveLength(1)
onEvent({ event: 'run.started', session_id: 'session-1' })
await store.sendMessage('/not-a-hermes-command')
expect(chatApi.startRunViaSocket).toHaveBeenCalledTimes(2)
expect(store.queuedUserMessages.get('session-1')).toEqual([
expect.objectContaining({
role: 'user',
content: '/not-a-hermes-command',
queued: true,
systemType: undefined,
}),
])
expect(store.messages).toEqual([
expect.objectContaining({
role: 'user',
content: 'first input',
queued: false,
}),
])
})
it('queues unknown slash commands in bridge sessions as normal user input', async () => {
const store = useChatStore()
const session = makeSession()
@@ -484,111 +438,14 @@ describe('chat store reasoning/tool boundaries', () => {
])
})
it('starts global coding-agent runs without provider credentials', async () => {
it('sends the selected workspace and reasoning effort when starting a bridge run', async () => {
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'codex'
session.codingAgentId = 'codex'
session.codingAgentMode = 'global'
session.provider = 'should-not-send'
session.model = 'should-not-send'
session.baseUrl = 'http://example.invalid'
session.apiKey = 'secret'
session.apiMode = 'chat_completions'
session.reasoningEffort = 'high'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
await store.sendMessage('use global codex auth')
const body = chatApi.startRunViaSocket.mock.calls[0][0]
expect(body).toEqual(expect.objectContaining({
source: 'coding_agent',
coding_agent_id: 'codex',
mode: 'global',
}))
expect(body.provider).toBeUndefined()
expect(body.model).toBeUndefined()
expect(body.baseUrl).toBeUndefined()
expect(body.apiKey).toBeUndefined()
expect(body.apiMode).toBeUndefined()
expect(body.reasoning_effort).toBeUndefined()
})
it('clears stale coding-agent runtime credentials when switching providers', async () => {
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'codex'
session.codingAgentId = 'codex'
session.codingAgentMode = 'scoped'
session.provider = 'xiaomi'
session.model = 'mimo-v2.5-pro'
session.baseUrl = 'https://api.xiaomimimo.com/v1'
session.apiKey = 'sk-xiaomi'
session.apiMode = 'chat_completions'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
const ok = await store.switchSessionModel('deepseek-v4-pro', 'deepseek', 'session-1', 'chat_completions')
expect(ok).toBe(true)
expect(sessionsApi.setSessionModel).toHaveBeenCalledWith(
'session-1',
'deepseek-v4-pro',
'deepseek',
'chat_completions',
)
expect(session.provider).toBe('deepseek')
expect(session.model).toBe('deepseek-v4-pro')
expect(session.baseUrl).toBeUndefined()
expect(session.apiKey).toBeUndefined()
expect(session.apiMode).toBe('chat_completions')
})
it('preserves a scoped coding-agent API mode when reselecting the same provider', async () => {
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'codex'
session.codingAgentId = 'codex'
session.codingAgentMode = 'scoped'
session.provider = 'fun-codex'
session.model = 'gpt-5.4'
session.apiMode = 'chat_completions'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
const ok = await store.switchSessionModel('gpt-5.5', 'fun-codex', 'session-1')
expect(ok).toBe(true)
expect(sessionsApi.setSessionModel).toHaveBeenCalledWith(
'session-1',
'gpt-5.5',
'fun-codex',
'chat_completions',
)
expect(session.apiMode).toBe('chat_completions')
})
it('sends the selected workspace when starting a coding-agent run', async () => {
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'claude'
session.codingAgentId = 'claude-code'
session.codingAgentMode = 'scoped'
session.source = 'cli'
session.provider = 'openrouter'
session.model = 'anthropic/claude-sonnet-4.6'
session.baseUrl = 'https://openrouter.ai/api/v1'
session.apiKey = 'sk-test'
session.apiMode = 'anthropic_messages'
session.workspace = '/workspace/project-a'
session.reasoningEffort = 'high'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
@@ -596,21 +453,62 @@ describe('chat store reasoning/tool boundaries', () => {
await store.sendMessage('use this project')
expect(chatApi.startRunViaSocket.mock.calls[0][0]).toEqual(expect.objectContaining({
source: 'coding_agent',
coding_agent_id: 'claude-code',
mode: 'scoped',
source: 'cli',
workspace: '/workspace/project-a',
reasoning_effort: 'high',
}))
})
it('keeps a local stream controller for active coding-agent runs', async () => {
it('sends only the bridge run fields on the run payload', async () => {
const store = useChatStore()
const session = makeSession()
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
await store.sendMessage('hello')
expect(Object.keys(chatApi.startRunViaSocket.mock.calls[0][0] as Record<string, unknown>).sort())
.toEqual([
'input',
'model',
'model_groups',
'profile',
'provider',
'queue_id',
'reasoning_effort',
'session_id',
'source',
'workspace',
])
})
it('switches the session model without touching runtime credentials', async () => {
const store = useChatStore()
const session = makeSession()
session.provider = 'xiaomi'
session.model = 'mimo-v2.5-pro'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
const ok = await store.switchSessionModel('deepseek-v4-pro', 'deepseek', 'session-1')
expect(ok).toBe(true)
expect(sessionsApi.setSessionModel).toHaveBeenCalledWith(
'session-1',
'deepseek-v4-pro',
'deepseek',
)
expect(session.provider).toBe('deepseek')
expect(session.model).toBe('deepseek-v4-pro')
})
it('keeps a local stream controller for active runs', async () => {
const abort = vi.fn()
chatApi.startRunViaSocket.mockReturnValue({ abort })
const store = useChatStore()
const session = makeSession()
session.source = 'coding_agent'
session.agent = 'claude'
session.codingAgentId = 'claude-code'
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
@@ -1,30 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import {
CODING_AGENT_API_MODES,
inferCodingAgentApiMode,
normalizeCodingAgentApiMode,
} from '@/api/coding-agents'
describe('coding agent api modes', () => {
it('only exposes scoped coding-agent protocols supported by launch flows', () => {
expect(CODING_AGENT_API_MODES).toEqual([
'chat_completions',
'codex_responses',
'anthropic_messages',
])
})
it('keeps supported provider api modes and downgrades unsupported ones', () => {
expect(normalizeCodingAgentApiMode('anthropic_messages', 'codex_responses')).toBe('anthropic_messages')
expect(normalizeCodingAgentApiMode('bedrock_converse', 'chat_completions')).toBe('chat_completions')
expect(normalizeCodingAgentApiMode('codex_app_server', 'codex_responses')).toBe('codex_responses')
})
it('infers a safe fallback protocol from provider identity and base URL', () => {
expect(inferCodingAgentApiMode('anthropic', 'https://api.anthropic.com')).toBe('anthropic_messages')
expect(inferCodingAgentApiMode('deepseek', 'https://api.deepseek.com')).toBe('chat_completions')
expect(inferCodingAgentApiMode('xiaomi', 'https://api.xiaomimimo.com/v1')).toBe('chat_completions')
expect(inferCodingAgentApiMode('openrouter', 'https://openrouter.ai/api/v1')).toBe('chat_completions')
})
})
+3 -45
View File
@@ -151,7 +151,7 @@ describe('SessionListItem', () => {
})
const logo = wrapper.get('.session-item-agent-logo')
expect(logo.attributes('src')).toBe('/coding-agents/hermes.png')
expect(logo.attributes('src')).toBe('/logo.png')
expect(logo.attributes('alt')).toBe('Hermes')
expect(wrapper.find('.session-item-agent-name').exists()).toBe(false)
})
@@ -159,7 +159,7 @@ describe('SessionListItem', () => {
it('defaults old sessions without agent metadata to the Hermes logo', () => {
const wrapper = mount(SessionListItem, {
props: {
session: { ...session, source: undefined, agent: undefined, codingAgentId: undefined },
session: { ...session, source: undefined, agent: undefined },
active: false,
pinned: false,
canDelete: true,
@@ -172,50 +172,8 @@ describe('SessionListItem', () => {
})
const logo = wrapper.get('.session-item-agent-logo')
expect(logo.attributes('src')).toBe('/coding-agents/hermes.png')
expect(logo.attributes('src')).toBe('/logo.png')
expect(logo.attributes('alt')).toBe('Hermes')
expect(wrapper.find('.session-item-agent-name').exists()).toBe(false)
})
it('renders the Claude Code logo for Claude coding agent sessions', () => {
const wrapper = mount(SessionListItem, {
props: {
session: { ...session, source: 'coding_agent', agent: 'claude', codingAgentId: 'claude-code' },
active: false,
pinned: false,
canDelete: true,
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
const logo = wrapper.get('.session-item-agent-logo')
expect(logo.attributes('src')).toBe('/coding-agents/claude-code.svg')
expect(logo.attributes('alt')).toBe('Claude Code')
expect(wrapper.find('.session-item-agent-name').exists()).toBe(false)
})
it('renders the Codex logo for Codex coding agent sessions', () => {
const wrapper = mount(SessionListItem, {
props: {
session: { ...session, source: 'coding_agent', agent: 'codex', codingAgentId: 'codex' },
active: false,
pinned: false,
canDelete: true,
},
global: {
stubs: {
ProfileAvatar: true,
},
},
})
const logo = wrapper.get('.session-item-agent-logo')
expect(logo.attributes('src')).toBe('/coding-agents/codex-openai.png')
expect(logo.attributes('alt')).toBe('Codex')
expect(wrapper.find('.session-item-agent-name').exists()).toBe(false)
})
})