From dff17bc1ddbce995bdb8f17eb05551fd903e4545 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Sun, 26 Jul 2026 17:11:07 -0400 Subject: [PATCH] Qt-removal R7.5d: composer thinking-mode toggle now has a real effect The composer's own "Thinking Mode" quick-access popover previously only updated its own display label. backend/composer.py's set_reasoning_level never called into api_provider.py, so the toggle had zero effect on the next real chat call - which always used whatever the Ollama/Llama.cpp Settings-page toggle (R7.4b/c) had last set instead. Fixed by routing the composer's toggle through the same mechanism as the Settings page, not a second parallel one. backend/settings.py gains two shared functions - apply_ollama_reasoning_mode and apply_llama_cpp_reasoning_mode - extracted from the persist-and-live- reapply logic that used to live only inside setOllamaReasoningMode/ setLlamaCppReasoningMode's own closures; both intents are now thin wrappers over them, and the five pre-existing regression tests for that pair pass unmodified, confirming the extraction preserves behavior exactly. backend/composer.py's setReasoningLevel intent now normalizes the id, branches on which local provider is actually live, calls the matching shared function, and surfaces a Llama.cpp live-reapply failure through the global notification banner (the composer has no settings-page- local notice field to write into). It silently no-ops in cloud/API mode, matching legacy, since the UI already disables the control there. capabilities.reasoningSelection, previously hardcoded true, now reflects the actually active provider - closing a second gap: the composer's own prior-generation React island already disabled this control while reasoningSelection was false or a request was busy; the current SPA component had silently dropped both gates at some point during the R2.3-R2.6 chrome consolidation. Composer.tsx now restores disabled={!composer.capabilities.reasoningSelection || !composer.request.canSend} on the trigger, matching the island's own established gating expression. The intent handler also gained legacy's busy-guard, reusing the existing request.canSend computation rather than adding a new field for it. This increment ran through a Workflow: a design-synthesis agent validated a hand-written recon brief against the actual code before implementation began. It independently caught two real regressions the brief missed - an existing backend test asserting the old hardcoded true that would become order-dependent once the field went live, and an existing frontend test relying on the fixture's default canSend: false that would break the moment the trigger became gated - and one real gap in the brief's own design, since the original proposal never accounted for what should happen when a composer-triggered Llama.cpp live-reapply actually fails. All three were fixed as part of the same change. Adversarial review of the finished diff found zero correctness defects: the extraction preserves the original same-to_thread-hop race-avoidance property, test_settings.py has a genuine zero-line diff, the busy-guard's ordering matches legacy exactly, and request.canSend cannot get stuck non-idle since every dispatch exit path in backend/agents.py runs end_request in a finally block. It flagged one worthwhile test strengthening - the Llama.cpp-failure test only asserted the notification publish, not the trailing composer publish on the same path - applied immediately. Live-verified end-to-end against a real backend: selected Thinking Mode in the composer, watched the trigger's label update, then read the Settings topic back over a second WebSocket connection to the same running backend and confirmed ollamaReasoningMode had genuinely become "Thinking" - proof the click reached the real SettingsManager, not just its own display state. 874 backend / 1204 frontend tests (7 new backend, 4 new/updated frontend) pass, typecheck/lint/build clean, burn-down gate unchanged at 152/84/68 (pure feature work), codegen untouched since the wire type was already boolean. Co-Authored-By: Claude Sonnet 5 --- backend/app.py | 2 +- backend/composer.py | 58 ++++++++- backend/settings.py | 150 +++++++++++++++--------- backend/tests/test_backend_composer.py | 149 ++++++++++++++++++++++- web_ui/src/app/chrome/Composer.test.tsx | 41 +++++++ web_ui/src/app/chrome/Composer.tsx | 1 + 6 files changed, 342 insertions(+), 59 deletions(-) diff --git a/backend/app.py b/backend/app.py index d94a9db..6226c64 100644 --- a/backend/app.py +++ b/backend/app.py @@ -158,7 +158,7 @@ def ping(*args): # to - both must exist before register_canvas builds the sendMessage # intent that calls them. token_counter = register_token_counter(bus) - composer_document = register_composer(bus, token_counter) + composer_document = register_composer(bus, token_counter, settings_manager, notifications_state) # R4 (doc/QT_REMOVAL_PLAN.md): the agent-dispatch service - one # AgentDispatcher per session (never a module-level singleton). Bolted diff --git a/backend/composer.py b/backend/composer.py index d7029fc..c42d99f 100644 --- a/backend/composer.py +++ b/backend/composer.py @@ -34,8 +34,13 @@ from typing import Any from uuid import uuid4 +import api_provider + from backend.events import SessionBus +from backend.notifications import NotificationState +from backend.settings import apply_llama_cpp_reasoning_mode, apply_ollama_reasoning_mode from backend.token_counter import TokenCounterState +from graphlink_licensing import SettingsManager REASONING_OPTIONS = [ {"id": "thinking", "label": "Thinking Mode (Enable CoT)", "description": "Slower, higher-quality reasoning."}, @@ -134,14 +139,25 @@ def payload(self) -> dict[str, Any]: "contextReview": False, "routeSelection": False, "modelSelection": False, - "reasoningSelection": True, + "reasoningSelection": api_provider.is_local_ollama_mode() or api_provider.is_local_llama_cpp_mode(), "settingsShortcut": True, "cancellation": True, }, } -def register_composer(bus: SessionBus, token_counter: TokenCounterState) -> ComposerDocument: +def register_composer( + bus: SessionBus, + token_counter: TokenCounterState, + settings_manager: SettingsManager | None = None, + notifications: NotificationState | None = None, +) -> ComposerDocument: + # settings_manager/notifications are optional only so the 2-positional- + # arg call in backend/tests/test_backend_composer.py's make_bus() + # (register_composer(bus, counter)) keeps working unchanged - the real + # (and only) production call site, backend/app.py's _configure_session, + # always passes both (mirroring register_settings's own `notifications` + # optional-param precedent exactly). document = ComposerDocument() bus.register_topic("app-composer", document.payload) @@ -156,7 +172,45 @@ async def update_draft(text): await bus.publish("token-counter") async def set_reasoning_level(level): + # Busy-guard, matching legacy's setReasoningLevel exactly + # (graphlink_composer_bridge.py, ~line 249): a bare no-op - no + # exception, no publish - while a request is in flight. This + # document only ever has two request_state values + # ("idle"/"generating"), already exposed on the wire as + # request.canSend == (request_state == "idle") - reuse that existing + # fact rather than adding a second field for it. + if document.request_state != "idle": + return + + # Unchanged existing behavior: raises ComposerError for an + # unrecognized id (see + # test_set_reasoning_level_intent_updates_and_rejects_unknown). document.set_reasoning_level(level) + + # Same Title-Case normalization backend/settings.py's own + # _OLLAMA_REASONING_MODES/_LLAMA_CPP_REASONING_MODES validate + # against, and legacy's own bridge applied. This ternary is TOTAL - + # it always produces "Thinking" or "Quick" - so the shared apply + # functions below never need to re-validate whatever this handler + # passes them. + normalized_mode = "Thinking" if str(level).strip().lower() == "thinking" else "Quick" + + if settings_manager is not None: + # Mutually exclusive by construction (api_provider.py) - order + # between the two branches is arbitrary; Ollama-first here + # purely to match backend/settings.py's own file ordering + # (Ollama defined before Llama.cpp throughout that file). + if api_provider.is_local_ollama_mode(): + await apply_ollama_reasoning_mode(settings_manager, normalized_mode) + elif api_provider.is_local_llama_cpp_mode(): + failure = await apply_llama_cpp_reasoning_mode(settings_manager, normalized_mode) + if failure is not None and notifications is not None: + notifications.show(failure, "error") + await bus.publish("notification") + # else: cloud/API mode - reasoningSelection is already False + # there (see payload() above), so the UI disables this control + # entirely; skip the apply step, never raise. + await publish() bus.register_intent("app-composer", "updateDraft", update_draft) diff --git a/backend/settings.py b/backend/settings.py index 944fcb3..60877ff 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -140,6 +140,51 @@ def _locked_llama_cpp_settings(manager: SettingsManager) -> dict[str, Any]: return manager.get_llama_cpp_settings() +async def apply_ollama_reasoning_mode(manager: SettingsManager, mode: str) -> None: + """Persist `mode` and, if Ollama is still the live provider, re-apply it to + api_provider's module state so the very next chat()/chat_stream() call + picks it up. `mode` MUST already be "Thinking" or "Quick" - this function + does not re-validate it (every caller's own normalization is total, so + there is no path that could hand it anything else). Checked-and-applied + inside the SAME asyncio.to_thread hop as a deliberate race-preemption: + see set_ollama_reasoning_mode's inline comment for why. Shared by this + file's own setOllamaReasoningMode intent (Settings-dialog Ollama page) + and backend/composer.py's setReasoningLevel intent (composer's own + quick-access popover) so both surfaces apply a change identically.""" + await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode) + + def _reapply_if_ollama_is_still_the_live_provider() -> None: + if api_provider.is_local_ollama_mode(): + api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode}) + + await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider) + + +async def apply_llama_cpp_reasoning_mode(manager: SettingsManager, mode: str) -> str | None: + """Same contract as apply_ollama_reasoning_mode, for Llama.cpp. Returns a + human-readable failure message if the live re-apply fails (the mode is + ALREADY persisted regardless - only the live effect is delayed to the + next mode switch/restart), or None on success / when Llama.cpp is not the + active provider. Returns rather than writes a notice directly: this + file's own setLlamaCppReasoningMode intent writes the message into its + page-local llama_notice cell; backend/composer.py's intent has no such + cell and surfaces it through the shared NotificationState banner + instead - that decision belongs to each caller, not to this function.""" + await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode) + + def _reapply_if_llama_cpp_is_still_the_live_provider() -> None: + if api_provider.is_local_llama_cpp_mode(): + settings = _locked_llama_cpp_settings(manager) + settings["reasoning_mode"] = mode + api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings) + + try: + await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider) + except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential) + return f"Reasoning mode saved, but could not be applied to the live model: {exc}" + return None + + def _redact(text: str, secret: Any) -> str: # Post-review fix: some HTTP client libraries embed request parameters # (including a rejected API key) directly in exception text - the @@ -642,40 +687,37 @@ def _reset() -> None: await bus.publish("app-settings") async def set_ollama_reasoning_mode(mode: str): + # Checked and applied inside the SAME to_thread hop, not split across + # an await boundary: a concurrent mode switch (the toolbar's provider + # selector) landing in a gap between a separate check and a later + # apply could otherwise force the live provider back to Ollama after + # the user had already switched away - the same class of + # stale-check-then-await race the R7.4a audit found and fixed + # elsewhere in this file. + # Legacy-parity note (corrected after adversarial review): this live + # re-apply is NOT what legacy's own settings dialog did. + # graphlink_settings_bridge.py's setOllamaReasoningMode called + # _reinitialize_main_window_agent() unconditionally, which just + # rebuilds ChatAgent - it never touched the live + # OLLAMA_REASONING_MODE global api_provider.py's chat dispatch + # actually reads. Legacy's ONLY path that ever updated that global + # was a completely separate control - the composer toolbar's + # setReasoningLevel - which the Settings dialog was never wired to. + # So in real legacy usage, changing reasoning mode via Settings + # persisted the value but left the live think= kwarg stale until the + # next provider-mode switch or restart. This re-apply is a genuine + # fix for that gap, not a port of existing legacy behavior - gated on + # is_local_ollama_mode() so it can't be the mechanism that forces a + # user who already switched to a different provider back onto + # Ollama. + # + # R7.5d: this apply/re-apply sequence is now shared with + # backend/composer.py's own setReasoningLevel intent - see + # apply_ollama_reasoning_mode's own docstring above. mode = str(mode) if mode not in _OLLAMA_REASONING_MODES: return - await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode) - - def _reapply_if_ollama_is_still_the_live_provider() -> None: - # Checked and applied inside the SAME to_thread hop, not split - # across an await boundary: a concurrent mode switch (the - # toolbar's provider selector) landing in a gap between a - # separate check and a later apply could otherwise force the - # live provider back to Ollama after the user had already - # switched away - the same class of stale-check-then-await - # race the R7.4a audit found and fixed elsewhere in this file. - # Legacy-parity note (corrected after adversarial review): this - # live re-apply is NOT what legacy's own settings dialog did. - # graphlink_settings_bridge.py's setOllamaReasoningMode called - # _reinitialize_main_window_agent() unconditionally, which just - # rebuilds ChatAgent - it never touched the live - # OLLAMA_REASONING_MODE global api_provider.py's chat dispatch - # actually reads. Legacy's ONLY path that ever updated that - # global was a completely separate control - the composer - # toolbar's setReasoningLevel - which the Settings dialog was - # never wired to. So in real legacy usage, changing reasoning - # mode via Settings persisted the value but left the live - # think= kwarg stale until the next provider-mode switch or - # restart. This re-apply is a genuine fix for that gap, not a - # port of existing legacy behavior - gated on - # is_local_ollama_mode() so it can't be the mechanism that - # forces a user who already switched to a different provider - # back onto Ollama. - if api_provider.is_local_ollama_mode(): - api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode}) - - await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider) + await apply_ollama_reasoning_mode(manager, mode) await bus.publish("app-settings") async def set_ollama_model_assignment(task: str, value: str): @@ -866,33 +908,31 @@ def _persist() -> None: await bus.publish("app-settings") async def set_llama_cpp_reasoning_mode(mode: str): + # Same shape as set_ollama_reasoning_mode's own live re-apply: gated + # on is_local_llama_cpp_mode(), checked and applied inside the SAME + # to_thread hop so a concurrent provider-mode switch can't be + # clobbered back to Llama.cpp. Unlike Ollama's version, this + # genuinely CAN fail: initialize_local_provider's Llama.cpp branch + # re-validates chat_model_path (must still be a real, existing .gguf + # file) every time it runs - if that file was deleted/moved from + # under an already-active session since Llama.cpp was last + # activated, this raises. The mode is already persisted regardless, + # so a failure here only means it takes effect on the next mode + # switch/restart instead of immediately - not a lost setting. + # + # R7.5d: this apply/re-apply sequence is now shared with + # backend/composer.py's own setReasoningLevel intent - see + # apply_llama_cpp_reasoning_mode's own docstring above. mode = str(mode) if mode not in _LLAMA_CPP_REASONING_MODES: return - await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode) - - def _reapply_if_llama_cpp_is_still_the_live_provider() -> None: - # Same shape as set_ollama_reasoning_mode's own live re-apply: - # gated on is_local_llama_cpp_mode(), checked and applied inside - # this SAME to_thread hop so a concurrent provider-mode switch - # can't be clobbered back to Llama.cpp. Unlike Ollama's version, - # this genuinely CAN fail: initialize_local_provider's - # Llama.cpp branch re-validates chat_model_path (must still be - # a real, existing .gguf file) every time it runs - if that - # file was deleted/moved from under an already-active session - # since Llama.cpp was last activated, this raises. The mode is - # already persisted above regardless, so a failure here only - # means it takes effect on the next mode switch/restart instead - # of immediately - not a lost setting. - if api_provider.is_local_llama_cpp_mode(): - settings = _locked_llama_cpp_settings(manager) - settings["reasoning_mode"] = mode - api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings) - - try: - await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider) - except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential) - llama_notice["value"] = f"Reasoning mode saved, but could not be applied to the live model: {exc}" + failure = await apply_llama_cpp_reasoning_mode(manager, mode) + if failure is not None: + llama_notice["value"] = failure + # NOTE: on success, do NOT clear llama_notice to "" - this preserves + # the exact pre-existing behavior (today's code never clears it on + # success either); do not "improve" this as a drive-by fix in this + # increment. await bus.publish("app-settings") async def set_llama_cpp_chat_format(chat_format: str): diff --git a/backend/tests/test_backend_composer.py b/backend/tests/test_backend_composer.py index 9b74588..b611c78 100644 --- a/backend/tests/test_backend_composer.py +++ b/backend/tests/test_backend_composer.py @@ -4,6 +4,10 @@ import pytest +import api_provider +import graphlink_task_config as config +from graphlink_licensing import SettingsManager + from backend.composer import ComposerDocument, ComposerError, register_composer from backend.events import SessionBus from backend.notifications import NotificationState, register_notifications @@ -31,10 +35,31 @@ def make_bus(): return bus, composer, counter, notifications, recorder +def _make_bus_with_settings(settings_manager): + # R7.5d: a small, self-contained helper for the reasoning-toggle tests + # below, which need a real SettingsManager wired all the way through + # register_composer - kept separate from make_bus() above (which must + # keep working exactly as it does today for the pre-existing tests). + bus = SessionBus("composer-reasoning-test") + counter = register_token_counter(bus) + notifications = register_notifications(bus) + composer = register_composer(bus, counter, settings_manager, notifications) + recorder = Recorder() + bus.attach(recorder) + return bus, composer, notifications, recorder + + # -- composer ---------------------------------------------------------------- -def test_default_payload_matches_generated_validator_shape(): +def test_default_payload_matches_generated_validator_shape(monkeypatch): + # R7.5d: reasoningSelection is now computed from live api_provider + # module state (see test_reasoning_selection_capability_reflects_ + # active_local_provider below), not hardcoded - pin it here so this + # shape assertion is deterministic regardless of any other test's + # ambient global provider state. + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) payload = ComposerDocument().payload() assert set(payload) == {"draft", "context", "route", "request", "capabilities"} assert set(payload["draft"]) == {"id", "text", "contextMode", "sendMode", "restored"} @@ -68,6 +93,128 @@ async def run(): asyncio.run(run()) +def test_reasoning_selection_capability_reflects_active_local_provider(monkeypatch): + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is True + + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is True + + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + assert ComposerDocument().payload()["capabilities"]["reasoningSelection"] is False + + +def test_set_reasoning_level_reapplies_live_when_ollama_is_active(tmp_path, monkeypatch): + manager = SettingsManager(tmp_path / "session.dat") + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + calls = [] + monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a)) + + async def run(): + bus, document, _, _ = _make_bus_with_settings(manager) + await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"]) + + asyncio.run(run()) + + assert manager.get_ollama_reasoning_mode() == "Thinking" + assert calls == [(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": "Thinking"})] + + +def test_set_reasoning_level_reapplies_live_when_llama_cpp_is_active(tmp_path, monkeypatch): + manager = SettingsManager(tmp_path / "session.dat") + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + calls = [] + monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a)) + + async def run(): + bus, document, _, _ = _make_bus_with_settings(manager) + await bus.dispatch_intent("app-composer", "setReasoningLevel", ["quick"]) + + asyncio.run(run()) + + assert manager.get_llama_cpp_reasoning_mode() == "Quick" + assert len(calls) == 1 + provider, settings = calls[0] + assert provider == config.LOCAL_PROVIDER_LLAMACPP + assert settings["reasoning_mode"] == "Quick" + + +def test_set_reasoning_level_llama_cpp_reapply_failure_surfaces_a_notification(tmp_path, monkeypatch): + manager = SettingsManager(tmp_path / "session.dat") + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: True) + + def _boom(*a, **k): + raise RuntimeError("boom") + + monkeypatch.setattr(api_provider, "initialize_local_provider", _boom) + + async def run(): + bus, document, notifications, recorder = _make_bus_with_settings(manager) + await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"]) + return notifications, recorder + + notifications, recorder = asyncio.run(run()) + + payload = notifications.payload() + assert payload["visible"] is True + assert "boom" in payload["message"] + assert recorder.topics_seen().count("notification") == 1 + # The notification path must not short-circuit before the handler's own + # trailing publish - a future edit that early-returns after showing the + # notification would silently stop the composer UI from ever reflecting + # the persisted reasoning_level change. + assert recorder.topics_seen().count("app-composer") == 1 + # Persisted despite the live-apply failure. + assert manager.get_llama_cpp_reasoning_mode() == "Thinking" + + +def test_set_reasoning_level_skips_apply_in_cloud_mode(tmp_path, monkeypatch): + manager = SettingsManager(tmp_path / "session.dat") + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: False) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + calls = [] + monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a)) + + async def run(): + bus, document, _, recorder = _make_bus_with_settings(manager) + await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"]) + return document, recorder + + document, recorder = asyncio.run(run()) + + assert document.reasoning_level == "thinking" + assert calls == [] + assert recorder.topics_seen().count("app-composer") == 1 + + +def test_set_reasoning_level_busy_guard_no_ops_even_with_settings_manager(tmp_path, monkeypatch): + manager = SettingsManager(tmp_path / "session.dat") + monkeypatch.setattr(api_provider, "is_local_ollama_mode", lambda: True) + monkeypatch.setattr(api_provider, "is_local_llama_cpp_mode", lambda: False) + calls = [] + monkeypatch.setattr(api_provider, "initialize_local_provider", lambda *a, **k: calls.append(a)) + + async def run(): + bus, document, _, recorder = _make_bus_with_settings(manager) + document.begin_request("req-1") + publishes_before = recorder.topics_seen().count("app-composer") + await bus.dispatch_intent("app-composer", "setReasoningLevel", ["thinking"]) + publishes_after = recorder.topics_seen().count("app-composer") + return document, publishes_before, publishes_after + + document, publishes_before, publishes_after = asyncio.run(run()) + + assert document.reasoning_level == "quick" + assert calls == [] + assert publishes_after == publishes_before + + # -- R4: request lifecycle (begin_request/end_request) ------------------------ diff --git a/web_ui/src/app/chrome/Composer.test.tsx b/web_ui/src/app/chrome/Composer.test.tsx index d184526..70d5646 100644 --- a/web_ui/src/app/chrome/Composer.test.tsx +++ b/web_ui/src/app/chrome/Composer.test.tsx @@ -238,6 +238,7 @@ describe("Composer", () => { ], }, }, + request: { ...initialComposerState.request, canSend: true }, }, }); render( @@ -251,6 +252,46 @@ describe("Composer", () => { expect(setReasoningLevel).toHaveBeenCalledWith("thinking"); expect(screen.queryByText("Thinking Mode (Enable CoT)")).toBeNull(); }); + + it("Reasoning trigger is disabled when reasoningSelection is false", () => { + const { store } = makeStore({ + composer: { + capabilities: { ...initialComposerState.capabilities, reasoningSelection: false }, + request: { ...initialComposerState.request, canSend: true }, + }, + }); + const { container } = render( + + {/* @ts-expect-error - test double */} + + , + ); + expect(container.querySelector('[data-overlay-trigger="reasoning"]')).toBeDisabled(); + }); + + it("Reasoning trigger is disabled while a request is busy even when reasoningSelection is true", () => { + const { store } = makeStore(); + const { container } = render( + + {/* @ts-expect-error - test double */} + + , + ); + expect(container.querySelector('[data-overlay-trigger="reasoning"]')).toBeDisabled(); + }); + + it("Reasoning trigger is enabled when reasoningSelection is true and canSend is true", () => { + const { store } = makeStore({ + composer: { request: { ...initialComposerState.request, canSend: true } }, + }); + const { container } = render( + + {/* @ts-expect-error - test double */} + + , + ); + expect(container.querySelector('[data-overlay-trigger="reasoning"]')).not.toBeDisabled(); + }); }); describe("TokenCounter", () => { diff --git a/web_ui/src/app/chrome/Composer.tsx b/web_ui/src/app/chrome/Composer.tsx index f5ff1a3..1d836ae 100644 --- a/web_ui/src/app/chrome/Composer.tsx +++ b/web_ui/src/app/chrome/Composer.tsx @@ -107,6 +107,7 @@ export function Composer({ store, sceneStore }: { store: ComposerStore; sceneSto data-overlay-trigger="reasoning" aria-haspopup="dialog" aria-pressed={overlays.isOpen("reasoning")} + disabled={!composer.capabilities.reasoningSelection || !composer.request.canSend} onClick={() => overlays.toggle("reasoning", "popover")} > Reasoning