From af07ed00a3568e17ca513db949f5295d05a0f647 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:25:23 +0800 Subject: [PATCH] fix coding agent model switching (#1725) --- ...22-coding-agent-session-model-workspace.md | 8 ++ packages/client/src/api/hermes/sessions.ts | 5 +- .../src/components/hermes/chat/ChatPanel.vue | 118 ++++++++++++++---- packages/client/src/stores/hermes/chat.ts | 28 ++++- .../server/src/controllers/hermes/sessions.ts | 26 +++- packages/server/src/services/coding-agents.ts | 41 +++++- .../hermes/run-chat/handle-bridge-run.ts | 8 +- .../run-chat/handle-coding-agent-run.ts | 12 +- .../src/services/hermes/run-chat/index.ts | 3 +- .../src/services/hermes/run-chat/workspace.ts | 13 ++ tests/client/chat-panel-session-click.test.ts | 13 ++ ...chat-store-reasoning-tool-boundary.test.ts | 33 +++++ .../server/coding-agent-resume-config.test.ts | 73 +++++++++++ tests/server/handle-coding-agent-run.test.ts | 55 ++++++++ .../run-chat-bridge-final-context.test.ts | 5 + tests/server/sessions-controller.test.ts | 44 ++++++- 16 files changed, 432 insertions(+), 53 deletions(-) create mode 100644 docs/chat-chain-changes/2026-06-22-coding-agent-session-model-workspace.md create mode 100644 packages/server/src/services/hermes/run-chat/workspace.ts diff --git a/docs/chat-chain-changes/2026-06-22-coding-agent-session-model-workspace.md b/docs/chat-chain-changes/2026-06-22-coding-agent-session-model-workspace.md new file mode 100644 index 00000000..2f1cf7e5 --- /dev/null +++ b/docs/chat-chain-changes/2026-06-22-coding-agent-session-model-workspace.md @@ -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 切换模型时会先选择模型,再弹出协议选择,确认协议后才提交切换。 diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index 072001d8..5a9d2398 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -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 { +export async function setSessionModel(id: string, model: string, provider: string, apiMode?: ProviderApiMode): Promise { try { await request(`/api/hermes/sessions/${id}/model`, { method: 'POST', - body: JSON.stringify({ model, provider }), + body: JSON.stringify({ model, provider, apiMode }), }) return true } catch { diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 06b77429..caf56d29 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -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(null); const sessionModelSearch = ref(""); const sessionModelCollapsedGroups = ref>({}); @@ -878,14 +875,30 @@ const sessionModelValue = ref(""); const sessionModelProvider = ref(""); const sessionModelCustomInput = ref(""); const sessionModelCustomProvider = ref(""); +const sessionModelApiMode = ref("codex_responses"); +const pendingSessionModelSwitch = ref<{ model: string; provider: string } | null>(null); const sessionModelProfile = computed(() => { 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() { + + + + + { return session } - async function switchSessionModel(modelId: string, provider?: string, sessionId?: string): Promise { + async function switchSessionModel(modelId: string, provider?: string, sessionId?: string, apiMode?: ProviderApiMode): Promise { 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 } diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index d2b7d091..a47c81f1 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -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 { return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set() @@ -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 = { 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 } } diff --git a/packages/server/src/services/coding-agents.ts b/packages/server/src/services/coding-agents.ts index 1d98a278..bc659b7e 100644 --- a/packages/server/src/services/coding-agents.ts +++ b/packages/server/src/services/coding-agents.ts @@ -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 = {} @@ -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() : '') diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts index a87ce000..294a6d0b 100644 --- a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -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}`) diff --git a/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts b/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts index c9cb3fa9..6db9023a 100644 --- a/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts @@ -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, diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts index e5b98481..c2a01e42 100644 --- a/packages/server/src/services/hermes/run-chat/index.ts +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -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}` diff --git a/packages/server/src/services/hermes/run-chat/workspace.ts b/packages/server/src/services/hermes/run-chat/workspace.ts new file mode 100644 index 00000000..23f03945 --- /dev/null +++ b/packages/server/src/services/hermes/run-chat/workspace.ts @@ -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 { + const resolved = String(workspace || '').trim() || defaultHermesWorkspace(profile) + await mkdir(resolved, { recursive: true }) + return resolved +} diff --git a/tests/client/chat-panel-session-click.test.ts b/tests/client/chat-panel-session-click.test.ts index ef074467..a1e0c643 100644 --- a/tests/client/chat-panel-session-click.test.ts +++ b/tests/client/chat-panel-session-click.test.ts @@ -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') + }) }) diff --git a/tests/client/chat-store-reasoning-tool-boundary.test.ts b/tests/client/chat-store-reasoning-tool-boundary.test.ts index 5aa66b15..ec310413 100644 --- a/tests/client/chat-store-reasoning-tool-boundary.test.ts +++ b/tests/client/chat-store-reasoning-tool-boundary.test.ts @@ -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() diff --git a/tests/server/coding-agent-resume-config.test.ts b/tests/server/coding-agent-resume-config.test.ts index 46e8bae5..ed20ac77 100644 --- a/tests/server/coding-agent-resume-config.test.ts +++ b/tests/server/coding-agent-resume-config.test.ts @@ -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({ diff --git a/tests/server/handle-coding-agent-run.test.ts b/tests/server/handle-coding-agent-run.test.ts index 314e6eca..89d1d354 100644 --- a/tests/server/handle-coding-agent-run.test.ts +++ b/tests/server/handle-coding-agent-run.test.ts @@ -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) diff --git a/tests/server/run-chat-bridge-final-context.test.ts b/tests/server/run-chat-bridge-final-context.test.ts index 21dd43aa..3b3d4ca2 100644 --- a/tests/server/run-chat-bridge-final-context.test.ts +++ b/tests/server/run-chat-bridge-final-context.test.ts @@ -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') }) diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index bc53184c..836ae61a 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -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)