From 9349d11a0783e6a02d2be754dcd3f09976eae358 Mon Sep 17 00:00:00 2001 From: jesus alberto cornelio <365diascollaboration@gmail.com> Date: Wed, 17 Jun 2026 08:01:07 -0400 Subject: [PATCH 1/5] fix: model picker in chat no longer overwrites the global default model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a model from the bottom-panel picker in a chat session previously called setModelConfig(), which wrote to config.yaml and permanently changed the user's global default — even though the intent was only to override the model for that conversation. The fix separates session-scoped model selection from persistent (settings) selection: - `useModelConfig.selectModel` accepts a new `{ persist?: boolean }` option (default true). When `persist: false` the local React state is updated immediately but `setModelConfig` / `getModelConfig` IPC calls are skipped, so config.yaml stays untouched. - The chat-screen picker now calls `selectModel(..., { persist: false })`, while the Settings screen retains the existing persisting behaviour. - A `modelOverride` parameter is threaded through the full send pipeline (`useChatActions` → IPC preload → IPC handler → hermes.ts export → `sendMessageViaBestApi*` → `sendMessageViaNonGatewayApi` → `sendMessageViaRuns` / `sendMessageViaApi`, and the CLI fallback `sendMessageViaCli`) so the gateway receives the session model on every message instead of re-reading the now-unchanged config.yaml value. Fixes #688. --- src/main/hermes.ts | 28 +++++++++++++++---- src/main/index.ts | 2 ++ src/preload/index.ts | 2 ++ src/renderer/src/screens/Chat/Chat.tsx | 7 ++++- .../src/screens/Chat/hooks/useChatActions.ts | 7 +++++ .../src/screens/Chat/hooks/useModelConfig.ts | 13 +++++++-- 6 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 6f618af06..23d11586e 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -1109,6 +1109,7 @@ function sendMessageViaApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): ChatHandle { const mc = getModelConfig(profile); const controller = new AbortController(); @@ -1138,7 +1139,7 @@ function sendMessageViaApi( const reasoningEffort = reasoningEffortForProfile(profile); const bodyObj: Record = { - model: mc.model || "hermes-agent", + model: modelOverride || mc.model || "hermes-agent", messages, stream: true, ...(_resumeSessionId ? { session_id: _resumeSessionId } : {}), @@ -1224,7 +1225,7 @@ function sendMessageViaApi( function probeRealError(): void { // When streaming returns empty, make a non-streaming request to surface the real error const probeBodyObj: Record = { - model: mc.model || "hermes-agent", + model: modelOverride || mc.model || "hermes-agent", messages: [{ role: "user", content: userContent }], stream: false, }; @@ -1513,6 +1514,7 @@ function sendMessageViaRuns( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): ChatHandle { const mc = getModelConfig(profile); const controller = new AbortController(); @@ -1523,7 +1525,7 @@ function sendMessageViaRuns( (headersForAuth.Authorization ? `desk-${Date.now()}-${randomUUID()}` : ""); const ctxSystem = contextFolderSystemMessage(contextFolder); const bodyObj: Record = { - model: mc.model || "hermes-agent", + model: modelOverride || mc.model || "hermes-agent", input: message, conversation_history: apiHistory(history), }; @@ -2077,6 +2079,7 @@ function sendMessageViaCli( profile?: string, resumeSessionId?: string, attachments?: Attachment[], + modelOverride?: string, ): ChatHandle { // CLI fallback can't pipe multimodal content; inline text-file attachments // and ignore images. The gateway is the supported attachment path; this @@ -2108,8 +2111,8 @@ function sendMessageViaCli( args.push("--resume", resumeSessionId); } - if (mc.model) { - args.push("-m", mc.model); + if (modelOverride || mc.model) { + args.push("-m", modelOverride || mc.model); } const cliProvider = CLI_COMPAT_PROVIDER_OVERRIDE[mc.provider]; @@ -2404,6 +2407,7 @@ async function sendMessageViaNonGatewayApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); if (!attachments?.length && !approvalCommand) { @@ -2417,6 +2421,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, + modelOverride, ); } } @@ -2429,6 +2434,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, + modelOverride, ); } @@ -2440,6 +2446,7 @@ async function sendMessageViaBestApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); if ( @@ -2473,6 +2480,7 @@ async function sendMessageViaBestApi( history, attachments, contextFolder, + modelOverride, ); } @@ -2484,6 +2492,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { let aborted = false; let retrying = false; @@ -2528,6 +2537,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, + modelOverride, ); return; } @@ -2538,6 +2548,7 @@ async function sendMessageViaBestApiWithLocalRecovery( profile, resumeSessionId, attachments, + modelOverride, ); }; @@ -2615,6 +2626,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, + modelOverride, ); return handle; @@ -2628,6 +2640,7 @@ export async function sendMessage( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, + modelOverride?: string, ): Promise { ensureInitialized(); @@ -2641,6 +2654,7 @@ export async function sendMessage( history, attachments, contextFolder, + modelOverride, ); } @@ -2652,6 +2666,7 @@ export async function sendMessage( profile, resumeSessionId, attachments, + modelOverride, ); } @@ -2675,11 +2690,12 @@ export async function sendMessage( history, attachments, contextFolder, + modelOverride, ); } // Fallback to CLI - return sendMessageViaCli(message, cb, profile, resumeSessionId, attachments); + return sendMessageViaCli(message, cb, profile, resumeSessionId, attachments, modelOverride); } // Lazy init — called on first sendMessage or gateway start diff --git a/src/main/index.ts b/src/main/index.ts index ba3ffc756..f1ce04fba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1247,6 +1247,7 @@ function setupIPC(): void { attachments?: Attachment[], contextFolder?: string, runId?: string, + modelOverride?: string, ) => { // Each conversation has a stable runId minted by the renderer. Fall back // to a generated id for legacy callers so the run is still tracked. @@ -1391,6 +1392,7 @@ function setupIPC(): void { history, attachments, contextFolder, + modelOverride, ); activeRuns.set(chatRunId, handle.abort); diff --git a/src/preload/index.ts b/src/preload/index.ts index 2b0a8e7c9..b9e2eb908 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -367,6 +367,7 @@ const hermesAPI = { attachments?: Attachment[], contextFolder?: string, runId?: string, + modelOverride?: string, ): Promise<{ response: string; sessionId?: string }> => ipcRenderer.invoke( "send-message", @@ -377,6 +378,7 @@ const hermesAPI = { attachments, contextFolder, runId, + modelOverride, ), abortChat: (runId?: string): Promise => diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index eed0a00b4..8b40c760d 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -418,6 +418,7 @@ function Chat({ localCommands, activeTurnRef, contextFolder, + sessionModel: modelConfig.currentModel || undefined, sendViaDashboard: dashboardTransport.enabled ? dashboardTransport.sendMessage : undefined, @@ -595,7 +596,11 @@ function Chat({ modelGroups={modelConfig.modelGroups} displayModel={modelConfig.displayModel} onOpen={modelConfig.reload} - onSelectModel={modelConfig.selectModel} + onSelectModel={(provider, model, baseUrl) => + void modelConfig.selectModel(provider, model, baseUrl, { + persist: false, + }) + } /> ; /** Working folder bound to this conversation (issue #27), or null. */ contextFolder: string | null; + /** Session-local model override — selected via the chat picker without + * persisting to config.yaml (issue #688). */ + sessionModel?: string; sendViaDashboard?: ( text: string, attachments?: Attachment[], @@ -62,14 +65,17 @@ export function useChatActions({ localCommands, activeTurnRef, contextFolder, + sessionModel, sendViaDashboard, abortDashboard, }: UseChatActionsArgs): UseChatActionsResult { const messagesRef = useRef(messages); const isLoadingRef = useRef(isLoading); + const sessionModelRef = useRef(sessionModel); useEffect(() => { messagesRef.current = messages; isLoadingRef.current = isLoading; + sessionModelRef.current = sessionModel; }); const pushUser = useCallback( @@ -108,6 +114,7 @@ export function useChatActions({ attachments, contextFolder ?? undefined, runId, + sessionModelRef.current || undefined, ); } catch { // onChatError IPC already surfaces this to the user diff --git a/src/renderer/src/screens/Chat/hooks/useModelConfig.ts b/src/renderer/src/screens/Chat/hooks/useModelConfig.ts index b5502185e..c86e4b8ae 100644 --- a/src/renderer/src/screens/Chat/hooks/useModelConfig.ts +++ b/src/renderer/src/screens/Chat/hooks/useModelConfig.ts @@ -49,6 +49,7 @@ interface UseModelConfigResult { provider: string, model: string, baseUrl: string, + options?: { persist?: boolean }, ) => Promise; } @@ -134,8 +135,12 @@ export function useModelConfig(profile?: string): UseModelConfigResult { }, [reload]); const selectModel = useCallback( - async (provider: string, model: string, baseUrl: string): Promise => { - const seq = ++loadSeqRef.current; + async ( + provider: string, + model: string, + baseUrl: string, + { persist = true }: { persist?: boolean } = {}, + ): Promise => { // Named providers (deepseek, groq, anthropic, …) have a hardcoded // canonical base_url in `hermes-agent`'s PROVIDER_REGISTRY. A stored // model entry that carries a stale `baseUrl` from an earlier confused @@ -148,6 +153,10 @@ export function useModelConfig(profile?: string): UseModelConfigResult { setCurrentModel(model); setCurrentProvider(provider); setCurrentBaseUrl(effectiveBaseUrl); + // Session-only selection: update local state only, do not write to + // config.yaml so the global default model is preserved (issue #688). + if (!persist) return; + const seq = ++loadSeqRef.current; try { await window.hermesAPI.setModelConfig( provider, From 0642f380fb7822249e4807ba9181250099599df0 Mon Sep 17 00:00:00 2001 From: jesus alberto cornelio <365diascollaboration@gmail.com> Date: Wed, 17 Jun 2026 08:07:39 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20TUI=20gateway=20bypass=20+=20reload()=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues raised in the Greptile review of #713: 1. TUI gateway silently drops modelOverride (P1) sendMessageViaBestApi tried the TUI gateway first, which reads its model from config.yaml and has no per-request override mechanism. A one-line `!modelOverride` guard now bypasses the TUI path when a session model is active, routing directly to sendMessageViaNonGatewayApi which already propagates modelOverride correctly. 2. Background reload() can clobber session-scoped model selection When selectModel is called with persist:false, loadSeqRef was not advanced, so a concurrent reload() triggered by onConnectionConfigChanged or onModelLibraryChanged could race and overwrite the in-session choice with the persisted value. Incrementing loadSeqRef on the non-persist path cancels any in-flight reload, consistent with how the persist path already handles seq races. --- src/main/hermes.ts | 6 +++++- src/renderer/src/screens/Chat/hooks/useModelConfig.ts | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 23d11586e..8c3076c6a 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -2449,11 +2449,15 @@ async function sendMessageViaBestApi( modelOverride?: string, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); + // Skip the TUI gateway when a session-scoped model override is active — the + // TUI gateway reads its model from config.yaml and has no per-request + // override mechanism. The API path below already honours modelOverride. if ( shouldUseTuiGatewayClient() && !isRemoteMode() && !attachments?.length && - !approvalCommand + !approvalCommand && + !modelOverride ) { try { return await sendMessageViaTuiGateway( diff --git a/src/renderer/src/screens/Chat/hooks/useModelConfig.ts b/src/renderer/src/screens/Chat/hooks/useModelConfig.ts index c86e4b8ae..691ba832d 100644 --- a/src/renderer/src/screens/Chat/hooks/useModelConfig.ts +++ b/src/renderer/src/screens/Chat/hooks/useModelConfig.ts @@ -155,7 +155,13 @@ export function useModelConfig(profile?: string): UseModelConfigResult { setCurrentBaseUrl(effectiveBaseUrl); // Session-only selection: update local state only, do not write to // config.yaml so the global default model is preserved (issue #688). - if (!persist) return; + // Advance the sequence counter so any in-flight reload() triggered by + // onConnectionConfigChanged / onModelLibraryChanged cannot clobber the + // session-scoped selection with the persisted value. + if (!persist) { + ++loadSeqRef.current; + return; + } const seq = ++loadSeqRef.current; try { await window.hermesAPI.setModelConfig( From b1f337596e9f4e79b8804d618662751772ec174f Mon Sep 17 00:00:00 2001 From: jesus alberto cornelio <365diascollaboration@gmail.com> Date: Wed, 17 Jun 2026 08:34:14 -0400 Subject: [PATCH 3/5] fix: add modelOverride to preload type declaration (CI typecheck) --- src/preload/index.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 0ef64e674..98f3cd213 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -368,6 +368,7 @@ interface HermesAPI { attachments?: Attachment[], contextFolder?: string, runId?: string, + modelOverride?: string, ) => Promise<{ response: string; sessionId?: string }>; abortChat: (runId?: string) => Promise; transcribeAudio: ( From d8f571a11f4e8e3b87b7853a6fb9992115e9a64a Mon Sep 17 00:00:00 2001 From: jesus alberto cornelio <365diascollaboration@gmail.com> Date: Wed, 17 Jun 2026 08:39:15 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20use=20dedicated=20sessionModelOverri?= =?UTF-8?q?de=20state=20=E2=80=94=20not=20currentModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing modelConfig.currentModel as sessionModel caused modelOverride to always be non-empty after the initial config load, permanently bypassing the TUI gateway for all users regardless of whether they had explicitly picked a session model. Use a separate sessionModelOverride state variable (undefined by default) that is only set when the user explicitly selects a model from the chat picker. The TUI gateway bypass now triggers only for sessions where the user made an explicit in-chat model change, which is the intended behaviour. --- src/renderer/src/screens/Chat/Chat.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index 8b40c760d..b97bc7208 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -117,6 +117,13 @@ function Chat({ // Whether the worktree panel is visible (only applies when contextFolder is set) // Default false so the panel doesn't open automatically and interfere with scrolling const [worktreeVisible, setWorktreeVisible] = useState(false); + // Explicit session-scoped model override — set only when the user picks + // from the chat-screen picker (persist:false). Undefined until then so the + // TUI gateway bypass in sendMessageViaBestApi is not triggered for normal + // chats where the user never changed the model (issue #688). + const [sessionModelOverride, setSessionModelOverride] = useState< + string | undefined + >(undefined); const dragCounter = useRef(0); const chatInputRef = useRef(null); const queueRef = useRef([]); @@ -418,7 +425,7 @@ function Chat({ localCommands, activeTurnRef, contextFolder, - sessionModel: modelConfig.currentModel || undefined, + sessionModel: sessionModelOverride, sendViaDashboard: dashboardTransport.enabled ? dashboardTransport.sendMessage : undefined, @@ -596,11 +603,12 @@ function Chat({ modelGroups={modelConfig.modelGroups} displayModel={modelConfig.displayModel} onOpen={modelConfig.reload} - onSelectModel={(provider, model, baseUrl) => + onSelectModel={(provider, model, baseUrl) => { void modelConfig.selectModel(provider, model, baseUrl, { persist: false, - }) - } + }); + setSessionModelOverride(model || undefined); + }} /> Date: Wed, 17 Jun 2026 08:48:08 -0400 Subject: [PATCH 5/5] fix: forward modelOverride in sendMessageViaRuns fallback The fallbackToChatCompletions closure inside sendMessageViaRuns called sendMessageViaApi without forwarding the modelOverride captured in the outer scope. If the Runs transport failed mid-flight, the session model selection was silently dropped on the fallback path. --- src/main/hermes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 8c3076c6a..8ddd8c092 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -1569,6 +1569,7 @@ function sendMessageViaRuns( history, attachments, contextFolder, + modelOverride, ); }