fix coding agent model switching (#1725)

This commit is contained in:
ekko
2026-06-22 10:25:23 +08:00
committed by GitHub
parent 6e87e19314
commit af07ed00a3
16 changed files with 432 additions and 53 deletions
@@ -0,0 +1,8 @@
---
date: 2026-06-22
commit: local
feature: 会话默认 workspace 持久化
impact: Hermes/Claude/Codex 未显式选择 workspace 时会落库默认 workspace,模型切换后继续沿用。
---
Hermes bridge run 在创建或首次使用 session 时会把 profile 下的默认 `workspace` 目录写入本地 session store。Coding agent launch 会优先使用 session store 里已有的 workspace;未显式选择 workspace 的新 Claude/Codex session 仍由 launcher 计算默认目录并写回 DB。`/api/hermes/sessions/:id/model` 对 coding agent session 不再通知 Hermes bridge 热切换,也不会在点击切换时停止当前 hidden runner;模型或 provider 变化时只清空旧 `agent_native_session_id`,下一条消息会用更新后的 model/provider/protocol 启动新的 proxy/native 会话,并沿用 DB 中的 workspace。前端 coding agent session 切换模型时会先选择模型,再弹出协议选择,确认协议后才提交切换。
+3 -2
View File
@@ -1,4 +1,5 @@
import { request, getApiKey, getBaseUrlValue } from '../client'
import type { ProviderApiMode } from './system'
export interface SessionSummary {
id: string
@@ -254,11 +255,11 @@ export async function setSessionWorkspace(id: string, workspace: string | null):
}
}
export async function setSessionModel(id: string, model: string, provider: string): Promise<boolean> {
export async function setSessionModel(id: string, model: string, provider: string, apiMode?: ProviderApiMode): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/model`, {
method: 'POST',
body: JSON.stringify({ model, provider }),
body: JSON.stringify({ model, provider, apiMode }),
})
return true
} catch {
@@ -310,10 +310,6 @@ const activeSessionModelLabel = computed(() => {
return appStore.displayModelName(session.model, session.provider);
});
const isActiveSessionCodingAgent = computed(() =>
chatStore.activeSession?.source === "coding_agent",
);
const headerTitle = computed(() =>
currentMode.value === "live"
? t("chat.liveSessions")
@@ -718,7 +714,7 @@ const contextMenuOptions = computed(() => {
{ label: t("chat.rename"), key: "rename" },
{ label: t("chat.setWorkspace"), key: "workspace" }]
if (contextSession.value?.source === "cli") {
if (contextSession.value?.source === "cli" || contextSession.value?.source === "coding_agent") {
options.push({ label: t("chat.setModel"), key: "model" })
}
@@ -871,6 +867,7 @@ async function handleWorkspaceConfirm() {
}
const showSessionModelModal = ref(false);
const showSessionModelModeModal = ref(false);
const sessionModelSessionId = ref<string | null>(null);
const sessionModelSearch = ref("");
const sessionModelCollapsedGroups = ref<Record<string, boolean>>({});
@@ -878,14 +875,30 @@ const sessionModelValue = ref("");
const sessionModelProvider = ref("");
const sessionModelCustomInput = ref("");
const sessionModelCustomProvider = ref("");
const sessionModelApiMode = ref<CodingAgentApiMode>("codex_responses");
const pendingSessionModelSwitch = ref<{ model: string; provider: string } | null>(null);
const sessionModelProfile = computed<string | null>(() => {
const session = chatStore.sessions.find((s) => s.id === sessionModelSessionId.value);
return session?.profile || null;
});
const sessionModelSession = computed(() =>
chatStore.sessions.find((s) => s.id === sessionModelSessionId.value) ||
(chatStore.activeSession?.id === sessionModelSessionId.value ? chatStore.activeSession : undefined),
);
const isSessionModelScopedCodingAgent = computed(() =>
sessionModelSession.value?.source === "coding_agent" &&
sessionModelSession.value?.codingAgentMode !== "global",
);
const sessionModelBaseGroups = computed(() =>
sessionModelProfile.value ? getModelGroupsForProfile(sessionModelProfile.value) : [],
sessionModelProfile.value
? getModelGroupsForProfile(sessionModelProfile.value).filter((group) => (
!isSessionModelScopedCodingAgent.value || !isCodingAgentAuthProvider(group.provider)
))
: [],
);
const sessionModelProviderOptions = computed(() =>
@@ -925,12 +938,20 @@ async function openSessionModelModal(sessionId: string) {
const session =
chatStore.sessions.find((s) => s.id === sessionId) ||
(chatStore.activeSession?.id === sessionId ? chatStore.activeSession : undefined);
const defaults = session?.profile
? getDefaultModelForProfile(session.profile)
: { provider: "", model: "" };
sessionModelSessionId.value = sessionId;
sessionModelValue.value = session?.model || defaults.model || "";
sessionModelProvider.value = session?.provider || defaults.provider || "";
const groups = sessionModelBaseGroups.value;
const providerGroup = session?.provider
? groups.find((group) => group.provider === session.provider)
: undefined;
const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0);
const defaults = {
provider: fallbackGroup?.provider || "",
model: fallbackGroup?.models.includes(session?.model || "")
? session?.model || ""
: fallbackGroup?.models[0] || "",
};
sessionModelValue.value = providerGroup ? session?.model || defaults.model || "" : defaults.model || "";
sessionModelProvider.value = providerGroup ? session?.provider || "" : defaults.provider || "";
sessionModelCustomProvider.value = sessionModelProvider.value;
sessionModelSearch.value = "";
sessionModelCustomInput.value = "";
@@ -944,7 +965,6 @@ function handleHeaderModelClick() {
openNewChatModal();
return;
}
if (isActiveSessionCodingAgent.value) return;
openSessionModelModal(sessionId);
}
@@ -968,13 +988,25 @@ function sessionModelAlias(model: string, provider: string) {
return appStore.getModelAlias(model, provider);
}
async function selectSessionModel(model: string, provider: string) {
const meta = sessionModelBaseGroups.value.find((group) => group.provider === provider)?.model_meta?.[model];
if (meta?.disabled || !sessionModelSessionId.value) return;
const ok = await chatStore.switchSessionModel(model, provider, sessionModelSessionId.value);
function defaultSessionModelApiMode(provider: string): CodingAgentApiMode {
const group = sessionModelBaseGroups.value.find((item) => item.provider === provider);
const providerKey = String(group?.provider || provider || "").toLowerCase();
const baseUrl = String(group?.base_url || "").toLowerCase();
return normalizeCodingAgentApiMode(
group?.api_mode,
inferCodingAgentApiMode(providerKey, baseUrl),
);
}
async function applySessionModelSwitch(model: string, provider: string, apiMode?: CodingAgentApiMode) {
if (!sessionModelSessionId.value) return;
const ok = await chatStore.switchSessionModel(model, provider, sessionModelSessionId.value, apiMode);
if (ok) {
sessionModelValue.value = model;
sessionModelProvider.value = provider;
if (apiMode) sessionModelApiMode.value = apiMode;
pendingSessionModelSwitch.value = null;
showSessionModelModeModal.value = false;
showSessionModelModal.value = false;
message.success(t("chat.modelSet"));
} else {
@@ -982,6 +1014,29 @@ async function selectSessionModel(model: string, provider: string) {
}
}
async function selectSessionModel(model: string, provider: string) {
const meta = sessionModelBaseGroups.value.find((group) => group.provider === provider)?.model_meta?.[model];
if (meta?.disabled || !sessionModelSessionId.value) return;
if (isSessionModelScopedCodingAgent.value) {
pendingSessionModelSwitch.value = { model, provider };
sessionModelApiMode.value = defaultSessionModelApiMode(provider);
showSessionModelModeModal.value = true;
return;
}
await applySessionModelSwitch(model, provider);
}
async function confirmSessionModelMode() {
const pending = pendingSessionModelSwitch.value;
if (!pending) return;
await applySessionModelSwitch(pending.model, pending.provider, sessionModelApiMode.value);
}
function cancelSessionModelMode() {
pendingSessionModelSwitch.value = null;
showSessionModelModeModal.value = false;
}
async function handleSessionModelCustomSubmit() {
const model = sessionModelCustomInput.value.trim();
const provider = sessionModelCustomProvider.value;
@@ -1344,6 +1399,27 @@ async function handleSessionModelCustomSubmit() {
</div>
</NModal>
<NModal
v-model:show="showSessionModelModeModal"
preset="dialog"
:title="t('codingAgents.protocolScope')"
:mask-closable="true"
style="width: min(420px, calc(100vw - 32px))"
>
<NSelect
v-model:value="sessionModelApiMode"
:options="newChatApiModeOptions"
/>
<template #action>
<NButton size="small" @click="cancelSessionModelMode">
{{ t('common.cancel') }}
</NButton>
<NButton size="small" type="primary" @click="confirmSessionModelMode">
{{ t('common.confirm') }}
</NButton>
</template>
</NModal>
<NDrawer
v-model:show="showNewChatModal"
class="new-chat-drawer"
@@ -1567,7 +1643,6 @@ async function handleSessionModelCustomSubmit() {
</NTooltip>
<NButton
class="header-model-button"
:class="{ 'header-model-button--readonly': isActiveSessionCodingAgent }"
size="small"
:circle="isMobile"
:title="activeSessionModelLabel"
@@ -2301,15 +2376,6 @@ async function handleSessionModelCustomSubmit() {
max-width: 220px;
}
.header-model-button--readonly {
cursor: default;
}
.header-model-button--readonly :deep(.n-button__content),
.header-model-button--readonly :deep(.n-button__icon) {
cursor: default;
}
.header-model-button :deep(.n-button__content) {
min-width: 0;
overflow: hidden;
+22 -6
View File
@@ -542,6 +542,12 @@ function isCodingAgentLikeSession(session?: Pick<Session, 'source' | 'agent' | '
session?.agent === 'codex'
}
function clearCodingAgentRuntimeCredentials(session?: Session | null) {
if (!session || !isCodingAgentLikeSession(session)) return
session.baseUrl = undefined
session.apiKey = undefined
}
function isQuotaExceededError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
const e = error as { name?: string, code?: number }
@@ -1230,19 +1236,29 @@ export const useChatStore = defineStore('chat', () => {
return session
}
async function switchSessionModel(modelId: string, provider?: string, sessionId?: string): Promise<boolean> {
async function switchSessionModel(modelId: string, provider?: string, sessionId?: string, apiMode?: ProviderApiMode): Promise<boolean> {
const targetId = sessionId || activeSession.value?.id
if (!targetId) return false
const ok = await setSessionModel(targetId, modelId, provider || '')
if (!ok) 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 ok = await setSessionModel(targetId, modelId, provider || '', apiMode)
if (!ok) return false
if (target) {
target.model = modelId
target.provider = provider || ''
if (apiMode) target.apiMode = apiMode
if (shouldClearRuntimeCredentials) clearCodingAgentRuntimeCredentials(target)
}
if (activeSession.value?.id === targetId) {
activeSession.value.model = modelId
activeSession.value.provider = provider || ''
if (activeTarget) {
activeTarget.model = modelId
activeTarget.provider = provider || ''
if (apiMode) activeTarget.apiMode = apiMode
if (shouldClearRuntimeCredentials) clearCodingAgentRuntimeCredentials(activeTarget)
}
return true
}
@@ -25,6 +25,7 @@ import { listUserProfiles } from '../../db/hermes/users-store'
import { readConfigYamlForProfile } from '../../services/config-helpers'
import { codingAgentRunManager } from '../../services/agent-runner/coding-agent-run-manager'
import { AgentBridgeClient, getAgentBridgeManager } from '../../services/hermes/agent-bridge'
import { ensureHermesRunWorkspace } from '../../services/hermes/run-chat/workspace'
function getPendingDeletedSessionIds(): Set<string> {
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
@@ -802,13 +803,28 @@ export async function setModel(ctx: any) {
const existing = getSession(id)
if (denySessionAccess(ctx, existing)) return
const profile = existing?.profile || requestedProfile(ctx) || 'default'
if (!existing) {
createSession({ id, profile, title: '' })
}
const cleanModel = model.trim()
const cleanProvider = (provider || '').trim()
updateSession(id, { model: cleanModel, provider: cleanProvider } as any)
await notifyBridgeSessionModelChanged(id, cleanModel, cleanProvider, profile)
const codingAgentSession = isCodingAgentSession(existing)
const workspace = !codingAgentSession
? await ensureHermesRunWorkspace(profile, existing?.workspace)
: undefined
if (!existing) {
createSession({ id, profile, title: '', model: cleanModel, provider: cleanProvider, workspace })
}
const updates: Record<string, string> = { model: cleanModel, provider: cleanProvider }
if (!codingAgentSession && existing && !existing.workspace && workspace) updates.workspace = workspace
if (
codingAgentSession &&
existing &&
(existing.model !== cleanModel || existing.provider !== cleanProvider)
) {
updates.agent_native_session_id = ''
}
updateSession(id, updates as any)
if (!codingAgentSession) {
await notifyBridgeSessionModelChanged(id, cleanModel, cleanProvider, profile)
}
ctx.body = { ok: true }
}
+37 -4
View File
@@ -454,6 +454,30 @@ function inferLaunchApiMode(provider: string, baseUrl: string, fallback: ApiMode
return fallback
}
function providerPresetHost(value?: string): string {
const url = String(value || '').trim()
if (!url) return ''
try {
return new URL(url).hostname.toLowerCase()
} catch {
return ''
}
}
function belongsToDifferentBuiltinProvider(provider: string, baseUrl: string): boolean {
const providerKey = providerKeyWithoutCustomPrefix(String(provider || '').trim().toLowerCase())
if (!providerKey || provider !== providerKey) return false
const currentPreset = PROVIDER_PRESETS.find(item => item.value === providerKey)
if (!currentPreset) return false
const inputHost = providerPresetHost(baseUrl)
const currentHost = providerPresetHost(currentPreset.base_url)
if (!inputHost || !currentHost || inputHost === currentHost) return false
return PROVIDER_PRESETS.some((item) => (
item.value !== providerKey &&
providerPresetHost(item.base_url) === inputHost
))
}
function isScopedCodingAgentAuthProvider(provider: string, apiKey = ''): boolean {
const providerKey = String(provider || '').trim().toLowerCase()
return CODING_AGENT_SCOPED_AUTH_PROVIDERS.has(providerKey)
@@ -476,13 +500,19 @@ async function resolveStoredProviderLaunchInput(
const profile = String(input.profile || existingSession?.profile || 'default').trim() || 'default'
const provider = String(input.provider || existingSession?.provider || '').trim()
const model = String(input.model || existingSession?.model || '').trim()
const workspace = input.workspace || existingSession?.workspace || undefined
let baseUrl = String(input.baseUrl || '').trim()
let apiKey = String(input.apiKey || '').trim()
let apiMode = input.apiMode
let canonicalProvider = provider
const ignoredStaleProviderRuntime = belongsToDifferentBuiltinProvider(provider, baseUrl)
if (ignoredStaleProviderRuntime) {
baseUrl = ''
apiKey = ''
}
if (!provider || (baseUrl && apiKey && apiMode)) {
return { ...input, profile, provider: provider || input.provider, model: model || input.model, baseUrl, apiKey, apiMode }
return { ...input, profile, provider: provider || input.provider, model: model || input.model, workspace, baseUrl, apiKey, apiMode }
}
let config: Record<string, any> = {}
@@ -538,8 +568,9 @@ async function resolveStoredProviderLaunchInput(
profile,
provider: canonicalProvider,
model: model || input.model,
baseUrl: baseUrl || input.baseUrl,
apiKey: apiKey || input.apiKey,
workspace,
baseUrl: baseUrl || (ignoredStaleProviderRuntime ? '' : input.baseUrl),
apiKey: apiKey || (ignoredStaleProviderRuntime ? '' : input.apiKey),
apiMode,
}
}
@@ -1719,7 +1750,9 @@ export async function startCodingAgentRun(
const agentSessionId = resolvedInput.agentSessionId || existingAgentSessionId || makeAgentSessionId()
const canResumeNativeSession = existingSession
? storedCodingAgentMode(existingSession) === requestedMode &&
(existingSession.agent === (id === 'codex' ? 'codex' : 'claude') || !existingSession.agent)
(existingSession.agent === (id === 'codex' ? 'codex' : 'claude') || !existingSession.agent) &&
String(existingSession.provider || '').trim() === String(resolvedInput.provider || '').trim() &&
String(existingSession.model || '').trim() === String(resolvedInput.model || '').trim()
: false
const existingNativeSessionId = canResumeNativeSession ? existingSession?.agent_native_session_id || '' : ''
const agentNativeSessionId = resolvedInput.agentNativeSessionId || existingNativeSessionId || (id === 'claude-code' ? randomUUID() : '')
@@ -33,6 +33,7 @@ import { filterBridgeToolCallMarkupDelta, flushPendingToolCallMarkup } from './b
import { markAbortCompleted } from './abort'
import { writeModelRunProfileToken } from './model-run-prompt'
import type { AuthenticatedUser } from '../../../middleware/user-auth'
import { ensureHermesRunWorkspace } from './workspace'
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
const BRIDGE_TITLE_EVENT_POLL_INTERVAL_MS = 500
@@ -316,7 +317,8 @@ export async function handleBridgeRun(
? `${getSystemPrompt()}\n${instructions}`
: getSystemPrompt()
const sessionRow = getSession(session_id)
const workspace = sessionRow?.workspace || String(data.workspace || '').trim()
const workspace = await ensureHermesRunWorkspace(profile, sessionRow?.workspace || data.workspace)
if (sessionRow && !sessionRow.workspace) updateSession(session_id, { workspace })
const sessionModel = sessionRow?.model || ''
const sessionProvider = sessionRow?.provider || ''
const { model: resolvedModel, provider: resolvedProvider } = await resolveBridgeRunModelConfig({
@@ -396,7 +398,7 @@ export async function handleBridgeRun(
if (!getSession(session_id)) {
const previewText = extractTextForPreview(displayInput || input)
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, source: runSource, model: resolvedModel, provider: resolvedProvider, title: preview, workspace: data.workspace || undefined })
createSession({ id: session_id, profile, source: runSource, model: resolvedModel, provider: resolvedProvider, title: preview, workspace })
}
messageId = addMessage({
session_id,
@@ -409,7 +411,7 @@ export async function handleBridgeRun(
} else if (!getSession(session_id)) {
const previewText = displayInput === null ? extractTextForPreview(input) : extractTextForPreview(displayInput || input)
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, source: runSource, model: resolvedModel, provider: resolvedProvider, title: preview, workspace: data.workspace || undefined })
createSession({ id: session_id, profile, source: runSource, model: resolvedModel, provider: resolvedProvider, title: preview, workspace })
}
socket.join(`session:${session_id}`)
@@ -11,6 +11,7 @@ import type { ContentBlock, SessionState } from './types'
import { writeModelRunProfileToken } from './model-run-prompt'
import type { AuthenticatedUser } from '../../../middleware/user-auth'
import { getSystemPrompt } from '../../../lib/llm-prompt'
import { getSession } from '../../../db/hermes/session-store'
export interface CodingAgentRunSocketData {
input: string | ContentBlock[]
@@ -57,11 +58,14 @@ export async function handleCodingAgentRun(
let runId = codingAgentRunManager.runIdForSession(sessionId)
const mode = data.mode === 'global' ? 'global' : 'scoped'
const storedSession = getSession(sessionId)
const launchProvider = data.provider || (mode === 'scoped' ? storedSession?.provider || undefined : undefined)
const launchModel = data.model || (mode === 'scoped' ? storedSession?.model || undefined : undefined)
if (runId && !codingAgentRunManager.isSessionLaunchCompatible(sessionId, {
agentId,
mode,
provider: data.provider,
model: data.model,
provider: launchProvider,
model: launchModel,
})) {
codingAgentRunManager.stop(sessionId, { reportClosed: false })
runId = undefined
@@ -71,8 +75,8 @@ export async function handleCodingAgentRun(
sessionId,
mode,
profile,
provider: data.provider,
model: data.model,
provider: launchProvider,
model: launchModel,
workspace: data.workspace,
baseUrl: data.baseUrl || data.base_url,
apiKey: data.apiKey || data.api_key,
@@ -25,6 +25,7 @@ import { contentBlocksToString } from './content-blocks'
import type { ContentBlock, QueuedRun, SessionState } from './types'
import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth'
import { userCanAccessProfile } from '../../../db/hermes/users-store'
import { ensureHermesRunWorkspace } from './workspace'
export type { ContentBlock } from './types'
@@ -425,7 +426,7 @@ export class ChatRunSocket {
: getSystemPrompt()
if (data.session_id) {
const sessionRow = getSession(data.session_id)
const workspace = sessionRow?.workspace || String(data.workspace || '').trim()
const workspace = await ensureHermesRunWorkspace(profile, sessionRow?.workspace || data.workspace)
if (workspace) {
const workspaceCtx = `[Current working directory: ${workspace}]`
fullInstructions = `\n${workspaceCtx}\n${fullInstructions}`
@@ -0,0 +1,13 @@
import { mkdir } from 'fs/promises'
import { join } from 'path'
import { getProfileDir } from '../hermes-profile'
export function defaultHermesWorkspace(profile: string): string {
return join(getProfileDir(profile || 'default'), 'workspace')
}
export async function ensureHermesRunWorkspace(profile: string, workspace?: string | null): Promise<string> {
const resolved = String(workspace || '').trim() || defaultHermesWorkspace(profile)
await mkdir(resolved, { recursive: true })
return resolved
}
@@ -8,4 +8,17 @@ describe('ChatPanel session clicks', () => {
expect(source).toContain('if (chatStore.activeSessionId !== sessionId)')
expect(source).toContain('await chatStore.switchSession(sessionId)')
})
it('allows session model switching for coding agent sessions', () => {
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).not.toContain('header-model-button--readonly')
expect(source).not.toContain('if (isActiveSessionCodingAgent.value) return')
})
})
@@ -77,6 +77,7 @@ describe('chat store reasoning/tool boundaries', () => {
setActivePinia(createPinia())
chatApi.startRunViaSocket.mockReturnValue({ abort: vi.fn() })
sessionsApi.deleteSession.mockResolvedValue(true)
sessionsApi.setSessionModel.mockResolvedValue(true)
})
it('merges reasoning across tool cycles without appending post-tool text before the tool', async () => {
@@ -323,6 +324,38 @@ describe('chat store reasoning/tool boundaries', () => {
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('sends the selected workspace when starting a coding-agent run', async () => {
const store = useChatStore()
const session = makeSession()
@@ -132,6 +132,79 @@ describe('coding agent resumed session config', () => {
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({
@@ -9,6 +9,7 @@ const startCodingAgentRunMock = vi.hoisted(() => vi.fn())
const sendCodingAgentRunInputMock = vi.hoisted(() => vi.fn())
const writeModelRunProfileTokenMock = vi.hoisted(() => vi.fn(async () => undefined))
const getSystemPromptMock = vi.hoisted(() => vi.fn(() => 'system prompt'))
const getSessionMock = vi.hoisted(() => vi.fn())
vi.mock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({
codingAgentRunManager: managerMock,
@@ -27,9 +28,14 @@ vi.mock('../../packages/server/src/lib/llm-prompt', () => ({
getSystemPrompt: getSystemPromptMock,
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: getSessionMock,
}))
describe('handleCodingAgentRun', () => {
beforeEach(() => {
vi.clearAllMocks()
getSessionMock.mockReturnValue(null)
writeModelRunProfileTokenMock.mockResolvedValue(undefined)
getSystemPromptMock.mockReturnValue('system prompt')
})
@@ -76,6 +82,55 @@ describe('handleCodingAgentRun', () => {
expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'use global codex', 'system prompt')
})
it('restarts an existing scoped runner when the stored session model changed even if the socket payload omits it', async () => {
managerMock.runIdForSession.mockReturnValue('agent-session-1')
managerMock.isSessionLaunchCompatible.mockReturnValue(false)
getSessionMock.mockReturnValue({
id: 'session-1',
source: 'coding_agent',
agent: 'codex',
provider: 'deepseek',
model: 'deepseek-v4-pro',
})
startCodingAgentRunMock.mockResolvedValue({ agentSessionId: 'agent-session-2' })
sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-2' })
const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run')
const state = {
messages: [],
isWorking: false,
isAborting: false,
events: [],
queue: [],
}
const sessionMap = new Map([['session-1', state]])
const socket = {
join: vi.fn(),
emit: vi.fn(),
}
await handleCodingAgentRun({} as any, socket as any, {
session_id: 'session-1',
input: 'continue with new model',
coding_agent_id: 'codex',
mode: 'scoped',
}, 'default', sessionMap as any)
expect(managerMock.isSessionLaunchCompatible).toHaveBeenCalledWith('session-1', {
agentId: 'codex',
mode: 'scoped',
provider: 'deepseek',
model: 'deepseek-v4-pro',
})
expect(managerMock.stop).toHaveBeenCalledWith('session-1', { reportClosed: false })
expect(startCodingAgentRunMock).toHaveBeenCalledWith('codex', expect.objectContaining({
sessionId: 'session-1',
mode: 'scoped',
profile: 'default',
}), state)
expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'continue with new model', 'system prompt')
})
it('passes global session source through to the coding-agent runner', async () => {
managerMock.runIdForSession.mockReturnValue(undefined)
managerMock.isSessionLaunchCompatible.mockReturnValue(true)
@@ -92,6 +92,10 @@ vi.mock('../../packages/server/src/services/hermes/run-chat/model-config', () =>
resolveBridgeRunModelConfig: resolveBridgeRunModelConfigMock,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getProfileDir: (profile: string) => `/tmp/hermes-bridge-final-context/${profile || 'default'}`,
}))
vi.mock('../../packages/server/src/middleware/user-auth', () => ({
issueModelRunJwt: issueModelRunJwtMock,
}))
@@ -356,6 +360,7 @@ describe('bridge run final context usage', () => {
expect(createSessionMock).toHaveBeenCalledWith(expect.objectContaining({
id: 'session-1',
source: 'global_agent',
workspace: '/tmp/hermes-bridge-final-context/default/workspace',
}))
expect(state.source).toBe('global_agent')
})
+42 -2
View File
@@ -101,6 +101,7 @@ vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: getActiveProfileNameMock,
getProfileDir: (name: string) => `/tmp/hermes-test/${name || 'default'}`,
listProfileNamesFromDisk: () => ['default', 'travel'],
}))
@@ -838,7 +839,11 @@ describe('session conversations controller', () => {
await mod.setModel(ctx)
expect(localCreateSessionMock).not.toHaveBeenCalled()
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', { model: 'grok-4', provider: 'xai' })
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
model: 'grok-4',
provider: 'xai',
workspace: '/tmp/hermes-test/default/workspace',
})
expect(bridgeSwitchSessionModelMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ ok: true })
})
@@ -863,7 +868,11 @@ describe('session conversations controller', () => {
}
await mod.setModel(ctx)
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', { model: 'claude-sonnet-4-6', provider: 'claude-oauth' })
expect(localUpdateSessionMock).toHaveBeenCalledWith('session-1', {
model: 'claude-sonnet-4-6',
provider: 'claude-oauth',
workspace: '/tmp/hermes-test/travel/workspace',
})
expect(bridgeSwitchSessionModelMock).toHaveBeenCalledWith(
'session-1',
'claude-sonnet-4-6',
@@ -873,6 +882,37 @@ describe('session conversations controller', () => {
expect(ctx.body).toEqual({ ok: true })
})
it('stores a coding agent session model without stopping the runner or notifying the Hermes bridge', async () => {
bridgeGetRuntimeStateMock.mockReturnValue({ ready: true, running: true, endpoint: 'ipc:///tmp/hermes-agent-bridge.sock' })
getSessionMock.mockReturnValue({
id: 'codex-session',
profile: 'default',
source: 'coding_agent',
agent: 'codex',
model: 'old-model',
provider: 'openrouter',
agent_native_session_id: 'old-native-thread',
workspace: '/tmp/original-workspace',
})
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = {
params: { id: 'codex-session' },
request: { body: { model: 'gpt-5.5', provider: 'openai-codex' } },
body: null,
}
await mod.setModel(ctx)
expect(localUpdateSessionMock).toHaveBeenCalledWith('codex-session', {
model: 'gpt-5.5',
provider: 'openai-codex',
agent_native_session_id: '',
})
expect(codingAgentRunManagerMock.stop).not.toHaveBeenCalled()
expect(bridgeSwitchSessionModelMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ ok: true })
})
it('deletes a current-profile Hermes history session even when no local Web UI session exists', async () => {
getActiveProfileNameMock.mockReturnValue('travel')
getSessionMock.mockReturnValue(null)