emrg: the GUI shows the image capability the daemon is running with - #1395
Conversation
Rant 2026-09-17T16:53:02, requirement 3's GUI half: the TUI prints the effective value beside the model name, the GUI showed nothing, so the only way to learn whether an image would be understood was to send one and read the refusal. The renderer now reads the value the daemon reports it acts on, not config.toml's declaration — a different number the moment a /model switch or an [llm] vision reload happens. Three report points carry it: pong (the connect / heartbeat answer), status (main's broadcast of the same pong), and config_applied (the reload frame, #1380/#1387). A frame that carries no boolean says nothing, so the last reading stands rather than being reset; null means no frame has reported yet, which is not the same as false, and the badge is simply absent then. The header badge is where the effective value belongs: the settings form edits declarations ("this entry declares images"), a different question from "will an image be understood right now". Measured: renderer 516 passed (typecheck clean), GUI main 120 passed / 8 skipped, Python 3218 passed / 17 skipped, doc counts synced (scripts/check-node-test-count.py --write plus the per-file breakdown). Three mutation arms, each reddening its own named test and then reverted byte-identically: a pong with no vision becoming a false claim (bridge test), the badge rendered without a reading (Shell test), and the config_applied arm disabled (both tests).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-010456
Reviewed #1395 at head 3d109eb1, whose base is current master 6667fba7 — so CI run
35374604968 judges the tree that would land (test 3m32s, test-windows 8m24s, both green).
The head is a merge commit refreshing the branch rather than a change of content: nothing in
the PR's own diff moved.
The premise holds on master, read out of the daemon rather than out of the PR text:
emrg/server/daemon.py reports self.llm.config.vision at :739, :1715 and :4456
(pong / model_set / the reload broadcast), and :201 names vision in
BROADCAST_ON_RELOAD — i.e. the daemon really does move this value on a /model switch and on
[llm] vision hot-reload, which is exactly why a surface that read config.toml instead would
stale. The renderer now reads the reported value on all three arrival paths and never derives
it from the file.
The three-state handling is what makes this a reading rather than a claim. false is only
ever a reported false: status, pong and init each keep the last reading when the frame
carries no boolean, and null ("nothing has said") renders no badge at all
(Shell.tsx: appState.connected && appState.vision !== null). A surface that painted no images before anything had reported would be making the same mistake in the other direction,
so the distinction is load-bearing rather than padding, and the docstring says so.
Counts move with the change, coherently. Agent.md's renderer line goes 514 → 516 and the
per-file breakdown moves exactly where the two new tests are (daemonBridge 18 → 19,
Shell 30 → 31); the i18n drift guard's pinned dictionary size follows its own three new keys
(394 → 397 in both zh and en), which is the guard doing its job rather than being edited
around. SettingsPanel.tsx's per-model image marker is deliberately left alone: the panel
edits the file, so a declaration is the right thing to show there, and "will an image be
understood right now" is a property of the running daemon's active model — hence the header,
not the editor.
CI runs the renderer suite for real (.github/workflows/test.yml step "GUI renderer React
suite": npm run typecheck && npm test), so this is not a change whose only evidence is local.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260919-014848
Verified the two halves of the claim rather than the summary, because this PR's value
is entirely "the label reads what the daemon runs, not what the file says":
- The daemon really reports it.
emrg/server/daemon.pycarries the effective
value in thepongpayload ("vision": self.llm.config.vision, the value every
/modelswitch and every[llm] visionreload moves) and inconfig_applied
(BROADCAST_ON_RELOAD = frozenset({"vision"})). So the field the renderer reads is
one the server really sends, not one invented on the client. - The renderer really consumes it, and stays honest when nothing has reported it.
At this head:DaemonAppState.vision: boolean | nullwith defaultnull; all four
report points (pong / status / config_applied / model_set result) write it with the
typeof x === "boolean" ? x : s.visionshape, so a frame that omits the field does
not silently claimfalse;Shellrenders only whenvision !== null, and the
i18n keys exist in both locales ("images" / "no images").
That null is not false matters here: "no reading yet" and "images are off" are
different claims, and the test asserts the first (no badge) separately from the second
(no images).
CI green on both legs (test, test-windows), including the GUI suites. The Agent.md
count line is bumped with the two added tests, which is the one line this whole family
of PRs collides on.
|
Queue state for the third vote (measurement, not a vote).
— so run The landing tree was measured for the whole open queue in landing order:
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260919-023751
This vote is cast on the landing tree, not on the head. check-merge-freshness.py 1395
reports the head STALE (behind_by=3); the head does not move — refreshing it would void the two
standing votes — so I measured the tree this merge produces, and it is exactly the one already
covered by this cycle's ordered plan:
check-merge-plan-suite.py 1397 1395 --steps
base dc2a3249 (refs/remotes/origin/master)
step 1 (#1397) tree 6660494b8be7 suite OK: 3282 passed, 18 skipped (2m15s)
step 2 (#1395) tree 1ae9f8038c70 suite OK: 3282 passed, 18 skipped (2m15s)
every step healthy (2 suite run(s))
#1397 has since landed as 65d723f4, whose tree is 6660494b8be7 — byte-identical to step 1 — so
check-merge-landing-diff.py 1395 against the new base 65d723f4 now names 1ae9f8038c70,
the same tree step 2 ran. check-merge-tree-health.py 1395 → HEALTHY (guard OK). The landing
change is 10 paths; the tool's own note explains why reading diff(master, head) here would show
9 reversals this PR does not make — a reading hazard, not a defect.
The two halves, verified this cycle at the head rather than summarised.
- The daemon really reports the effective value.
emrg/server/daemon.pyputs
self.llm.config.visionon the wire inpongand gates the reload broadcast on
BROADCAST_ON_RELOAD = frozenset({"vision"})— the same value a/modelswitch and an
[llm] visionhot-reload move. A surface readingconfig.tomlinstead would go stale, which
is precisely what this PR removes. - The renderer really consumes it, and stays honest when nothing has reported.
DaemonAppState.vision: boolean | nulldefaults tonull; every arrival path writes it with
typeof x === "boolean" ? x : s.vision, so a frame without a boolean leaves the last reading
standing; andShell.tsxrenders the badge only whenappState.vision !== null, so a surface
never claims a capability before any frame has said. This is the third requirement of the
original rant (rant 2026-09-17T16:53:02): the effective value must be visible, not the
declared one.
Suites run at the head 3d109eb1 (renderer worktree given node_modules and .venv, since a
worktree missing them turns an environment difference into a false reading):
npm run typecheck— clean;- renderer
vitest run— 45 files, 516 passed (the count theAgent.mdline now claims); - GUI main
npm test— 120 pass, 8 skipped, 0 fail. The first run of this suite failed one
assertion (python=python3 (expected .venv/bin/python)); that is the worktree lacking.venv,
not a defect here — symlinking it makes the failure disappear with no code change.
This closes the last open item of the vision rant, so the release gate this PR was blocking on
loses one of its three blockers.
What
Rant
2026-09-17T16:53:02, requirement 3's GUI half: the clients must show the imagecapability the daemon is acting on, not the one
config.tomldeclares.The TUI already prints it beside the model name (
[gpt-4o img]/[deepseek-chat no-img]). TheGUI showed nothing, so the only way to learn whether an image would be understood was to send one
and read the refusal.
The renderer now reads the value the daemon reports it acts on, and the header connection strip
carries a badge next to the model name.
Where the number comes from
Declared and effective are different numbers the moment a switch or a reload happens: the running
daemon resolved the entry-key → top-level-default priority at startup, a
/modelswitch re-resolvesit, and an
[llm] visionedit moves it without a restart. Three frames already carry the live value,and all three are now read:
pong(daemon.pysendsvisionbesidemodel)statusconfig_applied(#1380 / #1387)Missing ≠ false. A frame that carries no boolean says nothing about the capability, so the last
reading stands;
nullmeans nothing has been reported yet and the badge is simply absent. Nothingin the renderer derives a capability from
config.toml, so the file cannot pretend to move it.Why the badge and not the settings form
SettingsPanelmarks a model entry from that entry's ownvisionkey — but the panel is editingthe file, so a declaration is the right thing to show there. "Does this entry declare images?" and
"will an image be understood right now?" are different questions, and only the second one can be
answered by the daemon; the header is where it belongs.
Verification
npm run typecheckclean,npm test516 passed (2 new)node --check main.js preload.js daemon_client.js,npm test120 passed / 8 skippeduv run pytest tests/ -q3218 passed / 17 skippedscripts/check-node-test-count.py --write(514 → 516) plus the per-file breakdown(daemonBridge 18 → 19, Shell 30 → 31);
tests/test_doc_counts.pygreenThree mutation arms, each reddening its own named test before being reverted byte-identically
(
daemonBridge.tssha256c3adc1a6…,Shell.tsxb903046a…):pongwith novisionbecoming a false claim (?? false) → the bridge test reddensappState.connected && vision !== null→true) → theShell test reddens, including the disconnected case
config_appliedarm disabled → both tests reddenNo test starts, stops or restarts a daemon.