diff --git a/hindsight-api-slim/hindsight_api/config_resolver.py b/hindsight-api-slim/hindsight_api/config_resolver.py index 6b5702855..833d1a25e 100644 --- a/hindsight-api-slim/hindsight_api/config_resolver.py +++ b/hindsight-api-slim/hindsight_api/config_resolver.py @@ -172,8 +172,9 @@ async def _load_bank_config(self, bank_id: str) -> dict[str, Any]: # Normalize keys (handle both env var format and Python field format) normalized = normalize_config_dict(config_data) - # Only return overrides for configurable fields - return {k: v for k, v in normalized.items() if k in self._configurable_fields} + # Only return active overrides for configurable fields. JSON null is a tombstone + # for "Server Default" in the bank-config UI and should not override defaults. + return {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None} except Exception as e: logger.error(f"Failed to load bank config for {bank_id}: {e}") diff --git a/hindsight-api-slim/tests/test_hierarchical_config.py b/hindsight-api-slim/tests/test_hierarchical_config.py index d9eec79eb..0cb838a6d 100644 --- a/hindsight-api-slim/tests/test_hierarchical_config.py +++ b/hindsight-api-slim/tests/test_hierarchical_config.py @@ -5,6 +5,7 @@ key normalization, API endpoints, validation, and caching. """ +import json import os import pytest @@ -37,6 +38,33 @@ async def get_tenant_config(self, context): return self.tenant_config +class FakeBankConfigBackend: + """Minimal backend for ConfigResolver bank-config tests.""" + + def __init__(self): + self.config: dict[str, object] = {} + + def acquire(self): + return FakeBankConfigConnection(self) + + +class FakeBankConfigConnection: + def __init__(self, backend: FakeBankConfigBackend): + self.backend = backend + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def fetchrow(self, query, bank_id): + return {"config": self.backend.config} + + async def execute(self, query, updates_json, bank_id): + self.backend.config.update(json.loads(updates_json)) + + @pytest.mark.asyncio async def test_config_key_normalization(): """Test that env var keys are normalized to Python field names.""" @@ -169,6 +197,46 @@ async def test_config_hierarchy_resolution(memory, request_context): await memory.delete_bank(bank_id, request_context=request_context) +@pytest.mark.asyncio +async def test_bank_config_null_consolidation_overrides_use_server_defaults(): + """JSON null bank overrides should behave like Server Default. + + Regression test for #1619: the dashboard can send null for observation + config fields. Those nulls must not flow into consolidation as None. + """ + bank_id = "test-null-consolidation-config-bank" + fields = ( + "consolidation_llm_batch_size", + "consolidation_source_facts_max_tokens", + "consolidation_source_facts_max_tokens_per_observation", + "max_observations_per_scope", + ) + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + explicit_overrides = { + "consolidation_llm_batch_size": 7, + "consolidation_source_facts_max_tokens": 2048, + "consolidation_source_facts_max_tokens_per_observation": 256, + "max_observations_per_scope": 3, + } + + await resolver.update_bank_config(bank_id, explicit_overrides) + config = await resolver.resolve_full_config(bank_id) + for field_name, expected in explicit_overrides.items(): + assert getattr(config, field_name) == expected + + await resolver.update_bank_config(bank_id, {field_name: None for field_name in fields}) + + resolved_config = await resolver.resolve_full_config(bank_id) + global_config = resolver._global_config + for field_name in fields: + assert getattr(resolved_config, field_name) == getattr(global_config, field_name) + assert getattr(resolved_config, field_name) is not None + + bank_overrides = await resolver._load_bank_config(bank_id) + for field_name in fields: + assert field_name not in bank_overrides + + @pytest.mark.asyncio async def test_config_validation_rejects_static_fields(memory, request_context): """Test that attempting to override static fields raises ValueError."""