Skip to content

Qt-removal R7.5d: composer thinking-mode toggle now has a real effect#152

Merged
dovvnloading merged 1 commit into
mainfrom
qt-removal/r7-5d-thinking-mode-fix
Jul 26, 2026
Merged

Qt-removal R7.5d: composer thinking-mode toggle now has a real effect#152
dovvnloading merged 1 commit into
mainfrom
qt-removal/r7-5d-thinking-mode-fix

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Fourth R7.5 parity sub-increment, after R7.5a (#147), R7.5b (#148/#149/#150), and R7.5c (#151).

The bug

The composer's own "Thinking Mode" quick-access popover only ever 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. Clicking the composer control looked like it worked and did nothing.

The fix

Route the composer's toggle through the same mechanism as the Settings page, rather than 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. The five pre-existing regression tests for that pair pass unmodified, confirming the extraction preserves behavior exactly (including the same-to_thread-hop race-avoidance property the original code relied on).
  • backend/composer.py's setReasoningLevel intent normalizes the id, branches on which local provider is actually live (is_local_ollama_mode()/is_local_llama_cpp_mode(), mutually exclusive by construction), calls the matching shared function, and surfaces a Llama.cpp live-reapply failure through the global notification banner (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. It also gained legacy's busy-guard, reusing the existing request.canSend computation.
  • capabilities.reasoningSelection, previously hardcoded true, now reflects the actually active provider. This closes a second, related gap: the composer's own prior-generation React island already disabled this control when reasoningSelection was false or a request was busy — the current SPA component had silently dropped both gates somewhere during the R2.3–R2.6 chrome consolidation. Composer.tsx now restores disabled={!composer.capabilities.reasoningSelection || !composer.request.canSend}, matching the island's own established expression.

Process note

This increment ran through a Workflow: a design-synthesis agent validated a hand-written recon brief against the actual code before implementation began, rather than implementing straight off the brief. It independently caught two real regressions the brief missed:

  1. An existing backend test asserted the old hardcoded true for reasoningSelection — once that field went live off real module state, the assertion would become order-dependent on whatever any other test in the suite last left api_provider's globals as.
  2. An existing frontend test relied on the fixture's default canSend: false — once the trigger became gated on that value, the test's own user.click() would silently stop firing.

It also found one real gap in the brief's own design: the original proposal never accounted for what should happen when a composer-triggered Llama.cpp live-reapply actually fails (it would have propagated as an uncaught, unexplained WS error). All three were fixed as part of the same change rather than discovered later as surprises.

Adversarial review

Zero correctness defects found. Confirmed: the extraction preserves the original race-avoidance property; test_settings.py has a genuine zero-line diff; the busy-guard's bare-return-before-validation ordering matches legacy exactly; request.canSend cannot get stuck non-idle since every dispatch exit path in backend/agents.py runs end_request in a finally. One worthwhile strengthening was flagged and applied immediately: the Llama.cpp-failure test only asserted the "notification" publish, not the trailing "app-composer" publish on the same code path.

Test plan

  • 874 backend tests (pytest backend/ tests/test_no_qt_anywhere.py) — 7 new
  • 1204 frontend tests (npm run check) — 4 new/updated, 0 lint errors
  • Burn-down gate unchanged at 152/84/68 — pure feature work
  • Codegen untouched (the wire type was already bool; only the computed value changed)
  • Adversarial review of the diff
  • Live-verified end-to-end against a real backend: selected Thinking Mode in the composer, watched the trigger's own label update and the popover close, then opened a second raw WebSocket connection to the same running backend and read the app-settings topic directly — ollamaReasoningMode had genuinely become "Thinking", proving the composer's click reached the real SettingsManager, not just its own display state

Note for the reviewer: the live verification above genuinely flipped the real ~/.graphlink/session.dat's persisted Ollama reasoning mode on the machine this was tested on — flagged there rather than silently reverted, since it's user-owned local state.

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 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit eee3d4c into main Jul 26, 2026
2 checks passed
@dovvnloading

Copy link
Copy Markdown
Owner Author

Second pass — the first fix was half a fix

A requested adversarial re-audit of this diff found that R7.5d wired the write path and left the read path broken, so the two halves disagreed. Pushed as ef5b46e.

What was still wrong

SettingsManager.get_ollama_reasoning_mode() defaults to "Thinking"; DEFAULT_REASONING_LEVEL is "quick". The composer displayed its own private field, so any user who had never touched the setting — the default state, not an edge case — saw:

value
persisted ollama_reasoning_mode Thinking
live api_provider.OLLAMA_REASONING_MODE Thinking
what the composer displayed Quick Mode (No CoT)

Reproduced against the real ~/.graphlink/session.dat, not inferred. The user sees "Quick", the model is doing CoT, and selecting the already-active "Quick" is the only way to reach the state the UI already claims.

This is arguably worse than the original bug: before, the toggle was inert and the label was meaningless decoration. After the first pass, the toggle genuinely wrote — which makes the label look authoritative while it starts out lying.

Both sync directions were broken too, measured rather than assumed:

  • composer → settings: persisted correctly, but published only app-composer, so an open Settings dialog stayed stale.
  • settings → composer: did not move the composer's label at all. Not a publish-timing issue — the composer read a private field, so no republish could have fixed it.

The fix

Remove the second source of truth rather than sync two. ComposerDocument gains an optional reasoning_level_reader wired to the active provider's persisted getter, branching on is_local_llama_cpp_mode() exactly as legacy's _reasoning() did (graphlink_composer_bridge.py:464-474). Legacy never stored a composer-local reasoning level either — precisely why the Qt composer and Settings dialog could never diverge. The private field survives only as the no-manager fallback, keeping register_composer(bus, counter) valid in unit tests.

Both reasoning intents now cross-publish the other surface's topic, guarded by a new SessionBus.has_topic() (cross-topic publishing is already established here — the composer publishes token-counter, the canvas publishes notification — but publish() raises UnknownTopicError on an unregistered topic, which a focused single-module test bus would hit).

Re-verified

All three reproductions now pass, plus a live browser check: the very reading that was wrong before now shows Thinking Mode (Enable CoT) against a persisted Thinking.

6 new regression tests. 880 backend / 1204 frontend, lint/typecheck/build clean, burn-down gate unchanged at 152/84/68.

Why every check missed it

This diff had a design-synthesis agent, an adversarial reviewer, and a live browser drive. All three missed it, because each was scoped to "is the write path correct?" — the question the increment posed — rather than "is the feature correct?". The browser drive literally rendered the wrong label and it was recorded as passing. A bidirectional binding needs both directions named in the review scope explicitly, or the unexamined direction survives every check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant