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
+19 -136
View File
@@ -1,7 +1,6 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, onSessionCommand, onSessionTitleUpdated, onSessionWorkspaceUpdated, respondClarify, type ChatRunTransport, type RunEvent, type ResumeSessionPayload, type StartRunRequest, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { archiveSession as archiveSessionApi, deleteSession as deleteSessionApi, fetchSessionMessagesPage, fetchSessions, fetchWorkspaceRunChangeFile, fetchWorkspaceRunChangesForSession, setSessionModel, type HermesMessage, type SessionSummary, type WorkspaceRunChangeFileDetail, type WorkspaceRunChangeSummary } from '@/api/hermes/sessions'
import { getActiveProfileName } from '@/api/client'
import { inferCodingAgentApiMode, normalizeCodingAgentApiMode, type ChatCodingAgentId } from '@/api/coding-agents'
import { getDownloadUrl } from '@/api/hermes/download'
import type { ProviderApiMode } from '@/api/hermes/system'
import { defineStore } from 'pinia'
@@ -21,21 +20,7 @@ export type ContentBlock = ContentBlockImport
export const LIVE_CHAT_MESSAGE_PAGE_SIZE = 150
export const LIVE_CHAT_MAX_LOADED_MESSAGES = 300
const WORKSPACE_RUN_CHANGE_MESSAGE_PREFIX = 'workspace-run-change:'
type ChatAgentId = 'hermes' | 'claude' | 'codex' | 'ekko-agent'
function agentToCodingAgentId(agent?: string): ChatCodingAgentId | undefined {
if (agent === 'codex') return 'codex'
if (agent === 'claude') return 'claude-code'
if (agent === 'ekko-agent') return 'ekko-agent'
return undefined
}
function codingAgentIdToAgent(id?: ChatCodingAgentId): ChatAgentId | undefined {
if (id === 'codex') return 'codex'
if (id === 'claude-code') return 'claude'
if (id === 'ekko-agent') return 'ekko-agent'
return undefined
}
type ChatAgentId = 'hermes'
function moaReferenceLabel(evt: RunEvent): string {
const label = typeof evt.label === 'string' && evt.label.trim()
@@ -113,8 +98,6 @@ export interface Session {
agent?: string
agentSessionId?: string
agentNativeSessionId?: string
codingAgentId?: ChatCodingAgentId
codingAgentMode?: 'global' | 'scoped'
messages: Message[]
createdAt: number
updatedAt: number
@@ -540,13 +523,6 @@ function lastVisibleMessageRole(messages?: Message[] | null): string | null {
}
function mapHermesSession(s: SessionSummary): Session {
const codingAgentId = agentToCodingAgentId(s.agent)
const isCodingAgentSession = s.source === 'coding_agent' || Boolean(codingAgentId)
const codingAgentMode = isCodingAgentSession
? (s.agent_mode === 'global' || s.agent_mode === 'scoped'
? s.agent_mode
: s.provider === 'global' ? 'global' : 'scoped')
: undefined
const activitySeconds = sessionActivitySeconds(s)
return {
id: s.id,
@@ -556,8 +532,6 @@ function mapHermesSession(s: SessionSummary): Session {
agent: s.agent || undefined,
agentSessionId: s.agent_session_id || undefined,
agentNativeSessionId: s.agent_native_session_id || undefined,
codingAgentId,
codingAgentMode,
messages: [],
createdAt: Math.round(s.started_at * 1000),
updatedAt: Math.round(activitySeconds * 1000),
@@ -605,22 +579,6 @@ function runtimeStoragePrefix(): string {
function storageKey(): string { return runtimeStoragePrefix() + getProfileName() }
function legacyStorageKey(): string | null { return activeRuntimeMode === 'default' && getProfileName() === 'default' ? LEGACY_STORAGE_KEY : null }
function isCodingAgentLikeSession(session?: Pick<Session, 'source' | 'agent' | 'codingAgentId'> | null): boolean {
return session?.source === 'coding_agent' ||
Boolean(session?.codingAgentId) ||
Boolean(agentToCodingAgentId(session?.agent))
}
function clearCodingAgentRuntimeCredentials(session?: Session | null) {
if (!session || !isCodingAgentLikeSession(session)) return
session.baseUrl = undefined
session.apiKey = undefined
}
function shouldPreserveRuntimeApiMode(session?: Session | null): boolean {
return isCodingAgentLikeSession(session) && session?.codingAgentMode !== 'global'
}
function isQuotaExceededError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const e = error as { name?: string, code?: number }
@@ -1161,26 +1119,20 @@ export const useChatStore = defineStore('chat', () => {
profile?: string
model?: string
provider?: string
source?: 'api_server' | 'cli' | 'coding_agent' | 'global_agent' | 'workflow'
source?: 'api_server' | 'cli' | 'global_agent' | 'workflow'
agent?: ChatAgentId
codingAgentId?: ChatCodingAgentId
codingAgentMode?: 'global' | 'scoped'
workspace?: string | null
baseUrl?: string
apiKey?: string
apiMode?: ProviderApiMode
} = {}): Session {
const source = runtimeMode.value === 'global_agent' ? 'global_agent' : options.source || 'cli'
const codingAgentId = options.codingAgentId || agentToCodingAgentId(options.agent)
const codingAgentMode = codingAgentId ? (options.codingAgentMode || 'scoped') : undefined
const session: Session = {
id: uid(),
profile: options.profile || useProfilesStore().activeProfileName || 'default',
title: '',
source,
agent: options.agent || codingAgentIdToAgent(codingAgentId) || 'hermes',
codingAgentId,
codingAgentMode,
agent: options.agent || 'hermes',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
@@ -1446,10 +1398,8 @@ export const useChatStore = defineStore('chat', () => {
profile?: string
model?: string
provider?: string
source?: 'api_server' | 'cli' | 'coding_agent' | 'global_agent' | 'workflow'
source?: 'api_server' | 'cli' | 'global_agent' | 'workflow'
agent?: ChatAgentId
codingAgentId?: ChatCodingAgentId
codingAgentMode?: 'global' | 'scoped'
workspace?: string | null
baseUrl?: string
apiKey?: string
@@ -1457,16 +1407,12 @@ export const useChatStore = defineStore('chat', () => {
} = {}): Session {
const appStore = useAppStore()
const storageSource = runtimeMode.value === 'global_agent' ? 'global_agent' : options.source || 'cli'
const codingAgentId = options.codingAgentId || agentToCodingAgentId(options.agent)
const isGlobalCodingAgent = Boolean(codingAgentId) && options.codingAgentMode === 'global'
const session = createSession({
profile: options.profile,
model: isGlobalCodingAgent ? undefined : options.model || appStore.selectedModel || undefined,
provider: isGlobalCodingAgent ? '' : options.provider || appStore.selectedProvider || '',
model: options.model || appStore.selectedModel || undefined,
provider: options.provider || appStore.selectedProvider || '',
source: storageSource,
agent: options.agent,
codingAgentId,
codingAgentMode: options.codingAgentMode,
workspace: options.workspace,
baseUrl: options.baseUrl,
apiKey: options.apiKey,
@@ -1476,33 +1422,20 @@ export const useChatStore = defineStore('chat', () => {
return session
}
async function switchSessionModel(modelId: string, provider?: string, sessionId?: string, apiMode?: ProviderApiMode): Promise<boolean> {
async function switchSessionModel(modelId: string, provider?: string, sessionId?: string): Promise<boolean> {
const targetId = sessionId || activeSession.value?.id
if (!targetId) return false
const target = sessions.value.find(s => s.id === targetId)
const activeTarget = activeSession.value?.id === targetId ? activeSession.value : null
const previousProvider = String(target?.provider ?? activeTarget?.provider ?? '')
const nextProvider = provider || ''
const shouldClearRuntimeCredentials = previousProvider !== nextProvider && (
isCodingAgentLikeSession(target) || isCodingAgentLikeSession(activeTarget)
)
const preservedApiMode = apiMode || (previousProvider === nextProvider
? (shouldPreserveRuntimeApiMode(target) ? target?.apiMode : undefined) ||
(shouldPreserveRuntimeApiMode(activeTarget) ? activeTarget?.apiMode : undefined)
: undefined)
const ok = await setSessionModel(targetId, modelId, provider || '', preservedApiMode)
const ok = await setSessionModel(targetId, modelId, provider || '')
if (!ok) return false
if (target) {
target.model = modelId
target.provider = provider || ''
target.apiMode = preservedApiMode
if (shouldClearRuntimeCredentials) clearCodingAgentRuntimeCredentials(target)
}
if (activeTarget) {
activeTarget.model = modelId
activeTarget.provider = provider || ''
activeTarget.apiMode = preservedApiMode
if (shouldClearRuntimeCredentials) clearCodingAgentRuntimeCredentials(activeTarget)
}
return true
}
@@ -1882,7 +1815,6 @@ export const useChatStore = defineStore('chat', () => {
function handleAgentEvent(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
if ((evt as any).source === 'coding_agent' && (evt as any).kind === 'status') return
const text = String((evt as any).text || (evt as any).message || '').trim()
if (!text) return
@@ -2237,18 +2169,8 @@ export const useChatStore = defineStore('chat', () => {
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`
}
function completionNotificationAgent(session: Session): { icon: string } {
const codingAgentId = session.codingAgentId || agentToCodingAgentId(session.agent)
if (codingAgentId === 'codex') {
return { icon: '/coding-agents/codex-openai.png' }
}
if (codingAgentId === 'claude-code') {
return { icon: '/coding-agents/claude-code.svg' }
}
if (codingAgentId === 'ekko-agent') {
return { icon: '/coding-agents/ekko-agent.png' }
}
return { icon: '/coding-agents/hermes.png' }
function completionNotificationAgent(_session: Session): { icon: string } {
return { icon: '/favicon.ico' }
}
function completionNotificationBody(session: Session, message?: Message): string {
@@ -2292,15 +2214,14 @@ export const useChatStore = defineStore('chat', () => {
const shouldSendInitialSessionConfig = activeSession.value
? activeSession.value.messageCount == null || activeSession.value.messageCount === 0
: false
const isCodingAgentSession = isCodingAgentLikeSession(activeSession.value)
const isBridgeSlashCommand = !isCodingAgentSession && isKnownBridgeSessionCommand(trimmedContent)
const isBridgeSlashCommand = isKnownBridgeSessionCommand(trimmedContent)
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(trimmedContent)
const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(trimmedContent)
const isBridgeSkillCommand = isBridgeSlashCommand && /^\/skill(?:\s|$)/i.test(trimmedContent)
const isBridgeMoaCommand = isBridgeSlashCommand && /^\/moa(?:\s|$)/i.test(trimmedContent)
const isBridgeGoalCommand = isBridgeSlashCommand && /^\/goal(?:\s|$)/i.test(trimmedContent)
const isBridgeForkCommand = isBridgeSlashCommand && /^\/fork(?:\s|$)/i.test(trimmedContent)
const shouldOptimisticallyShowRunStatus = !isCodingAgentSession && !isBridgeForkCommand
const shouldOptimisticallyShowRunStatus = !isBridgeForkCommand
const wasLiveBeforeSend = isSessionLive(sid)
if (isBridgeForkCommand) {
if (pendingForkCommands.value.has(sid)) return
@@ -2380,42 +2301,20 @@ export const useChatStore = defineStore('chat', () => {
? appStore.profileModelGroups.find(entry => entry.profile === sessionProfile)?.groups
: undefined
const runModelGroups = profileModelGroups?.length ? profileModelGroups : appStore.modelGroups
const providerGroup = runModelGroups.find(group => group.provider === sessionProvider)
const storedSource = activeSession.value?.source
const sessionSource: StartRunRequest['source'] = storedSource === 'global_agent'
? 'global_agent'
: storedSource === 'workflow'
? 'workflow'
: isCodingAgentSession
? 'coding_agent'
: storedSource === 'api_server'
: storedSource === 'api_server'
? 'api_server'
: 'cli'
const isCodingAgentExecution = sessionSource === 'coding_agent' || (sessionSource === 'workflow' && isCodingAgentSession)
const codingAgentId: ChatCodingAgentId =
activeSession.value?.codingAgentId ||
agentToCodingAgentId(activeSession.value?.agent) ||
'claude-code'
const codingAgentMode = activeSession.value?.codingAgentMode || 'scoped'
const codingAgentApiMode = isCodingAgentExecution && codingAgentId !== 'ekko-agent' && codingAgentMode !== 'global'
? normalizeCodingAgentApiMode(
activeSession.value?.apiMode || providerGroup?.api_mode,
inferCodingAgentApiMode(
sessionProvider || providerGroup?.provider,
activeSession.value?.baseUrl || providerGroup?.base_url,
),
)
: undefined
const runPayload: StartRunRequest = {
input,
session_id: sid,
profile: sessionProfile,
model: isCodingAgentExecution
? (codingAgentMode === 'global' ? undefined : sessionModel || undefined)
: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined,
provider: isCodingAgentExecution
? (codingAgentMode === 'global' ? undefined : sessionProvider || undefined)
: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined,
model: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined,
provider: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined,
model_groups: runModelGroups.map(group => ({
provider: group.provider,
models: group.models,
@@ -2425,22 +2324,9 @@ export const useChatStore = defineStore('chat', () => {
source: sessionSource,
...(runtimeMode.value === 'global_agent' ? { session_source: 'global_agent' as const } : {}),
...(sessionSource === 'workflow' ? { session_source: 'workflow' as const } : {}),
...(isCodingAgentExecution
? {
coding_agent_id: codingAgentId,
mode: codingAgentMode,
baseUrl: codingAgentMode === 'global' ? undefined : activeSession.value?.baseUrl || providerGroup?.base_url || undefined,
apiKey: codingAgentMode === 'global' ? undefined : activeSession.value?.apiKey || providerGroup?.api_key || undefined,
apiMode: codingAgentApiMode,
}
: {}),
// Per-session reasoning effort override. Hermes bridge and scoped coding
// agents both consume this when the selected provider/API supports it.
// Global coding-agent mode uses the user's native CLI config, so avoid
// injecting a per-session override there.
reasoning_effort: isCodingAgentExecution && codingAgentMode === 'global'
? undefined
: activeSession.value?.reasoningEffort || undefined,
// Per-session reasoning effort override. Empty/undefined falls back to
// the profile default in config.yaml.
reasoning_effort: activeSession.value?.reasoningEffort || undefined,
}
if (shouldSendInitialSessionConfig && activeSession.value) {
activeSession.value.messageCount = Math.max(activeSession.value.messageCount || 0, 1)
@@ -3143,10 +3029,7 @@ export const useChatStore = defineStore('chat', () => {
)
runSubmitted = true
if (isCodingAgentSession) {
serverWorking.value.add(sid)
streamStates.value.set(sid, ctrl)
} else if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand || isBridgeGoalCommand) {
if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand || isBridgeGoalCommand) {
streamStates.value.set(sid, ctrl)
}
} catch (err: any) {