refactor(app_controller): migrate _fetch_models.do_fetch to per-provider Result (Phase 6 Group 6.4)

Replaces per-provider logging.debug body with _list_models_for_provider_result
SDK-boundary helper. Aggregates per-provider failures into self._model_fetch_errors
and returns Result with aggregated errors. Stderr summary on partial failure.

The SDK boundary (ai_client.list_models call) is the canonical place to
catch vendor exceptions and convert to ErrorInfo(kind=NETWORK), per
error_handling.md §'Boundary Types'.

Audit: INTERNAL_SILENT_SWALLOW for src/app_controller.py: 23 -> 22.
This commit is contained in:
ed
2026-06-19 15:56:53 -04:00
parent fd91c83a0c
commit 50750f3183
2 changed files with 106 additions and 10 deletions
+61
View File
@@ -405,3 +405,64 @@ def test_save_active_project_stores_error_on_save_failure():
assert isinstance(ctrl._save_project_error, ErrorInfo)
assert "denied" in ctrl._save_project_error.message
assert "save error" in ctrl.ai_status
# --- Phase 6: Group 6.4 (SDK boundary in _fetch_models) ---
def test_list_models_for_provider_result_returns_ok_on_success():
"""
SDK boundary (Phase 6 Group 6.4): _list_models_for_provider_result wraps
ai_client.list_models(p) and returns Result[list] on success.
"""
from src.app_controller import AppController
ctrl = AppController()
with patch("src.app_controller.ai_client.list_models", return_value=["model-a", "model-b"]):
result = ctrl._list_models_for_provider_result("gemini")
assert isinstance(result, Result)
assert result.ok is True
assert result.data == ["model-a", "model-b"]
def test_list_models_for_provider_result_returns_error_on_sdk_failure():
"""
SDK boundary: _list_models_for_provider_result converts SDK exceptions
to ErrorInfo(original=e) with NETWORK kind (the standard SDK boundary kind).
"""
from src.app_controller import AppController
ctrl = AppController()
with patch("src.app_controller.ai_client.list_models", side_effect=RuntimeError("network unreachable")):
result = ctrl._list_models_for_provider_result("gemini")
assert isinstance(result, Result)
assert result.ok is False
assert len(result.errors) == 1
assert isinstance(result.errors[0], ErrorInfo)
assert "network unreachable" in result.errors[0].message
assert result.errors[0].kind == ErrorKind.NETWORK
assert result.errors[0].original is not None
def test_fetch_models_aggregates_per_provider_errors():
"""
The _fetch_models.do_fetch wrapper accumulates per-provider failures in
self._model_fetch_errors and returns a Result that carries the aggregated
errors. The legacy wrapper (do_fetch itself) is internal; the public API
is the side effect (self.all_available_models gets a [] entry per failed provider).
"""
from src.app_controller import AppController
ctrl = AppController()
# Make the SDK return an error for "gemini" and succeed for "anthropic"
def fake_list_models(p):
if p == "gemini":
raise RuntimeError("gemini api down")
return [f"{p}-model"]
with patch("src.app_controller.ai_client.list_models", side_effect=fake_list_models):
with patch("src.app_controller.ai_client.PROVIDERS", new=["gemini", "anthropic"]):
# do_fetch is the inner function; we need to access it. Easiest: call _fetch_models
# and inspect the resulting side effect on all_available_models.
ctrl._fetch_models("anthropic")
# Per-provider errors should be accumulated in self._model_fetch_errors
assert "gemini" in ctrl._model_fetch_errors
assert isinstance(ctrl._model_fetch_errors["gemini"], ErrorInfo)
assert "gemini api down" in ctrl._model_fetch_errors["gemini"].message
# The gemini entry should have an empty list (per-provider failure placeholder)
assert ctrl.all_available_models.get("gemini") == [] # NOTE: do_fetch may not have run yet if deferred