diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 92888b4780df..30c2d64c6158 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -127,11 +127,33 @@ describe('useModelControls', () => { expect(requestGateway).toHaveBeenCalledWith('config.set', { session_id: 'session-1', key: 'model', - value: 'claude-sonnet-4.6 --provider anthropic' + value: 'claude-sonnet-4.6 --provider anthropic --session' }) expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) + it('keeps an active-session picker change session-scoped, never persisting to global config', async () => { + const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) + let controls!: Controls + + render( + (controls = value)} requestGateway={requestGateway} /> + ) + + await controls.selectModel({ model: 'claude-sonnet-4.6', provider: 'anthropic' }) + + // Regression: the switch MUST carry --session so parse_model_flags() scopes + // it to the session instead of writing the profile default in config.yaml. + expect(requestGateway).toHaveBeenCalledWith( + 'config.set', + expect.objectContaining({ value: expect.stringContaining('--session') }) + ) + expect(requestGateway).toHaveBeenCalledWith( + 'config.set', + expect.objectContaining({ value: expect.not.stringContaining('--global') }) + ) + }) + it('stores a no-session pick as UI state with no gateway or global write', async () => { const requestGateway = vi.fn() let controls!: Controls diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index dba30dd8d140..b302a5fa4554 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -96,7 +96,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway await requestGateway('config.set', { session_id: activeSessionId, key: 'model', - value: `${selection.model} --provider ${selection.provider}` + value: `${selection.model} --provider ${selection.provider} --session` }) void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] }) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index ca1af4cd9abe..d2862660bb56 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -192,7 +192,7 @@ describe('createSlashHandler', () => { expect(ctx.gateway.rpc).not.toHaveBeenCalled() }) - it('honors TUI picker session scope without adding --global', async () => { + it('rewrites the TUI picker sentinel to --session so the switch stays session-scoped', async () => { patchUiState({ sid: 'sid-abc' }) const ctx = buildCtx({ @@ -209,10 +209,38 @@ describe('createSlashHandler', () => { confirm_expensive_model: false, key: 'model', session_id: 'sid-abc', - value: 'anthropic/claude-sonnet-4.6 --provider openrouter' + value: 'anthropic/claude-sonnet-4.6 --provider openrouter --session' }) }) + it('never forwards the raw TUI sentinel and scopes the pick to the session', async () => { + patchUiState({ sid: 'sid-abc' }) + + const ctx = buildCtx({ + gateway: { + ...buildGateway(), + rpc: vi.fn(() => Promise.resolve({ value: 'anthropic/claude-sonnet-4.6' })) + } + }) + + createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`) + + // Regression: the UI-only sentinel must be rewritten, and the result must be + // session-scoped — not leaked to the gateway and not persisted globally. + expect(ctx.gateway.rpc).toHaveBeenCalledWith( + 'config.set', + expect.objectContaining({ value: expect.not.stringContaining(TUI_SESSION_MODEL_FLAG) }) + ) + expect(ctx.gateway.rpc).toHaveBeenCalledWith( + 'config.set', + expect.objectContaining({ value: expect.stringContaining('--session') }) + ) + expect(ctx.gateway.rpc).toHaveBeenCalledWith( + 'config.set', + expect.objectContaining({ value: expect.not.stringContaining('--global') }) + ) + }) + it('does not duplicate --global for explicit persistent model switches', () => { patchUiState({ sid: 'sid-abc' }) const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 6b1fe55481bc..35eb3c36aa48 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -32,7 +32,10 @@ const modelValueForConfigSet = (arg: string) => { } if (TUI_SESSION_MODEL_RE.test(trimmed)) { - return stripTuiSessionFlag(trimmed) + // Convert --tui-session → --session so the gateway's parse_model_flags() + // recognises the intent as session-scoped instead of persisting globally. + const stripped = stripTuiSessionFlag(trimmed) + return `${stripped} --session`.trim() } return trimmed