From 37b12fdc9a8837dcddf8a2afdf6fc1bb5c4288a5 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Wed, 8 Jul 2026 21:49:19 -0600 Subject: [PATCH] feat(surfaces): report an honest no-thinking-engine state Add DRAIN_STATE_NO_ENGINE, taking precedence over the realtime/deferred mode check so the state is honest in both modes. Add the thinking_engine_not_chosen readiness reason in all four registries: Python reason codes, presenter entries, chat-reasons JS, and health JS. The no-engine state is intercepted before cloud readiness so it never collapses into provider_key_missing. generate consumers that previously degraded on a missing API key now degrade identically on no-brain rather than crashing: transcript segmentation/JSON, creation-time detection, depict, enrich, and describe. Import and capture keep working; only analysis defers. --- solstone/apps/chat/copy.py | 1 + solstone/apps/health/static/health.js | 1 + solstone/apps/thinking/copy.py | 1 + solstone/apps/thinking/routes.py | 12 +++++-- .../tests/test_providers_payload_extended.py | 35 ++++++++++++++++-- solstone/convey/chat.py | 20 +++++++++-- solstone/convey/provider_readiness.py | 11 ++++++ solstone/convey/static/chat_reasons.js | 4 +++ solstone/observe/depict.py | 15 +++++--- solstone/observe/describe.py | 17 +++++++-- solstone/observe/enrich.py | 5 ++- solstone/think/detect_created.py | 9 ++++- solstone/think/detect_transcript.py | 10 ++++-- solstone/think/processing.py | 5 +++ solstone/think/surfaces/health.py | 20 +++++++---- tests/test_chat_reasons.py | 1 + tests/test_convey_chat_deferred.py | 28 ++++++++++++++- tests/test_health_readiness_js.py | 1 + tests/test_processing.py | 14 +++++--- tests/test_provider_readiness_presenter.py | 2 ++ tests/test_provider_state.py | 12 ++++++- tests/test_surfaces_health.py | 36 +++++++++++++++++++ 22 files changed, 229 insertions(+), 31 deletions(-) diff --git a/solstone/apps/chat/copy.py b/solstone/apps/chat/copy.py index e94d66867..480e4bbee 100644 --- a/solstone/apps/chat/copy.py +++ b/solstone/apps/chat/copy.py @@ -67,6 +67,7 @@ CHAT_SUPPORT_DRAFT_CANCELLED = "Okay — nothing was sent to solstone support." # but not yet analyzed"). Backend-only — no chat_copy.js twin. Substance locked # (wording VPX-refinable). Copy-canon compliant: no surveillance verbs. CHAT_DEFERRED_NOT_ANALYZED = "Today's segments aren't analyzed yet — they'll process during your deferred window." +CHAT_THINKING_ENGINE_NOT_CHOSEN = "No thinking engine is chosen yet. Choose one in Thinking so I can answer from your observations." # fmt: on from typing import Literal diff --git a/solstone/apps/health/static/health.js b/solstone/apps/health/static/health.js index c7d7262e2..b0ca50649 100644 --- a/solstone/apps/health/static/health.js +++ b/solstone/apps/health/static/health.js @@ -452,6 +452,7 @@ blocker: 3, }; const PROVIDER_LEVEL_REASON_CODES = new Set([ + 'thinking_engine_not_chosen', 'provider_key_missing', 'provider_key_invalid', 'provider_quota_exceeded', diff --git a/solstone/apps/thinking/copy.py b/solstone/apps/thinking/copy.py index 48cc9928a..194ebc98e 100644 --- a/solstone/apps/thinking/copy.py +++ b/solstone/apps/thinking/copy.py @@ -9,6 +9,7 @@ from typing import Any HEADING = "thinking" ACTIVE_LANE_LABELS = { + "none": "No provider chosen", "scout": "Scout", "byo": "BYO cloud", "local": "Local", diff --git a/solstone/apps/thinking/routes.py b/solstone/apps/thinking/routes.py index 46fbf2561..84f48ac1b 100644 --- a/solstone/apps/thinking/routes.py +++ b/solstone/apps/thinking/routes.py @@ -45,7 +45,12 @@ from solstone.think.journal_config import ( read_journal_config, write_journal_config, ) -from solstone.think.models import LOCAL_MODEL, TYPE_DEFAULTS +from solstone.think.models import ( + LOCAL_MODEL, + NO_BRAIN_PROVIDER, + TYPE_DEFAULTS, + resolve_provider, +) from solstone.think.providers import ( PROVIDER_REGISTRY, build_provider_status, @@ -203,8 +208,9 @@ def _type_settings(providers_config: dict[str, Any]) -> dict[str, dict[str, Any] type_config = providers_config.get(agent_type, {}) if not isinstance(type_config, dict): type_config = {} + provider, _ = resolve_provider("", agent_type) settings[agent_type] = { - "provider": type_config.get("provider", defaults["provider"]), + "provider": provider, "tier": type_config.get("tier", defaults["tier"]), "backup": type_config.get("backup", defaults["backup"]), } @@ -212,6 +218,8 @@ def _type_settings(providers_config: dict[str, Any]) -> dict[str, dict[str, Any] def _lane_for_provider(provider: str) -> str: + if provider == NO_BRAIN_PROVIDER: + return "none" if provider == "local": return "local" if provider == "google" and scout.is_scout_enabled(): diff --git a/solstone/apps/thinking/tests/test_providers_payload_extended.py b/solstone/apps/thinking/tests/test_providers_payload_extended.py index 807de947a..048978d29 100644 --- a/solstone/apps/thinking/tests/test_providers_payload_extended.py +++ b/solstone/apps/thinking/tests/test_providers_payload_extended.py @@ -11,7 +11,7 @@ import pytest from solstone.apps.thinking import routes from solstone.apps.thinking.local_bootstrap import LOCAL_MODEL_SPECS from solstone.convey import create_app -from solstone.think.models import LOCAL_MODEL +from solstone.think.models import LOCAL_MODEL, NO_BRAIN_PROVIDER from solstone.think.providers.install_state import InstallState from solstone.think.providers.state import ProviderState @@ -161,6 +161,32 @@ def test_get_providers_reports_byo_when_split_cloud_providers_share_lane( assert payload["active_lane"]["split"] is False +def test_get_providers_reports_none_lane_when_no_engine_selected( + settings_env, + monkeypatch, +): + journal_path, config = settings_env( + { + "setup": {"completed_at": "2026-05-23T00:00:00Z"}, + "env": {}, + "providers": {"contexts": {}, "models": {}}, + } + ) + monkeypatch.setattr( + "solstone.think.providers.state.local_runtime_ready", lambda: False + ) + client, _journal_path = _settings_client_with_journal( + lambda: (journal_path, config) + ) + + response = client.get("/app/thinking/api/providers") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["active_lane"]["lane"] == NO_BRAIN_PROVIDER + assert payload["active_lane"]["split"] is False + + def test_get_providers_reports_advanced_when_generate_and_cogitate_lanes_split( settings_client_with_journal, ): @@ -549,6 +575,7 @@ def test_effective_contexts_show_local_resolution_differences( cloud_pin = effective_contexts["talent.cloud.pin"] assert cloud_pin["interface"] == "generate" assert cloud_pin["provider"] == "local" + assert cloud_pin["model"] == LOCAL_MODEL assert cloud_pin["differs_from_raw"] is True blank_pin = effective_contexts["talent.local.blank"] assert blank_pin["provider"] == "local" @@ -567,6 +594,10 @@ def test_ai_readiness_context_routes_use_effective_local_route( "provider": "google", "model": "gemini-flash-lite-latest", } + config["providers"]["contexts"]["talent.local.blank"] = { + "provider": "local", + "model": "", + } _write_config(journal_path, config) def fake_readiness(provider: str, interface: str, model: str): @@ -586,7 +617,7 @@ def test_ai_readiness_context_routes_use_effective_local_route( assert response.status_code == 200 context_routes = response.get_json()["ai_readiness"]["context_routes"] - assert any(route["provider"] == "local" for route in context_routes) + assert {route["provider"] for route in context_routes} == {"local"} def test_get_providers_ai_readiness_surfaces_gpu_probe_failed_from_inspect( diff --git a/solstone/convey/chat.py b/solstone/convey/chat.py index 295a3dd1c..f3fe00a66 100644 --- a/solstone/convey/chat.py +++ b/solstone/convey/chat.py @@ -39,6 +39,7 @@ from solstone.apps.chat.copy import ( CHAT_SUPPORT_SUBMIT_AMBIGUOUS, CHAT_SUPPORT_SUBMIT_FAILED, CHAT_SUPPORT_SUBMIT_FILED_FORMAT, + CHAT_THINKING_ENGINE_NOT_CHOSEN, ) from solstone.apps.support.tools import support_attach, support_create, support_reply from solstone.convey.chat_sources import parse_sol_sources @@ -555,6 +556,12 @@ def _proxy_progress(message: dict[str, Any]) -> None: _emit_cortex_event(message["event"], **fields) +def _no_thinking_engine_chosen() -> bool: + from solstone.think.models import no_thinking_engine_chosen + + return no_thinking_engine_chosen() + + def compose_honest_degradation( settings: ProcessingSettings, backlog: SegmentBacklog, @@ -571,15 +578,22 @@ def compose_honest_degradation( fire requires pending > 0; it is derived from the real backlog read, never fabricated. """ - if settings.mode != "deferred": + no_engine = _no_thinking_engine_chosen() + if not no_engine and settings.mode != "deferred": return None if backlog.errors: - return None + return CHAT_THINKING_ENGINE_NOT_CHOSEN if no_engine else None anchor_day = queried_day if queried_day is not None else _today_day() completion = backlog.per_day.get(anchor_day) if completion is None: - return None + return CHAT_THINKING_ENGINE_NOT_CHOSEN if no_engine else None pending = completion.not_sensed + completion.not_thought + if no_engine: + if pending > 0: + return ( + f"{CHAT_THINKING_ENGINE_NOT_CHOSEN} {format_awaiting_analysis(pending)}" + ) + return CHAT_THINKING_ENGINE_NOT_CHOSEN if pending <= 0: return None return f"{CHAT_DEFERRED_NOT_ANALYZED} {format_awaiting_analysis(pending)}" diff --git a/solstone/convey/provider_readiness.py b/solstone/convey/provider_readiness.py index 8ae848a90..09589122d 100644 --- a/solstone/convey/provider_readiness.py +++ b/solstone/convey/provider_readiness.py @@ -53,6 +53,7 @@ DISPLAY_NAMES: dict[str, str] = { PROVIDER_LEVEL_CODES = frozenset( { + "thinking_engine_not_chosen", "provider_key_missing", "provider_key_invalid", "provider_quota_exceeded", @@ -70,6 +71,10 @@ _SETTINGS_ACTION = RecoveryAction( label="Open Settings", target="/app/thinking/#main", ) +_THINKING_ACTION = RecoveryAction( + label="Open Thinking", + target="/app/thinking/#main", +) _LOCAL_SETUP_ACTION = RecoveryAction( label="Open Local Model Setup", target="/app/thinking/#local-setup", @@ -81,6 +86,12 @@ _LOCAL_VERIFY_DETAIL = ( ) _ENTRIES: dict[str, _Entry] = { + "thinking_engine_not_chosen": _Entry( + klass="setup", + summary="no thinking engine is chosen yet", + detail="Open Thinking to choose how sol thinks, then try again.", + recovery_action=_THINKING_ACTION, + ), "provider_key_missing": _Entry( klass="setup", summary="{provider} needs credentials before it can read your screen descriptions", diff --git a/solstone/convey/static/chat_reasons.js b/solstone/convey/static/chat_reasons.js index 2ac75f090..1786a6026 100644 --- a/solstone/convey/static/chat_reasons.js +++ b/solstone/convey/static/chat_reasons.js @@ -10,6 +10,10 @@ }); const CHAT_REASONS = Object.freeze({ + "thinking_engine_not_chosen": { + "template": "no thinking engine is chosen yet", + "action": {"label": "Open Thinking", "href": "/app/thinking/#main"} + }, "provider_key_missing": { "template": "{provider} needs credentials before it can read your screen descriptions", "action": {"label": "Open Settings", "href": "/app/thinking/#main"} diff --git a/solstone/observe/depict.py b/solstone/observe/depict.py index a5a02add8..ea31e6808 100644 --- a/solstone/observe/depict.py +++ b/solstone/observe/depict.py @@ -15,7 +15,7 @@ from PIL import Image from solstone.observe.detect import detect_objects, detections_block from solstone.observe.utils import get_segment_key, resize_for_vlm from solstone.think.journal_io import write_jsonl -from solstone.think.models import generate +from solstone.think.models import NoBrainConfiguredError, generate from solstone.think.utils import require_solstone, setup_cli logger = logging.getLogger(__name__) @@ -57,9 +57,16 @@ def run(image_path: Path, *, redo: bool = False) -> Path | None: img.save(buf, format="PNG") detect_png = buf.getvalue() prepared = resize_for_vlm(img) - description = generate( - contents=[_DESCRIBE_PROMPT, prepared], context="observe.depict" - ).strip() + try: + description = generate( + contents=[_DESCRIBE_PROMPT, prepared], context="observe.depict" + ).strip() + except NoBrainConfiguredError: + logger.info( + "No thinking engine chosen; deferring still-image description for %s", + image_path, + ) + return None header = _build_header(image_path.name, "image") entry = {"start": "00:00:00", "text": description} diff --git a/solstone/observe/describe.py b/solstone/observe/describe.py index 7238b12af..81f4a4b81 100644 --- a/solstone/observe/describe.py +++ b/solstone/observe/describe.py @@ -695,7 +695,7 @@ class VideoProcessor: Path to write JSONL output (when None, no output file is written) """ from solstone.think.batch import Batch - from solstone.think.models import resolve_provider + from solstone.think.models import NO_BRAIN_PROVIDER, resolve_provider # Load config for max_extractions and redaction rules config = get_config() @@ -748,7 +748,10 @@ class VideoProcessor: try: # Resolve model for frame description (tier from describe.md frontmatter) - _, frame_model = resolve_provider(FRAME_CONTEXT, "generate") + frame_provider, frame_model = resolve_provider(FRAME_CONTEXT, "generate") + if frame_provider == NO_BRAIN_PROVIDER: + logger.info("No thinking engine selected; deferring frame description") + return # Create vision requests for all qualified frames for frame_data in qualified_frames: @@ -1068,7 +1071,15 @@ class VideoProcessor: is_json = cat_meta.get("output") == "json" # Resolve model for this category context - _, cat_model = resolve_provider(cat_meta["context"], "generate") + cat_provider, cat_model = resolve_provider( + cat_meta["context"], "generate" + ) + if cat_provider == NO_BRAIN_PROVIDER: + logger.info( + "No thinking engine selected; deferring %s extraction", + category, + ) + continue batch.update( extract_req, diff --git a/solstone/observe/enrich.py b/solstone/observe/enrich.py index 972320ab8..b893641d9 100644 --- a/solstone/observe/enrich.py +++ b/solstone/observe/enrich.py @@ -22,7 +22,7 @@ import numpy as np from google.genai import types from solstone.observe.utils import audio_to_flac_bytes -from solstone.think.models import generate +from solstone.think.models import NoBrainConfiguredError, generate from solstone.think.prompts import load_prompt logger = logging.getLogger(__name__) @@ -145,6 +145,9 @@ def enrich_transcript( return result + except NoBrainConfiguredError: + logger.info("No thinking engine chosen; audio enrichment skipped") + return None except Exception as e: logger.warning(f"Enrichment failed: {e}") from solstone.think.models import IncompleteJSONError diff --git a/solstone/think/detect_created.py b/solstone/think/detect_created.py index 3e0d958ab..16ef45371 100644 --- a/solstone/think/detect_created.py +++ b/solstone/think/detect_created.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import logging import re import subprocess from datetime import datetime, timezone @@ -46,6 +47,7 @@ _SUBSECOND_RE = re.compile(r"(\d{2}:\d{2}:\d{2})\.\d+") _SCHEMA = json.loads( (Path(__file__).parent / "detect_created.schema.json").read_text(encoding="utf-8") ) +logger = logging.getLogger(__name__) def _load_system_prompt() -> str: @@ -263,7 +265,7 @@ def detect_created( if guidance: markdown += f"\n\nImportant guidance from the user: {guidance}" - from solstone.think.models import generate + from solstone.think.models import NoBrainConfiguredError, generate try: response_text = generate( @@ -277,6 +279,11 @@ def detect_created( json_schema=_SCHEMA, ) result = json.loads(response_text) + except NoBrainConfiguredError: + logger.info( + "No thinking engine chosen; creation timestamp model detection skipped" + ) + return None except (ValueError, json.JSONDecodeError): return None diff --git a/solstone/think/detect_transcript.py b/solstone/think/detect_transcript.py index 78f2b95b7..e1d59ad3d 100644 --- a/solstone/think/detect_transcript.py +++ b/solstone/think/detect_transcript.py @@ -151,7 +151,7 @@ def detect_transcript_segment(text: str, start_time: str) -> List[tuple[str, str contents = f"START_TIME: {start_time}\n{numbered}" logging.info(f"Starting transcript segmentation (start: {start_time})...") - from solstone.think.models import generate + from solstone.think.models import NoBrainConfiguredError, generate try: response_text = generate( @@ -170,6 +170,9 @@ def detect_transcript_segment(text: str, start_time: str) -> List[tuple[str, str segments = segments_from_boundaries(lines, boundaries) return segments + except NoBrainConfiguredError: + logging.info("No thinking engine chosen; transcript segmentation skipped") + return [] except (ValueError, json.JSONDecodeError) as e: logging.error(f"Transcript segmentation failed: {e}") return [] @@ -192,7 +195,7 @@ def detect_transcript_json(text: str, segment_start: str) -> Optional[dict]: # Prepend SEGMENT_START for the prompt contents = f"SEGMENT_START: {segment_start}\n{text}" - from solstone.think.models import generate + from solstone.think.models import NoBrainConfiguredError, generate try: response_text = generate( @@ -210,6 +213,9 @@ def detect_transcript_json(text: str, segment_start: str) -> Optional[dict]: result = json.loads(response_text) logging.info("Successfully converted transcript to JSON") return result + except NoBrainConfiguredError: + logging.info("No thinking engine chosen; transcript JSON conversion skipped") + return None except (ValueError, json.JSONDecodeError) as e: logging.error(f"Failed to parse JSON response from LLM: {e}") return None diff --git a/solstone/think/processing.py b/solstone/think/processing.py index c6d5d2dcf..45f8db6fc 100644 --- a/solstone/think/processing.py +++ b/solstone/think/processing.py @@ -22,6 +22,7 @@ DRAIN_STATE_REALTIME = "realtime" DRAIN_STATE_WINDOW_OPEN = "window_open" DRAIN_STATE_WAITING = "waiting_for_window" DRAIN_STATE_NO_CONDITION = "no_active_condition" +DRAIN_STATE_NO_ENGINE = "no_engine" _MISSING = object() _MODES = frozenset({"realtime", "deferred"}) @@ -273,8 +274,11 @@ def read_last_drained_at() -> int | None: def derive_drain_state( settings: ProcessingSettings, gate_state: GateState, + no_engine: bool, ) -> str: """Return the stable drain-state token for current settings and gate state.""" + if no_engine: + return DRAIN_STATE_NO_ENGINE if settings.mode != "deferred": return DRAIN_STATE_REALTIME if gate_state.open: @@ -364,6 +368,7 @@ def _hhmm_minutes(value: str) -> int: __all__ = [ "AWAITING_ANALYSIS_TEMPLATE", "DRAIN_STATE_NO_CONDITION", + "DRAIN_STATE_NO_ENGINE", "DRAIN_STATE_REALTIME", "DRAIN_STATE_WAITING", "DRAIN_STATE_WINDOW_OPEN", diff --git a/solstone/think/surfaces/health.py b/solstone/think/surfaces/health.py index 149306304..ace5d3d32 100644 --- a/solstone/think/surfaces/health.py +++ b/solstone/think/surfaces/health.py @@ -57,6 +57,10 @@ INDEXER_STALE_WARN_DAYS = 7 # 7d matches the weekly freshness bar for search-backed consumers; shorter windows would over-warn on journals that intentionally rebuild less often. LEDGER_STALE_DAYS = 14 # 14d mirrors the consumer-signal stale-item threshold so the health surface stays aligned with ledger backlog review. +NO_ENGINE_ANALYSIS_TEXT = ( + "No thinking engine is chosen yet. Choose one in Thinking so observations " + "can be analyzed." +) USER_EDIT_ACTOR_PREFIXES = ("cli:", "owner", "user") # These prefixes identify operator- or user-authored corrections without trying to enumerate every internal automation actor string. DEGRADED_OUTPUT_NOTE_CAP = 10 @@ -513,13 +517,17 @@ def _build_segment_backlog_health() -> SegmentBacklogHealth: settings = load_processing_settings() reading = last_display_powersave() gate = evaluate_drain_gate(settings, datetime.now(), reading) - drain_state = derive_drain_state(settings, gate) + from solstone.think.models import no_thinking_engine_chosen + + no_engine = no_thinking_engine_chosen() + drain_state = derive_drain_state(settings, gate, no_engine) awaiting_total = backlog.not_sensed + backlog.not_thought - awaiting_text = ( - format_awaiting_analysis(awaiting_total) - if settings.mode == "deferred" - else None - ) + if no_engine: + awaiting_text = NO_ENGINE_ANALYSIS_TEXT + elif settings.mode == "deferred": + awaiting_text = format_awaiting_analysis(awaiting_total) + else: + awaiting_text = None return SegmentBacklogHealth( not_thought=backlog.not_thought, days_with_backlog=days_with_backlog, diff --git a/tests/test_chat_reasons.py b/tests/test_chat_reasons.py index cc1b3197a..0ef182bc6 100644 --- a/tests/test_chat_reasons.py +++ b/tests/test_chat_reasons.py @@ -12,6 +12,7 @@ from solstone.convey.provider_readiness import ( EXPECTED_CODES = { "provider_key_missing", + "thinking_engine_not_chosen", "ram_insufficient", "gpu_unavailable", "gpu_probe_failed", diff --git a/tests/test_convey_chat_deferred.py b/tests/test_convey_chat_deferred.py index 975813f46..1ecfabd91 100644 --- a/tests/test_convey_chat_deferred.py +++ b/tests/test_convey_chat_deferred.py @@ -8,7 +8,10 @@ import json import pytest from flask import Flask -from solstone.apps.chat.copy import CHAT_DEFERRED_NOT_ANALYZED +from solstone.apps.chat.copy import ( + CHAT_DEFERRED_NOT_ANALYZED, + CHAT_THINKING_ENGINE_NOT_CHOSEN, +) from solstone.convey.chat import chat_bp, compose_honest_degradation from solstone.convey.chat_stream import read_chat_events from solstone.think.pipeline_health import SegmentBacklog, SegmentCompletion @@ -41,6 +44,13 @@ def _reset_chat_state(chat_module) -> None: chat_module._last_use_id = 0 +@pytest.fixture(autouse=True) +def _default_thinking_engine_selected(monkeypatch): + monkeypatch.setattr( + "solstone.convey.chat._no_thinking_engine_chosen", lambda: False + ) + + def _set_current_chat(chat_module, logical_use_id: str, raw_use_id: str | None) -> None: with chat_module._state_lock: chat_module._current_chat_use_id = logical_use_id @@ -228,6 +238,22 @@ def test_compose_honest_degradation_ignores_indeterminate_backlog(): assert result is None +def test_compose_honest_degradation_fires_for_no_engine_realtime(monkeypatch): + import solstone.convey.chat as chat + + monkeypatch.setattr(chat, "_no_thinking_engine_chosen", lambda: True) + today = chat._today_day() + + result = compose_honest_degradation( + _settings("realtime"), + _backlog(today, not_sensed=2, not_thought=1), + ) + + assert result is not None + assert CHAT_THINKING_ENGINE_NOT_CHOSEN in result + assert format_awaiting_analysis(3) in result + + def test_empty_chat_finish_substitutes_honest_degradation(chat_client, monkeypatch): import solstone.convey.chat as chat diff --git a/tests/test_health_readiness_js.py b/tests/test_health_readiness_js.py index 6c2d7076d..828b02c65 100644 --- a/tests/test_health_readiness_js.py +++ b/tests/test_health_readiness_js.py @@ -76,6 +76,7 @@ def test_health_readiness_js_semantic_key_glance_and_grouping(): ) for code, provider, model in ( ("provider_key_missing", "anthropic", "claude-test"), + ("thinking_engine_not_chosen", "none", ""), ("provider_quota_exceeded", "openai", "gpt-test"), ("local_model_missing", "local", "qwen-test"), ("local_server_unhealthy", "local", "qwen-test"), diff --git a/tests/test_processing.py b/tests/test_processing.py index f9325cdf5..e27c5a3cb 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -14,6 +14,7 @@ from solstone.think.processing import ( DEFAULT_PROCESSING, DISPLAY_POWERSAVE_UNAVAILABLE, DRAIN_STATE_NO_CONDITION, + DRAIN_STATE_NO_ENGINE, DRAIN_STATE_REALTIME, DRAIN_STATE_WAITING, DRAIN_STATE_WINDOW_OPEN, @@ -280,7 +281,7 @@ def test_display_powersave_undetectable_has_no_active_condition() -> None: ) assert gate.open is False - assert derive_drain_state(settings, gate) == DRAIN_STATE_NO_CONDITION + assert derive_drain_state(settings, gate, False) == DRAIN_STATE_NO_CONDITION def test_derive_drain_state_tokens() -> None: @@ -300,18 +301,21 @@ def test_derive_drain_state_tokens() -> None: }, ) - assert derive_drain_state(_settings(mode="realtime"), open_gate) == ( + assert derive_drain_state(_settings(mode="realtime"), open_gate, False) == ( DRAIN_STATE_REALTIME ) - assert derive_drain_state(_settings(mode="deferred"), open_gate) == ( + assert derive_drain_state(_settings(mode="deferred"), open_gate, False) == ( DRAIN_STATE_WINDOW_OPEN ) - assert derive_drain_state(_settings(mode="deferred"), waiting_gate) == ( + assert derive_drain_state(_settings(mode="deferred"), waiting_gate, False) == ( DRAIN_STATE_WAITING ) - assert derive_drain_state(_settings(mode="deferred"), unavailable_gate) == ( + assert derive_drain_state(_settings(mode="deferred"), unavailable_gate, False) == ( DRAIN_STATE_NO_CONDITION ) + assert derive_drain_state(_settings(mode="realtime"), open_gate, True) == ( + DRAIN_STATE_NO_ENGINE + ) def test_format_awaiting_analysis_uses_fixed_template() -> None: diff --git a/tests/test_provider_readiness_presenter.py b/tests/test_provider_readiness_presenter.py index 0f6d153d3..e156413e3 100644 --- a/tests/test_provider_readiness_presenter.py +++ b/tests/test_provider_readiness_presenter.py @@ -85,9 +85,11 @@ def test_semantic_key_composition_is_stable(): provider_level = semantic_key_for( "provider_key_missing", "anthropic", "claude-test" ) + no_engine = semantic_key_for("thinking_engine_not_chosen", "none", "") model_level = semantic_key_for("local_model_missing", "local", "llama-test") assert provider_level == "provider_key_missing:anthropic:" + assert no_engine == "thinking_engine_not_chosen:none:" assert semantic_key_for("provider_key_missing", "anthropic", "other") == ( provider_level ) diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py index 1398edf5f..0a0788904 100644 --- a/tests/test_provider_state.py +++ b/tests/test_provider_state.py @@ -7,7 +7,7 @@ from types import SimpleNamespace import pytest -from solstone.think.models import LOCAL_MODEL, QWEN_35_9B +from solstone.think.models import LOCAL_MODEL, NO_BRAIN_PROVIDER, QWEN_35_9B from solstone.think.providers import ( local_endpoint, local_install, @@ -247,6 +247,16 @@ def test_cloud_readiness_missing_key(monkeypatch): assert provider_state.source == "config" +def test_no_brain_readiness_is_not_key_missing(monkeypatch): + monkeypatch.setattr(state, "cloud_key_configured", lambda _env_key: False) + + provider_state = state.readiness_for_provider(NO_BRAIN_PROVIDER, "generate", "") + + assert provider_state.status == "blocked" + assert provider_state.reason_code == "thinking_engine_not_chosen" + assert provider_state.source == "config" + + def test_cloud_readiness_key_present_without_health_row_is_unknown(monkeypatch): monkeypatch.setattr(state, "cloud_key_configured", lambda _env_key: True) monkeypatch.setattr(state, "read_health_status", lambda: None) diff --git a/tests/test_surfaces_health.py b/tests/test_surfaces_health.py index fdf6e8354..e13dd8f53 100644 --- a/tests/test_surfaces_health.py +++ b/tests/test_surfaces_health.py @@ -21,6 +21,7 @@ from solstone.think.pipeline_health import SegmentBacklog, SegmentCompletion from solstone.think.processing import ( DISPLAY_POWERSAVE_UNAVAILABLE, DRAIN_STATE_NO_CONDITION, + DRAIN_STATE_NO_ENGINE, DRAIN_STATE_REALTIME, DRAIN_STATE_WAITING, DRAIN_STATE_WINDOW_OPEN, @@ -608,6 +609,9 @@ def test_segment_backlog_deferred_awaiting_analysis_uses_unsensed( lambda settings, now, reading: GateState(open=False, conditions={}), ) monkeypatch.setattr(health_surface, "read_last_drained_at", lambda: None) + monkeypatch.setattr( + "solstone.think.models.no_thinking_engine_chosen", lambda: False + ) _stub_display_powersave(monkeypatch) backlog = health_surface._build_segment_backlog_health() @@ -634,6 +638,9 @@ def test_segment_backlog_realtime_omits_awaiting_analysis_text(monkeypatch) -> N lambda settings, now, reading: GateState(open=True, conditions={}), ) monkeypatch.setattr(health_surface, "read_last_drained_at", lambda: None) + monkeypatch.setattr( + "solstone.think.models.no_thinking_engine_chosen", lambda: False + ) _stub_display_powersave(monkeypatch) backlog = health_surface._build_segment_backlog_health() @@ -688,6 +695,9 @@ def test_segment_backlog_drain_state_tokens(monkeypatch, gate, expected_state) - lambda settings, now, reading: gate, ) monkeypatch.setattr(health_surface, "read_last_drained_at", lambda: None) + monkeypatch.setattr( + "solstone.think.models.no_thinking_engine_chosen", lambda: False + ) _stub_display_powersave(monkeypatch) backlog = health_surface._build_segment_backlog_health() @@ -695,6 +705,32 @@ def test_segment_backlog_drain_state_tokens(monkeypatch, gate, expected_state) - assert backlog.drain_state == expected_state +def test_segment_backlog_no_engine_wins_before_realtime(monkeypatch) -> None: + monkeypatch.setattr( + health_surface, + "read_segment_backlog", + lambda: _segment_backlog({"20260410": 2}, not_sensed=3), + ) + monkeypatch.setattr( + health_surface, + "load_processing_settings", + lambda: _processing_settings("realtime"), + ) + monkeypatch.setattr( + health_surface, + "evaluate_drain_gate", + lambda settings, now, reading: GateState(open=True, conditions={}), + ) + monkeypatch.setattr(health_surface, "read_last_drained_at", lambda: None) + monkeypatch.setattr("solstone.think.models.no_thinking_engine_chosen", lambda: True) + _stub_display_powersave(monkeypatch) + + backlog = health_surface._build_segment_backlog_health() + + assert backlog.awaiting_analysis_text == health_surface.NO_ENGINE_ANALYSIS_TEXT + assert backlog.drain_state == DRAIN_STATE_NO_ENGINE + + def test_segment_backlog_last_drained_at_passes_through(monkeypatch) -> None: monkeypatch.setattr( health_surface, -- 2.51.2