feat(channels): a /model command for Telegram and Discord - #394
Merged
Conversation
Remote operators could read and steer a session from a chat but never change what it ran on, so a bot on someone else's machine was pinned to whatever model that machine was last told to use. /model reports the active provider and every configured one; /model <provider> [model-id] switches. It writes the same state the TUI's LLM pane owns — the active text provider and its defaultChatModel in the user config, plus the per-session llm stamp — so there is no second, channel-local store to drift.
Review fixups on the `/model` command. The mid-turn refusal only covered the issuing chat's own session, but `llm.activeTextProvider` is global and `resolveActiveLlmSlice` re-reads it per inference attempt — provider, tool transport and tool-call adapter with it. A `/model` from one chat therefore landed on every other in-flight turn at its *next step*, not its next turn, which is exactly the hazard the refusal existed to prevent. It now gates on `turnController.busySessionIds()` and names how many sessions are running so the operator can wait or `/cancel`. Also: - A failed provider reload no longer leaves a torn config. The model pin has to be written before the rebuild (the rebuild reads it off disk), so the pin — and the in-memory active provider — are rolled back before answering "Could not switch"; a later bare `/model` can no longer report a model that was refused. - `stampChatSession` moved inside a `catch`. It ran after the try block, so a `sessionStore.save` failure rejected the whole command *after* the config write had succeeded: no reply at all on Telegram, an unhandled rejection on Discord's `void this.onDispatch(...)`. - `/model` registered in Telegram's `setMyCommands`, so it appears in the command menu and autocomplete like every other verb. - A one-token argument starting with a slash (`/model /vendor/m-9`) is answered with the command's shape instead of "Unknown provider .". - Trailing arguments are refused rather than silently dropped. - Tests: the API-key environment variables are saved, cleared and restored per test, so a developer or CI with a real OPENROUTER_API_KEY exported no longer fails (or vacuously passes) the "no API key" cases. - Tests: the `openai-compat` fixture gains `baseUrl`/`defaultChatModel`, which the real registry requires, and a new test builds the fixture through `ProviderRegistry.fromConfig` so the one stubbed seam (`reloadLlmProviders`) can no longer hide a config production refuses.
…ode change Second review round on #394. Four substantive findings. 1. `/model local-llama <model-id>` was accepted, changed no inference, and permanently renamed the model everywhere with no chat-side undo. The `llama-server` factory never reads `entry.defaultChatModel` (register-built-in-providers.ts), but `resolveActiveModelName()` in bootstrap.ts reads it FIRST — ahead of `localModels.managed.modelId` — so the pin fed the report, the pricing key and every `message_sent` event while the daemon kept serving its own GGUF. The TUI cannot reach that state (`openChatModelPicker` / `ensureInlineModels` refuse non-cloud kinds); now neither can a chat. `MODEL_PIN_IGNORED_KINDS` refuses the pin and points at the Local Models tab. 2. `/model <provider>` could drop a fusion deployment out of fusion — removing `fusion.delegate` and the `### fusion` guidance from every session and invalidating every KV prefix — and answer "Now on local-llama." Leaving fusion by switching provider is by design (`resolveRunMode` derives the effective mode from `activeTextProvider`); having no way to see it is not. The bare report now carries a `Run mode:` line built from the same `describeRunMode` the TUI's chip uses, and a switch that crosses into or out of fusion says so, naming the provider that restores it. Local <-> Cloud stays silent: the reply already names the provider. 3. On the default local-first install, bare `/model` answered "provider default" for a managed daemon whose GGUF id the config knows. `displayModelOf` now falls back to `localModels.managed.modelId` for a `llama-server` entry — the same first legs, in the same order, as `resolveActiveModelName()`. Display only: the session stamp keeps using the config pin, because a stamp carrying a model on a llama-server entry would make the TUI's session restore write it into `defaultChatModel` on reopen (session-model-restore.ts) — the exact poisoning (1) prevents. 4. The API-key refusal and the report's flag were keyed on provider kind alone, so known-service presets (Groq, Nous, Anthropic — all stored as `openai-compatible` with their own `apiKeyEnvVar`) were shown as usable and switched to with the variable unset, after which every turn 401s silently. An entry that declares an `apiKeyEnvVar` has said it needs a key; a bare keyless compat entry (LM Studio, Ollama) has not, and keeps working. The refusal and the report name the variable. `apiKeyEnvVar` was missing from `AtomicAgentConfig.llm.providers[]` although `parseLlmProviders` has always carried it — added. Plus the two nits: the Discord suite gains the one-token `<provider>/<model-id>` case it was missing, so the two files are the symmetric pair they claim to be (25 tests each), and `restoreProviderDefaultChatModelInConfig` gains direct tests in the file that owns it, including the unset branch `setProviderDefaultChatModel` cannot express. Tests: 7 of the 9 new Telegram assertions fail against this branch's own src (verified by reverting model-command.ts); the two that pass either way are deliberate controls — a preset with its key set must still switch, and an ordinary Local -> Cloud switch must stay quiet.
Third review round on #394. 1. The "never throws" contract was still not held. The post-switch config re-read was guarded, but the *entry* `getConfig()` / `resolveLlmConfig()` was outside every guard — and that is the read most likely to fail, because `/model`'s own `setActiveTextProviderInConfig` calls `resetConfigCache()`, so every invocation goes back to disk. A hand-edited or torn config therefore rejected the whole command: no reply at all on Telegram, an unhandled rejection on Discord's bare `void this.onDispatch(...)`. Reproduced with `llm.activeTextProvider` naming an entry that is not in `llm.providers`. The guarantee is now enforced in one place — `runModelCommand` wraps the whole body — rather than promised leg by leg; the legs with something better to say still catch first. 2. The managed-model fallback in `displayModelOf` was applied to every `llama-server` entry, not the active one. Its justification is parity with `resolveActiveModelName()`, and that only holds for the active provider — `bootstrap` computes that name for `activeTextProvider` alone, while the report walks the whole list. `localModels.managed` describes one daemon, so a second local entry (a box on the LAN; `resolve-run-mode.ts` picks the worker leg with a `find`, so more than one is expected) was reported as running a GGUF it has never seen. A non-active local entry now reads "provider default", which is what the config actually says about it. The `localModels.mode` leg is deliberately left alone: gating on it would make the channel disagree with the model name in every `message_sent` event and in the cost lookup. 3. The report enumerated every configured provider with no cap and no clip. Discord splits a message at 2000 characters and Telegram at 4096, so an install with a dozen `openai-compatible` entries turned one answer into several messages — and on Telegram a chunk a second 429 drops is simply lost. Sixty providers produced a 3962-character Discord report. The enumeration is now fitted to a budget under the tighter of the two limits, the overflow is counted rather than printed, and the active entry's line is budgeted up front so the cap can never drop the provider the report is about. Unbounded names — model ids, `apiKeyEnvVar`, the typed token — are clipped; provider ids are not, because `PROVIDER_ID_RE` already caps them at 32 characters. The `Configured:` list in the unknown-provider refusal is capped the same way. Plus the nit: the unreachable `return head` in `runModeChangeNote` is gone, the fusion branch being the only shape the two guards above can leave. Tests: 5 new cases per channel file (30 each, still symmetric). All 10 fail against this branch's own src with `model-command.ts` reverted — the config-parse case fails by rejecting `handleInboundText` outright, which is the defect.
…andidates
Two of the three answers that interpolate a name the config schema does
not bound were left uncapped by the previous round, and both are the
*likeliest* to carry a long one, because the operator has just typed it:
- the switch confirmation ("Now on X · <model>"), and
- the llama-server pin refusal ("Pinning <model> would rename it …").
A Discord-legal 2000-character `/model openrouter <1982 m's>` answered in
2041 characters and `/model local-llama <1981 m's>` in 2352, both past
`DISCORD_MESSAGE_LIMIT`, so `DiscordApi.sendMessage` chunked each into
two. A third path had the same hole: `resolveTarget`'s no-API-key refusal
printed `apiKeyEnvVar` whole, while `providerLine` clips it. All three now
go through `clipName`, so the 48-character rule holds everywhere a name is
printed. The value written to the config is untouched — clipping is a
display concern, and a test asserts the stored pin is still the whole id.
The other half is a regression this round introduced. `joinIds`' flat
twelve-entry cap was right for the unknown-provider `Configured:` list,
which is orientation, but wrong for the ambiguous-prefix refusal, whose
entire job is "say which one": on an install with sixty `compat-*`
providers it answered "compat matches compat-provider-0 … and 48 more —
say which one", hiding the very information the sentence asks the operator
to act on, and buying nothing — the full list was 1220 characters on
Telegram and 1342 on Discord, inside both limits. Ambiguous candidates are
now fitted to a character budget instead: everything that fits is printed,
and only an install past one message's worth gets a count, together with
the one thing that shortens the list from a chat.
Five new cases per channel, symmetric across both surfaces.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Discord
#feedback-and-bugs,thegreatteacher, 2026-09-09 21:42 / 21:44 UTC: "Some crucial features should be managable through telegram or discord. Such as being able to switch models. Currently, they are pretty limited." … "Therefore, important features of TUI should be accessible through telegram or discord commands." The maintainer replied that he'd add it.Both channels already had
/start,/help,/status,/sessions,/switch,/new,/cancel, and a session already remembers its own provider/model (#321). What was missing was the one verb that changes it — so a bot running on someone else's machine was pinned to whatever model that machine was last told to use, with no remote way out.What the command does
/model— report: the active provider and the model it actually runs, the run mode, the configured providers (active one marked, ones that cannot authenticate flagged with the variable to set), and the two forms below./model <provider>— switch provider, keeping that provider's own pinned model./model <provider> <model-id>— pin a model on that provider and activate it./model <provider>/<model-id>— the same in one token; it splits at the first slash, soopenrouter/anthropic/claude-opus-4works.Registered in Telegram's
setMyCommandsalongside the other verbs, so it shows up in the command menu and autocomplete.Where the state lives
No new store. The command writes exactly what the TUI's LLM pane writes, through the same helpers, in the same order as
ProvidersOrchestrator.selectChatModel:setProviderDefaultChatModelInConfig(when a model was named),reloadLlmProvider(id)— orreloadLlmProviders()for a provider the registry has not built yet,providerRegistry.setActive(id)+setActiveTextProviderInConfig(id).Then it stamps the chat's session with
session-llm.ts'sllmmetadata key, for the same reason the TUI stamps on selection: that stamp is what the TUI restores when it later opens the session.Shared logic lives in
src/channels/model-command.ts; each handler keeps its own/modelcase and passes acodeformatter (bare on Telegram, backticks on Discord). The handlers stay parallel — no unification.Reporting the model that is actually running
chatModelOf(the config pin) anddisplayModelOf(what an operator reads) are deliberately two things.The stamp uses the pin, byte-identical to what
executeTurnwrites. The report and the confirmation usedisplayModelOf, which for the activellama-serverentry with no pin falls back tolocalModels.managed.modelId— the same first legs, in the same order, asresolveActiveModelName()inbootstrap.ts, which is what feeds the pricing key and everymessage_sentevent. On the default local-first install that turns "Model: local-llama · provider default" into the GGUF id the config already knows.That fallback is scoped to the active entry on purpose, and the scope is the whole justification for it.
bootstrapcomputes that name forresolved.activeTextProvideralone; the report walks every configured provider.localModels.manageddescribes one daemon — the managed one this install starts — so applying it to a secondllama-serverentry (a box on the LAN;resolve-run-mode.tspicks the worker leg with afind, so more than one is a shape the code expects) would report that box as running a GGUF it has never seen. A non-active local entry reads "provider default", which is what the config actually says about it.They must not be merged: a stamp carrying a model on a llama-server entry would make the TUI's session restore call
selectChatModelon reopen (session-model-restore.ts→planModelRestore), writing that id intodefaultChatModel— the exact poisoning the next section refuses.Run mode is part of the answer
Switching the active provider is how a deployment leaves fusion:
resolveRunModederives the effective mode fromllm.activeTextProvideron purpose, so the two keys can never contradict each other, and the TUI's own LLM pane drops out of fusion the same way. What the TUI has and a chat did not is a place that says so — the run-mode chip. Leaving fusion removes thefusion.delegatetool and the### fusionguidance from every session and invalidates every session's KV prefix (bootstrap.tsgates the fan-out descriptor oneffective === "fusion"); "Now on local-llama." is not an adequate answer to that.So the bare report carries a
Run mode:line built from the samedescribeRunModethe TUI uses — including the "stored fusion, effective local" disagreement — and a switch that crosses into or out of fusion appends a line naming the provider that restores it. Ordinary Local ↔ Cloud switches stay silent: the reply already names the provider that caused it, and a paragraph on every switch is the noise that teaches operators to skip the paragraph that matters.Every answer has to fit in one chat message
A chat message is not a terminal pane. Discord rejects a
contentpast 2000 characters andDiscordApi.sendMessagechunks silently atDISCORD_MESSAGE_LIMIT; Telegram's own limit is 4096 andoutbound-sender.tschunks at 4000, where a chunk that takes a second 429 is dropped with a log line and no reply. So an oversized answer does not merely read badly — it becomes several messages, and on Telegram one of them can vanish.Three parts of an answer are unbounded. All three are fitted. Every figure below is a measurement through the real handler on the fixture in the tests, re-run for this revision.
The provider enumeration. On the fixture plus sixty
openai-compatibleentries, the uncapped report was 4962 characters on Discord — three messages, 1983 / 1975 / 1002 — and 4694 on Telegram, whichsendOutboundsplit into 3960 + 734, stranding the tail. It is now fitted to a budget under the tighter of the two limits with the overflow counted: 1703 characters on Discord and 1681 on Telegram, one message each, ending…and 44 more not shown — /model <provider> switches to any of them, listed or not.Nothing is lost by that: the active provider is named on the first line either way, and a provider that is not listed is still switchable by name. The active entry's own line is budgeted up front and emitted wherever it falls in the list, so the cap can never drop the one provider the report is about. Ordinary installs are untouched — on Telegram a fresh install is 224 characters and the five-provider fixture 394, byte-identical to before the cap existed.Names the config schema does not bound. A model id and an
apiKeyEnvVarareparseOptionalString, and the token in/model <token>is whatever was typed; each is clipped to 48 characters with a visible ellipsis. That now holds on every path that prints one, which it did not in the previous revision of this branch: the switch confirmation and thellama-serverpin refusal both interpolated the model id whole, andresolveTarget's no-API-key refusal printedapiKeyEnvVarwhole whileproviderLineclipped it. Those are the likeliest paths to carry a pathological name, because the operator has just typed it, and one Discord-legal 2000-character command reached two of them:/model openrouter <1982 m's>answered in 2041 characters and/model local-llama <1981 m's>in 2352, each past the Discord limit and therefore two messages. They now answer in 107 and 419. Clipping is display only — the id written todefaultChatModelis the whole one, and a test asserts it. Provider ids are not clipped:PROVIDER_ID_REalready caps them at 32 kebab-case characters, so clipping one would be dead code.The id lists in the two refusals, which are deliberately capped by different rules.
Configured:list is orientation, not a menu — the token matched nothing, so what the message has to teach is the form of the command plus enough real ids to recognise one. Twelve entries, then a count.compat-*candidates, 1342 characters on Discord and 1220 on Telegram. Only past a message's worth does a count appear (120 entries at the longest id the regex allows renders 41 of them in 1559 characters on Discord), and there the message also names the one thing that shortens the list from a chat: type more of the id.Worst case measured across every shape
/modelcan answer with: 1703 characters, one message on both surfaces. The single exception is error text, which is passed through whole — see "What this does not cover".The switch is global, and in-flight turns are refused
llm.activeTextProvideris one config setting, andbootstrap'sresolveActiveLlmSlicere-reads it per inference attempt ("Re-read on every inference so TUIsetActivehot-swap takes effect") — returning the tool transport and tool-call adapter with it. So a switch does not politely wait for anyone's next turn: it lands on the very next step of every turn already running, and can flip native_tools ↔ grammar underneath one./modeltherefore refuses whenever any session has a turn in flight, not just the issuing chat's —turnController.busySessionIds(), which is where every origin (Telegram per chat, Discord per channel, TUI, HTTP, scheduler) registers. Arguments are validated first, so a typo is still answered as a typo, and only a command that would actually change something is refused. The message says how many sessions are running so the operator can wait or/cancel; a successful switch says "Takes effect on the next message."Making selection genuinely per-session is a separate design call —
executeTurnre-stamps every session from the global config at the top of each turn, so it is not a small change (see "What this does not cover").Gating
Confirmed, not re-implemented: Telegram drops every non-owner update in
handleInboundText(theownerUserIdcheck) and Discord drops everything outsideownerUserIdsinroute, both before the slash dispatch runs./modeladds no second check; a test in each file asserts a non-owner/modelsends nothing and changes no config.Refusals and failure handling
A chat has no undo, so a write it could not take back must not happen at all. That is the shape of the refusal list.
llama-serverprovider — refused. The llama-server factory never readsentry.defaultChatModel(register-built-in-providers.ts; every other kind's factory does), butresolveActiveModelName()reads it first, ahead oflocalModels.managed.modelId. A pin there would rename the model in the report, in the cost lookup and in everymessage_sentevent while the daemon carried on serving its own GGUF — and no chat command clears it again. The TUI cannot reach that state either (openChatModelPickerandensureInlineModelsboth refuse non-cloud kinds), so neither does this. The refusal points at the Local Models tab.openrouter/aimlapi/geminientry with no resolvable key, and any entry that declares its ownapiKeyEnvVar(which is how the known-service presets — Groq, Nous, Anthropic — are written byproviders-wizard-build-entry.ts, all underkind: "openai-compatible") while that variable is unset. A kind-only check waved those through and every following turn 401'd with nothing in the channel to explain it. A bare keylessopenai-compatibleentry declares no variable — that is how LM Studio and Ollama are configured — and keeps working. The refusal and the report name the variable to set./model <provider> <model-id>form.open→openrouter,openai-compat). The refusal names the candidates, as many as one message holds./model /vendor/model-9, a model id typed with its vendor prefix) — answered with the command's shape rather than an "Unknown provider" naming nothing./modelcannot report a model that was just rejected.restoreProviderDefaultChatModelInConfigis the undo half, and the only writer that can express unset —setProviderDefaultChatModelInConfigrejects an empty id.runModelCommandwraps its whole body in onecatch, so the guarantee is enforced in a single place rather than promised leg by leg. The legs with something better to say still catch first (a failed session-store write after a successful switch, a post-switch config re-read), but the read with no message of its own is the entrygetConfig()/resolveLlmConfig()— and that is the one most likely to fail, because/model's ownsetActiveTextProviderInConfigcallsresetConfigCache(), so every invocation goes back to disk. A config hand-edited or torn since boot used to reject the whole command: no reply at all on Telegram, an unhandled rejection on Discord's barevoid this.onDispatch(...). It now answersCould not run /model: <reason>.One config-type gap fixed along the way
apiKeyEnvVarwas missing from thellm.providers[]shape inAtomicAgentConfig, althoughparseLlmProvidershas always parsed and carried it (it is onUserLlmProviderEntry, andresolveLlmProviderApiKeyreads it). Added to the mirror; no behaviour change of its own.Test evidence
src/channels/telegram/inbound-model-command.test.ts(35) andsrc/channels/discord/discord-inbound-model-command.test.ts(35) — the same 35 cases on both surfaces, differing only in id decoration and in the one name that has to differ (Telegram's "this chat" is Discord's "this channel"). Each drives the real handler and writes the real user config in an isolatedATOMIC_AGENT_STATE_DIR, then asserts ongetConfig()— the feature is "the chat writes the state the TUI writes", so a stubbed config writer would prove nothing. The API-key environment variables (GROQ_API_KEYincluded) are saved, cleared and restored per test, so an exported key cannot decide a "no API key" assertion either way.The fixture carries a
groqpreset (openai-compatible+apiKeyEnvVar) next to a keylessopenai-compatentry, so the two are told apart by what the code actually keys on, and anaimlapientry with no model of its own so the rollback test exercises the clear the pin branch and not just revert-to-previous.reloadLlmProvider(s)is the one stubbed seam, so one test in each file builds the same fixture through the realProviderRegistry.fromConfig: without it the "provider not yet in the registry" case would keep passing for a config production refuses (openai-compatiblerequiresbaseUrlanddefaultChatModel).src/tui/persist-llm-provider.test.tsnow testsrestoreProviderDefaultChatModelInConfigdirectly against a real config file — restore, clear (asserting the key is deleted from the file, not written asnull), leave other providers alone, and no-op on an unknown id.src/channels/telegram/telegram-channel.test.tsasserts the fullsetMyCommandsmenu,/modelincluded.npm run lint(tsc --noEmit) — clean.prettier --check— clean.npx vitest run src/channels src/session src/tui/providers src/tui/persist-llm-provider.test.ts— 788 passed, 53 files, 0 failed.npx vitest run src/tui src/runtime src/config src/llm— 4679 passed, 403 files, 0 failed. (One unhandledspawn llama-server EACCESfromlocal-models-orchestrator-auto-update.test.ts— sandbox noise onorigin/maintoo, no test fails.)Fails without the src change, all five controls re-run and counted for this revision, over the 123 tests in the four touched files:
model-command.tsdeleted, both handlers +telegram-channel.ts+persist-llm-provider.ts+config-schema.tsback toorigin/main): 71 of 123 fail.model-command.ts+config-schema.tsback tobf546815): 32 fail.model-command.tsback tobe5f676a, tests kept): 18 fail. The config-parse case fails there by rejectinghandleInboundTextoutright, which is the defect it exists to pin.model-command.tsback tofb14b122, tests kept): 10 fail — the five new cases per channel, the same five names on both surfaces.be5f676aandbf546815and fail only atfb14b122. That is the correct shape: they pin a cap that revision introduced, not a defect the earlier ones had. It is why the third-round control moves 10 → 18 rather than 10 → 20.What this does not cover
/modelfrom Telegram changes what the TUI and every other session run on. That is the existing model-selection contract, not something this command introduces:executeTurnoverwrites every session'sllmstamp from the globalresolveLlmConfigat the top of each turn, so the stamp cannot make channel selection per-session as things stand — it only steers what the TUI restores. The in-flight guard above is what keeps the global switch from corrupting a running turn. This is a declared deviation from the issue, which asked for/model <id>to switch this chat's session; genuinely per-session selection is a design call for the maintainer, not something to smuggle in here./runmodeverb./modelreports the run mode and announces a fusion transition it caused, but it cannot set one — choosing fusion, or setting the worker count, is still TUI-only. A remote operator who leaves fusion by switching provider can get back in with/model <orchestrator>, and nothing else./model <provider> <model-id>overwrites a pin, but nothing clears one back to "provider default" from a chat;restoreProviderDefaultChatModelInConfigis reachable only from the rollback path. Since a llama-server pin is now refused outright, the state a chat can create is always one another/modelcan overwrite — but a config hand-edited to a bad pin still needs the TUI.localModels.modeis not consulted, here or inbootstrap. An external-mode llama-server whose config still carries alocalModels.managed.modelIdfrom an earlier download reports that stale id, not "provider default" — exactly asresolveActiveModelName()does, so the channel agrees with everymessage_sentevent and with the cost lookup. Gating the channel on the mode would buy a truer report at the price of disagreeing with every other surface; if it is worth fixing it is worth fixing inbootstrap, not here. An external-mode entry with no managed id does read "provider default". The two further legsresolveActiveModelName()has — the operator--aliasfrom the/propsprobe and the prompt-profile id — live insidebootstrapand are not on the runtime interface, so neither is reachable from a channel at all./model list <provider>and no fuzzy model search. A headless deployment never refreshes the cloud catalogs (that is driven by the TUI's providers tab), so any model list it printed would be the stale bundled one./model openrouter typo-9is accepted and pinned; the gateway rejects it on the next turn. Validating against the bundled catalog would wrongly refuse models added since the release, which is worse than a wrong pin the next/modelfixes. (This is not the llama-server case above, where nothing ever rejects it.)/model gpt-5.4-miniis refused rather than guessed onto the active provider — guessing would silently pin a typo as the chat model and leave the agent broken until someone opened the TUI. The refusal names the form.Could not switch to <provider>: <reason>and the never-throws floor'sCould not run /model: <reason>— pass the underlying message through whole. That is a deliberate asymmetry: an error is diagnostic rather than a label, and truncating the one message whose job is to explain an unexpected failure would repeat exactly the mistake the ambiguous-prefix cap made. In practice these are short and come from this codebase rather than from what was typed — the two config-parse cases I can construct, an unknownactiveTextProviderand a duplicate provider id, both answer in a little under 300 characters, most of which is the config path the error names — but neither is bounded, so a provider factory or config validator that produced a multi-kilobyte message would still split the reply in two on Discord. Bounding it wants an error-sized budget rather than the 48-character name cap, and that is a call worth making against a real long error rather than a hypothetical one./model. A scheduled task holding a session busy makes the command refuse until it finishes or is cancelled. That is the price of refusing rather than deferring; deferring would need a queue and a drain hook that do not exist today./llm-style health checks, and adding a provider are still TUI-only.