From 0430588e32a4d289090d71d6b0b2b1783abcba82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 6 Feb 2026 17:13:54 +0100 Subject: [PATCH] fix: hindsight-embed profiles are not loaded correctly (#316) * fix: hindsight-embed profiles are not loaded correctly * fix: hindsight-embed profiles are not loaded correctly --- hindsight-api/tests/test_config_validation.py | 109 +++++++++ hindsight-api/tests/test_mental_models.py | 14 +- hindsight-embed/hindsight_embed/cli.py | 47 ++-- .../hindsight_embed/daemon_embed_manager.py | 7 + .../hindsight_embed/profile_manager.py | 124 ++++++++-- .../tests/test_profile_daemon_config.py | 223 ++++++++++++++++++ hindsight-embed/tests/test_profile_manager.py | 28 ++- 7 files changed, 498 insertions(+), 54 deletions(-) create mode 100644 hindsight-api/tests/test_config_validation.py create mode 100644 hindsight-embed/tests/test_profile_daemon_config.py diff --git a/hindsight-api/tests/test_config_validation.py b/hindsight-api/tests/test_config_validation.py new file mode 100644 index 00000000..ed1a2d73 --- /dev/null +++ b/hindsight-api/tests/test_config_validation.py @@ -0,0 +1,109 @@ +""" +Tests for configuration validation. + +Verifies that config validation catches invalid parameter combinations. +""" + +import os + +import pytest + + +@pytest.fixture(autouse=True) +def setup_test_env(): + """Set up environment for each test, restoring original values after.""" + from hindsight_api.config import clear_config_cache + + # Save original environment values + env_vars_to_save = [ + "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", + "HINDSIGHT_API_RETAIN_CHUNK_SIZE", + "HINDSIGHT_API_LLM_PROVIDER", + "HINDSIGHT_API_LLM_MODEL", + ] + + # Save original values + original_values = {} + for key in env_vars_to_save: + original_values[key] = os.environ.get(key) + + clear_config_cache() + + yield + + # Restore original environment + for key, original_value in original_values.items(): + if original_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = original_value + + clear_config_cache() + + +def test_retain_max_completion_tokens_must_be_greater_than_chunk_size(): + """Test that RETAIN_MAX_COMPLETION_TOKENS > RETAIN_CHUNK_SIZE validation works.""" + from hindsight_api.config import HindsightConfig + + # Set invalid config: max_completion_tokens <= chunk_size + os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "1000" + os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "2000" + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + + # Should raise ValueError with helpful message + with pytest.raises(ValueError) as exc_info: + HindsightConfig.from_env() + + error_message = str(exc_info.value) + + # Verify error message contains helpful information + assert "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message + assert "1000" in error_message + assert "HINDSIGHT_API_RETAIN_CHUNK_SIZE" in error_message + assert "2000" in error_message + assert "must be greater than" in error_message + assert "You have two options to fix this:" in error_message + assert "Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message + assert "Use a model that supports" in error_message + + +def test_retain_max_completion_tokens_equal_to_chunk_size_fails(): + """Test that RETAIN_MAX_COMPLETION_TOKENS == RETAIN_CHUNK_SIZE also fails.""" + from hindsight_api.config import HindsightConfig + + # Set invalid config: max_completion_tokens == chunk_size + os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "3000" + os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000" + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + + # Should raise ValueError + with pytest.raises(ValueError) as exc_info: + HindsightConfig.from_env() + + error_message = str(exc_info.value) + assert "must be greater than" in error_message + + +def test_valid_retain_config_succeeds(): + """Test that valid config with max_completion_tokens > chunk_size works.""" + from hindsight_api.config import HindsightConfig + + # Set valid config: max_completion_tokens > chunk_size + os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000" + os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000" + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + + # Should not raise + config = HindsightConfig.from_env() + assert config.retain_max_completion_tokens == 64000 + assert config.retain_chunk_size == 3000 + + +# Note: The BadRequestError wrapping is implemented in fact_extraction.py +# but requires a complex integration test setup. The functionality is +# straightforward: when a BadRequestError containing keywords like +# "max_tokens", "max_completion_tokens", or "maximum context" is caught, +# it's wrapped in a ValueError with helpful guidance. +# +# The config validation tests above ensure users get early feedback +# about invalid configurations before runtime errors occur. diff --git a/hindsight-api/tests/test_mental_models.py b/hindsight-api/tests/test_mental_models.py index 0383e90f..e3ea5e5f 100644 --- a/hindsight-api/tests/test_mental_models.py +++ b/hindsight-api/tests/test_mental_models.py @@ -738,12 +738,20 @@ class TestMentalModelRefreshTagSecurity: "Refreshed model should access memories/models with matching tags (user:alice)" # MUST NOT include Bob's content (security violation) - assert "bob" not in refreshed_content and "python" not in refreshed_content and "tea" not in refreshed_content, \ - f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed_content}" + # Use word boundary matching to avoid false positives (e.g., "team" contains "tea") + import re + def contains_word(text: str, word: str) -> bool: + """Check if text contains word as a whole word (not substring).""" + return bool(re.search(rf'\b{re.escape(word)}\b', text, re.IGNORECASE)) + + assert not contains_word(refreshed_content, "bob") and \ + not contains_word(refreshed_content, "python") and \ + not contains_word(refreshed_content, "tea"), \ + f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed['content']}" # MUST NOT include untagged content (security violation) assert "100 employees" not in refreshed_content and "growing fast" not in refreshed_content, \ - f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed_content}" + f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed['content']}" # Cleanup await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-embed/hindsight_embed/cli.py b/hindsight-embed/hindsight_embed/cli.py index 829372ed..7d512d01 100644 --- a/hindsight-embed/hindsight_embed/cli.py +++ b/hindsight-embed/hindsight_embed/cli.py @@ -86,21 +86,34 @@ def setup_logging(verbose: bool = False): def load_config_file(): - """Load configuration from file if it exists.""" - # Check both config file locations - config_files = [CONFIG_FILE, CONFIG_FILE_ALT] - for config_path in config_files: - if config_path.exists(): - with open(config_path) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and "=" in line: - # Handle 'export VAR=value' format - if line.startswith("export "): - line = line[7:] - key, value = line.split("=", 1) - if key not in os.environ: # Don't override env vars - os.environ[key] = value + """Load configuration from the active profile's file if it exists. + + IMPORTANT: Only loads from the active profile, never from default if a specific profile is set. + Uses dynamic path resolution to support testing with temporary HOME directories. + """ + from .profile_manager import ProfileManager, resolve_active_profile + + # Resolve which profile to use (respects --profile flag, env vars, active_profile file) + active_profile = resolve_active_profile() + + # Get the config file path for this profile + # Use ProfileManager which resolves paths dynamically + pm = ProfileManager() + paths = pm.resolve_profile_paths(active_profile) + config_path = paths.config + + # Load ONLY this profile's config, never fall back to default + if config_path.exists(): + with open(config_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + # Handle 'export VAR=value' format + if line.startswith("export "): + line = line[7:] + key, value = line.split("=", 1) + if key not in os.environ: # Don't override env vars + os.environ[key] = value def get_config(): @@ -1156,6 +1169,10 @@ def main(): if global_profile == "default": global_profile = None + # Set the CLI profile override so it's available to resolve_active_profile() + # This must happen BEFORE any config loading (load_config_file, get_config, etc.) + set_cli_profile_override(global_profile) + # Check for built-in commands first # Find the first non-flag argument (the actual command) command = None diff --git a/hindsight-embed/hindsight_embed/daemon_embed_manager.py b/hindsight-embed/hindsight_embed/daemon_embed_manager.py index e1ba8969..a821737d 100644 --- a/hindsight-embed/hindsight_embed/daemon_embed_manager.py +++ b/hindsight-embed/hindsight_embed/daemon_embed_manager.py @@ -108,6 +108,13 @@ class DaemonEmbedManager(EmbedManager): daemon_log = paths.log port = paths.port + # Load profile's .env file and merge with provided config + # This fixes issue #305 where profile env vars were ignored + profile_config = self._profile_manager.load_profile_config(profile) + # Merge: profile config first, then override with explicitly provided config + merged_config = {**profile_config, **config} + config = merged_config + # Build environment with LLM config # Support both formats: simple keys ("llm_api_key") and env var format ("HINDSIGHT_API_LLM_API_KEY") env = os.environ.copy() diff --git a/hindsight-embed/hindsight_embed/profile_manager.py b/hindsight-embed/hindsight_embed/profile_manager.py index 335a4e99..25ff76c1 100644 --- a/hindsight-embed/hindsight_embed/profile_manager.py +++ b/hindsight-embed/hindsight_embed/profile_manager.py @@ -65,9 +65,25 @@ class ProfileManager: """Initialize the profile manager.""" self._ensure_directories() + def _get_config_dir(self) -> Path: + """Get config directory path dynamically (supports testing with temp HOME).""" + return Path.home() / ".hindsight" + + def _get_profiles_dir(self) -> Path: + """Get profiles directory path dynamically.""" + return self._get_config_dir() / "profiles" + + def _get_metadata_file(self) -> Path: + """Get metadata file path dynamically.""" + return self._get_profiles_dir() / "metadata.json" + + def _get_active_profile_file(self) -> Path: + """Get active profile file path dynamically.""" + return self._get_config_dir() / "active_profile" + def _ensure_directories(self): """Ensure profile directories exist.""" - PROFILES_DIR.mkdir(parents=True, exist_ok=True) + self._get_profiles_dir().mkdir(parents=True, exist_ok=True) def list_profiles(self) -> list[ProfileInfo]: """List all profiles with their status. @@ -80,7 +96,7 @@ class ProfileManager: profiles = [] # Add default profile if config exists - default_config = CONFIG_DIR / "embed" + default_config = self._get_config_dir() / "embed" if default_config.exists(): profiles.append( ProfileInfo( @@ -119,10 +135,10 @@ class ProfileManager: """ if not name: # Default profile exists if config file exists - return (CONFIG_DIR / "embed").exists() + return (self._get_config_dir() / "embed").exists() # Named profile exists if config file exists - config_path = PROFILES_DIR / f"{name}.env" + config_path = self._get_profiles_dir() / f"{name}.env" return config_path.exists() def get_profile(self, name: str) -> Optional[ProfileInfo]: @@ -186,7 +202,7 @@ class ProfileManager: port = self._allocate_port(name) # Write config file - config_path = PROFILES_DIR / f"{name}.env" + config_path = self._get_profiles_dir() / f"{name}.env" config_lines = [f"{key}={value}" for key, value in config.items()] config_path.write_text("\n".join(config_lines) + "\n") @@ -223,17 +239,17 @@ class ProfileManager: raise ValueError(f"Profile '{name}' does not exist") # Remove config file - config_path = PROFILES_DIR / f"{name}.env" + config_path = self._get_profiles_dir() / f"{name}.env" if config_path.exists(): config_path.unlink() # Remove lock file - lock_path = PROFILES_DIR / f"{name}.lock" + lock_path = self._get_profiles_dir() / f"{name}.lock" if lock_path.exists(): lock_path.unlink() # Remove log file - log_path = PROFILES_DIR / f"{name}.log" + log_path = self._get_profiles_dir() / f"{name}.log" if log_path.exists(): log_path.unlink() @@ -259,12 +275,13 @@ class ProfileManager: if name and not self.profile_exists(name): raise ValueError(f"Profile '{name}' does not exist") + active_file = self._get_active_profile_file() if name: - ACTIVE_PROFILE_FILE.write_text(name) + active_file.write_text(name) else: # Clear active profile - if ACTIVE_PROFILE_FILE.exists(): - ACTIVE_PROFILE_FILE.unlink() + if active_file.exists(): + active_file.unlink() def get_active_profile(self) -> str: """Get the currently active profile name. @@ -272,8 +289,9 @@ class ProfileManager: Returns: Profile name, or empty string if no active profile. """ - if ACTIVE_PROFILE_FILE.exists(): - return ACTIVE_PROFILE_FILE.read_text().strip() + active_file = self._get_active_profile_file() + if active_file.exists(): + return active_file.read_text().strip() return "" def resolve_profile_paths(self, name: str) -> ProfilePaths: @@ -285,12 +303,16 @@ class ProfileManager: Returns: ProfilePaths with config, lock, log, and port. """ + # Use dynamic path resolution to support testing with temporary HOME directories + config_dir = Path.home() / ".hindsight" + profiles_dir = config_dir / "profiles" + if not name: # Default profile return ProfilePaths( - config=CONFIG_DIR / "embed", - lock=CONFIG_DIR / "daemon.lock", - log=CONFIG_DIR / "daemon.log", + config=config_dir / "embed", + lock=config_dir / "daemon.lock", + log=config_dir / "daemon.log", port=DEFAULT_PORT, ) @@ -299,12 +321,60 @@ class ProfileManager: port = metadata.profiles.get(name, {}).get("port", self._allocate_port(name)) return ProfilePaths( - config=PROFILES_DIR / f"{name}.env", - lock=PROFILES_DIR / f"{name}.lock", - log=PROFILES_DIR / f"{name}.log", + config=profiles_dir / f"{name}.env", + lock=profiles_dir / f"{name}.lock", + log=profiles_dir / f"{name}.log", port=port, ) + def load_profile_config(self, name: str) -> dict[str, str]: + """Load configuration from a profile's .env file. + + Args: + name: Profile name (empty string for default). + + Returns: + Dictionary of environment variable key-value pairs from the profile's .env file. + Also includes simple key aliases (e.g., 'idle_timeout' for 'HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT'). + """ + paths = self.resolve_profile_paths(name) + config = {} + + if not paths.config.exists(): + return config + + # Parse .env file + with open(paths.config) as f: + for line in f: + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + # Handle 'export VAR=value' format + if line.startswith("export "): + line = line[7:] + # Parse KEY=VALUE + if "=" in line: + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + + # Add simple key aliases for backward compatibility + # Some code checks config.get("idle_timeout") instead of the full env var name + key_aliases = { + "HINDSIGHT_API_LLM_API_KEY": "llm_api_key", + "HINDSIGHT_API_LLM_PROVIDER": "llm_provider", + "HINDSIGHT_API_LLM_MODEL": "llm_model", + "HINDSIGHT_API_LLM_BASE_URL": "llm_base_url", + "HINDSIGHT_API_LOG_LEVEL": "log_level", + "HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT": "idle_timeout", + } + + for env_key, simple_key in key_aliases.items(): + if env_key in config and simple_key not in config: + config[simple_key] = config[env_key] + + return config + def _allocate_port(self, name: str) -> int: """Allocate a port for a profile using hash-based strategy. @@ -359,11 +429,12 @@ class ProfileManager: Returns: ProfileMetadata object. """ - if not METADATA_FILE.exists(): + metadata_file = self._get_metadata_file() + if not metadata_file.exists(): return ProfileMetadata() try: - with open(METADATA_FILE) as f: + with open(metadata_file) as f: data = json.load(f) return ProfileMetadata(version=data.get("version", 1), profiles=data.get("profiles", {})) except (json.JSONDecodeError, IOError) as e: @@ -372,9 +443,9 @@ class ProfileManager: file=sys.stderr, ) # Backup corrupted metadata - backup_path = METADATA_FILE.with_suffix(".json.bak") - if METADATA_FILE.exists(): - METADATA_FILE.rename(backup_path) + backup_path = metadata_file.with_suffix(".json.bak") + if metadata_file.exists(): + metadata_file.rename(backup_path) return ProfileMetadata() def _save_metadata(self, metadata: ProfileMetadata): @@ -386,7 +457,8 @@ class ProfileManager: self._ensure_directories() # Use atomic write with temp file - temp_file = METADATA_FILE.with_suffix(".json.tmp") + metadata_file = self._get_metadata_file() + temp_file = metadata_file.with_suffix(".json.tmp") with open(temp_file, "w") as f: # Acquire exclusive lock @@ -403,7 +475,7 @@ class ProfileManager: fcntl.flock(f.fileno(), fcntl.LOCK_UN) # Atomic rename - temp_file.rename(METADATA_FILE) + temp_file.rename(metadata_file) def resolve_active_profile() -> str: diff --git a/hindsight-embed/tests/test_profile_daemon_config.py b/hindsight-embed/tests/test_profile_daemon_config.py new file mode 100644 index 00000000..14f42624 --- /dev/null +++ b/hindsight-embed/tests/test_profile_daemon_config.py @@ -0,0 +1,223 @@ +"""Test for validating that profile environment variables are loaded correctly when starting daemon. + +This is a regression test for issue #305 where profile .env files were not loaded +before daemon startup, causing environment variables to be ignored. +""" + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def temp_home(tmp_path, monkeypatch): + """Create a temporary home directory.""" + temp_home = tmp_path / "home" + temp_home.mkdir() + monkeypatch.setenv("HOME", str(temp_home)) + return temp_home + + +def test_profile_config_is_loaded_for_daemon(temp_home): + """Test that profile .env config is loaded when preparing daemon startup. + + Before the fix, the daemon would ignore all values from the profile's .env file, + using only os.environ or hardcoded defaults. This caused HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT + and other profile-specific settings to be silently ignored. + """ + # Create a profile with custom configuration + profile_dir = temp_home / ".hindsight" / "profiles" + profile_dir.mkdir(parents=True, exist_ok=True) + + profile_name = "test-timeout" + profile_env_path = profile_dir / f"{profile_name}.env" + + # Write profile config with custom values + profile_env_path.write_text( + "HINDSIGHT_API_LLM_PROVIDER=openai\n" + "HINDSIGHT_API_LLM_API_KEY=sk-test-fake-key\n" + "HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n" + "HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT=0\n" + "HINDSIGHT_API_LOG_LEVEL=debug\n" + ) + + # Create metadata to register the profile with a port + metadata_path = profile_dir / "metadata.json" + metadata = { + "version": 1, + "profiles": { + profile_name: { + "port": 9876, + "created_at": "2024-01-01T00:00:00+00:00", + "last_used": "2024-01-01T00:00:00+00:00", + } + }, + } + metadata_path.write_text(json.dumps(metadata, indent=2)) + + # Verify that ProfileManager can load the profile config + from hindsight_embed.profile_manager import ProfileManager + + pm = ProfileManager() + + # Verify profile exists + assert pm.profile_exists(profile_name) + + # Get profile paths + paths = pm.resolve_profile_paths(profile_name) + assert paths.config.exists() + assert paths.config == profile_env_path + assert paths.port == 9876 + + # Load profile config (this simulates what the fix should do) + profile_config = pm.load_profile_config(profile_name) + + # Verify config was loaded correctly + assert profile_config["HINDSIGHT_API_LLM_PROVIDER"] == "openai" + assert profile_config["HINDSIGHT_API_LLM_API_KEY"] == "sk-test-fake-key" + assert profile_config["HINDSIGHT_API_LLM_MODEL"] == "gpt-4o-mini" + assert profile_config["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] == "0" + assert profile_config["HINDSIGHT_API_LOG_LEVEL"] == "debug" + + # Verify that idle_timeout simple key is also available for backward compat + # (some code checks config.get("idle_timeout")) + assert profile_config.get("idle_timeout") == "0" + + +def test_load_config_file_uses_correct_profile(temp_home, monkeypatch): + """Test that load_config_file() loads the correct profile and not default. + + This is the core fix for issue #305 - when a profile is specified, we should + ONLY load that profile's .env, never the default profile's config. + """ + import os + + # Clear any existing profile override state + from hindsight_embed.cli import set_cli_profile_override + + set_cli_profile_override(None) + + # Must clear HINDSIGHT_EMBED_PROFILE env var to ensure test isolation + monkeypatch.delenv("HINDSIGHT_EMBED_PROFILE", raising=False) + + from hindsight_embed.cli import load_config_file + from hindsight_embed.profile_manager import ProfileManager + + # Create default profile with one provider + default_config_dir = temp_home / ".hindsight" + default_config_dir.mkdir(parents=True, exist_ok=True) + (default_config_dir / "embed").write_text( + "HINDSIGHT_API_LLM_PROVIDER=openai\n" "HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n" + ) + + # Create a named profile with a DIFFERENT provider + profile_dir = temp_home / ".hindsight" / "profiles" + profile_dir.mkdir(parents=True, exist_ok=True) + + profile_name = "myapp" + profile_env_path = profile_dir / f"{profile_name}.env" + profile_env_path.write_text("HINDSIGHT_API_LLM_PROVIDER=groq\n" "HINDSIGHT_API_LLM_MODEL=llama-3.1-70b\n") + + # Create metadata + import json + + metadata_path = profile_dir / "metadata.json" + metadata = { + "version": 1, + "profiles": { + profile_name: { + "port": 9876, + "created_at": "2024-01-01T00:00:00+00:00", + "last_used": "2024-01-01T00:00:00+00:00", + } + }, + } + metadata_path.write_text(json.dumps(metadata, indent=2)) + + # Clear env to ensure we're testing file loading + monkeypatch.delenv("HINDSIGHT_API_LLM_PROVIDER", raising=False) + monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False) + + # Test 1: Load default profile (no profile specified) + set_cli_profile_override(None) + load_config_file() + + assert os.environ.get("HINDSIGHT_API_LLM_PROVIDER") == "openai" + assert os.environ.get("HINDSIGHT_API_LLM_MODEL") == "gpt-4o-mini" + + # Clear env + monkeypatch.delenv("HINDSIGHT_API_LLM_PROVIDER") + monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL") + + # Test 2: Load named profile - should load ONLY that profile, not default + set_cli_profile_override(profile_name) + load_config_file() + + # Should have loaded from the named profile + assert os.environ.get("HINDSIGHT_API_LLM_PROVIDER") == "groq", "Should load from named profile, not default" + assert os.environ.get("HINDSIGHT_API_LLM_MODEL") == "llama-3.1-70b", "Should load from named profile, not default" + + +def test_get_config_respects_profile(temp_home, monkeypatch): + """Test that get_config() returns profile-specific values.""" + import os + + from hindsight_embed.cli import get_config, set_cli_profile_override + from hindsight_embed.profile_manager import ProfileManager + + # Create default profile + default_config_dir = temp_home / ".hindsight" + default_config_dir.mkdir(parents=True, exist_ok=True) + (default_config_dir / "embed").write_text( + "HINDSIGHT_API_LLM_PROVIDER=openai\n" + "HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n" + "HINDSIGHT_API_LLM_API_KEY=sk-default-key\n" + "HINDSIGHT_EMBED_BANK_ID=default-bank\n" + ) + + # Create named profile + profile_dir = temp_home / ".hindsight" / "profiles" + profile_dir.mkdir(parents=True, exist_ok=True) + + profile_name = "production" + profile_env_path = profile_dir / f"{profile_name}.env" + profile_env_path.write_text( + "HINDSIGHT_API_LLM_PROVIDER=anthropic\n" + "HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514\n" + "HINDSIGHT_API_LLM_API_KEY=sk-ant-production\n" + "HINDSIGHT_EMBED_BANK_ID=production-bank\n" + ) + + # Create metadata + import json + + metadata_path = profile_dir / "metadata.json" + metadata = { + "version": 1, + "profiles": { + profile_name: { + "port": 9900, + "created_at": "2024-01-01T00:00:00+00:00", + "last_used": "2024-01-01T00:00:00+00:00", + } + }, + } + metadata_path.write_text(json.dumps(metadata, indent=2)) + + # Clear env + monkeypatch.delenv("HINDSIGHT_API_LLM_PROVIDER", raising=False) + monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False) + monkeypatch.delenv("HINDSIGHT_API_LLM_API_KEY", raising=False) + monkeypatch.delenv("HINDSIGHT_EMBED_BANK_ID", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + # Test with named profile + set_cli_profile_override(profile_name) + config = get_config() + + # Should have loaded from production profile, NOT default + assert config["llm_provider"] == "anthropic", "Should use profile's provider" + assert config["llm_model"] == "claude-sonnet-4-20250514", "Should use profile's model" + assert config["llm_api_key"] == "sk-ant-production", "Should use profile's API key" + assert config["bank_id"] == "production-bank", "Should use profile's bank_id" diff --git a/hindsight-embed/tests/test_profile_manager.py b/hindsight-embed/tests/test_profile_manager.py index 9c893231..d9cd0c1d 100644 --- a/hindsight-embed/tests/test_profile_manager.py +++ b/hindsight-embed/tests/test_profile_manager.py @@ -8,8 +8,6 @@ from unittest.mock import Mock, patch import pytest from hindsight_embed.profile_manager import ( - CONFIG_DIR, - PROFILES_DIR, ProfileInfo, ProfileManager, ProfilePaths, @@ -20,15 +18,25 @@ from hindsight_embed.profile_manager import ( @pytest.fixture def temp_hindsight_dir(tmp_path, monkeypatch): - """Create a temporary hindsight directory for tests.""" - temp_config = tmp_path / ".hindsight" + """Create a temporary hindsight directory for tests. + + Uses HOME environment variable to make Path.home() return the temp directory. + This works with the dynamic path resolution in ProfileManager. + """ + # Clear any CLI profile override for test isolation + from hindsight_embed.cli import set_cli_profile_override + + set_cli_profile_override(None) + + # Clear HINDSIGHT_EMBED_PROFILE env var + monkeypatch.delenv("HINDSIGHT_EMBED_PROFILE", raising=False) + + temp_home = tmp_path / "home" + temp_home.mkdir() + monkeypatch.setenv("HOME", str(temp_home)) + + temp_config = temp_home / ".hindsight" temp_config.mkdir() - monkeypatch.setattr("hindsight_embed.profile_manager.CONFIG_DIR", temp_config) - monkeypatch.setattr("hindsight_embed.profile_manager.PROFILES_DIR", temp_config / "profiles") - monkeypatch.setattr( - "hindsight_embed.profile_manager.METADATA_FILE", temp_config / "profiles" / "metadata.json" - ) - monkeypatch.setattr("hindsight_embed.profile_manager.ACTIVE_PROFILE_FILE", temp_config / "active_profile") return temp_config