mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 13:40:14 +00:00
fix coding agent model switching (#1725)
This commit is contained in:
@@ -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 切换模型时会先选择模型,再弹出协议选择,确认协议后才提交切换。
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user