Skip to content

feat(channels): a /model command for Telegram and Discord - #394

Merged
plombeer31 merged 6 commits into
mainfrom
feat/remote-model-command
Sep 11, 2026
Merged

feat(channels): a /model command for Telegram and Discord#394
plombeer31 merged 6 commits into
mainfrom
feat/remote-model-command

Conversation

@plombeer31

@plombeer31 plombeer31 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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, so openrouter/anthropic/claude-opus-4 works.

Registered in Telegram's setMyCommands alongside 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:

  1. setProviderDefaultChatModelInConfig (when a model was named),
  2. reloadLlmProvider(id) — or reloadLlmProviders() for a provider the registry has not built yet,
  3. providerRegistry.setActive(id) + setActiveTextProviderInConfig(id).

Then it stamps the chat's session with session-llm.ts's llm metadata 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 /model case and passes a code formatter (bare on Telegram, backticks on Discord). The handlers stay parallel — no unification.

Reporting the model that is actually running

chatModelOf (the config pin) and displayModelOf (what an operator reads) are deliberately two things.

The stamp uses the pin, byte-identical to what executeTurn writes. The report and the confirmation use displayModelOf, which for the active llama-server entry with no pin falls back to localModels.managed.modelId — the same first legs, in the same order, as resolveActiveModelName() in bootstrap.ts, which is what feeds the pricing key and every message_sent event. 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. bootstrap computes that name for resolved.activeTextProvider alone; the report walks every configured provider. localModels.managed describes one daemon — the managed one this install starts — so applying it to a second llama-server entry (a box on the LAN; resolve-run-mode.ts picks the worker leg with a find, 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 selectChatModel on reopen (session-model-restore.tsplanModelRestore), writing that id into defaultChatModel — the exact poisoning the next section refuses.

Run mode is part of the answer

Switching the active provider is how a deployment leaves fusion: resolveRunMode derives the effective mode from llm.activeTextProvider on 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 the fusion.delegate tool and the ### fusion guidance from every session and invalidates every session's KV prefix (bootstrap.ts gates the fan-out descriptor on effective === "fusion"); "Now on local-llama." is not an adequate answer to that.

So the bare report carries a Run mode: line built from the same describeRunMode the 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 content past 2000 characters and DiscordApi.sendMessage chunks silently at DISCORD_MESSAGE_LIMIT; Telegram's own limit is 4096 and outbound-sender.ts chunks 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-compatible entries, the uncapped report was 4962 characters on Discord — three messages, 1983 / 1975 / 1002 — and 4694 on Telegram, which sendOutbound split 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 apiKeyEnvVar are parseOptionalString, 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 the llama-server pin refusal both interpolated the model id whole, and resolveTarget's no-API-key refusal printed apiKeyEnvVar whole while providerLine clipped 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 to defaultChatModel is the whole one, and a test asserts it. Provider ids are not clipped: PROVIDER_ID_RE already 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.

  • The unknown-provider 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.
  • The ambiguous-prefix list is the answer: the sentence's whole job is "say which one", and a candidate that is hidden cannot be picked, with no way to page through the rest from a chat. So it is fitted to a character budget rather than an entry count, and everything that fits in one message prints — all sixty 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 /model can 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.activeTextProvider is one config setting, and bootstrap's resolveActiveLlmSlice re-reads it per inference attempt ("Re-read on every inference so TUI setActive hot-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.

/model therefore 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 — executeTurn re-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 (the ownerUserId check) and Discord drops everything outside ownerUserIds in route, both before the slash dispatch runs. /model adds no second check; a test in each file asserts a non-owner /model sends 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.

  • a model id on a llama-server provider — refused. The llama-server factory never reads entry.defaultChatModel (register-built-in-providers.ts; every other kind's factory does), but resolveActiveModelName() reads it first, ahead of localModels.managed.modelId. A pin there would rename the model in the report, in the cost lookup and in every message_sent event while the daemon carried on serving its own GGUF — and no chat command clears it again. The TUI cannot reach that state either (openChatModelPicker and ensureInlineModels both refuse non-cloud kinds), so neither does this. The refusal points at the Local Models tab.
  • provider cannot authenticate — two cases. An openrouter/aimlapi/gemini entry with no resolvable key, and any entry that declares its own apiKeyEnvVar (which is how the known-service presets — Groq, Nous, Anthropic — are written by providers-wizard-build-entry.ts, all under kind: "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 keyless openai-compatible entry 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.
  • unknown id — names the configured providers and the /model <provider> <model-id> form.
  • ambiguous prefix — a token that prefixes more than one provider id (openopenrouter, openai-compat). The refusal names the candidates, as many as one message holds.
  • a token starting with a slash (/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.
  • trailing arguments — refused, not dropped: a provider id and a model id are one token each.
  • a turn in flight — above.
  • a failed config write or provider reload — comes back as a message, and rolls back. The model pin must be written before the rebuild (the rebuild reads it off disk), so on failure the pin and the in-memory active provider are both restored before answering: "Could not switch" means nothing switched, and a later bare /model cannot report a model that was just rejected. restoreProviderDefaultChatModelInConfig is the undo half, and the only writer that can express unsetsetProviderDefaultChatModelInConfig rejects an empty id.
  • anything else that throws — logged, and answered. runModelCommand wraps its whole body in one catch, 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 entry getConfig() / resolveLlmConfig() — and that is the one most likely to fail, because /model's own setActiveTextProviderInConfig calls resetConfigCache(), 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 bare void this.onDispatch(...). It now answers Could not run /model: <reason>.

One config-type gap fixed along the way

apiKeyEnvVar was missing from the llm.providers[] shape in AtomicAgentConfig, although parseLlmProviders has always parsed and carried it (it is on UserLlmProviderEntry, and resolveLlmProviderApiKey reads it). Added to the mirror; no behaviour change of its own.

Test evidence

src/channels/telegram/inbound-model-command.test.ts (35) and src/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 isolated ATOMIC_AGENT_STATE_DIR, then asserts on getConfig() — 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_KEY included) are saved, cleared and restored per test, so an exported key cannot decide a "no API key" assertion either way.

The fixture carries a groq preset (openai-compatible + apiKeyEnvVar) next to a keyless openai-compat entry, so the two are told apart by what the code actually keys on, and an aimlapi entry 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 real ProviderRegistry.fromConfig: without it the "provider not yet in the registry" case would keep passing for a config production refuses (openai-compatible requires baseUrl and defaultChatModel).

src/tui/persist-llm-provider.test.ts now tests restoreProviderDefaultChatModelInConfig directly against a real config file — restore, clear (asserting the key is deleted from the file, not written as null), leave other providers alone, and no-op on an unknown id.

src/channels/telegram/telegram-channel.test.ts asserts the full setMyCommands menu, /model included.

  • 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 unhandled spawn llama-server EACCES from local-models-orchestrator-auto-update.test.ts — sandbox noise on origin/main too, 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:

  • whole feature removed (model-command.ts deleted, both handlers + telegram-channel.ts + persist-llm-provider.ts + config-schema.ts back to origin/main): 71 of 123 fail.
  • second-round fix reverted (model-command.ts + config-schema.ts back to bf546815): 32 fail.
  • third-round fix reverted (model-command.ts back to be5f676a, tests kept): 18 fail. The config-parse case fails there by rejecting handleInboundText outright, which is the defect it exists to pin.
  • fourth-round fix reverted (model-command.ts back to fb14b122, tests kept): 10 fail — the five new cases per channel, the same five names on both surfaces.
  • Two of those ten — "names every candidate an ambiguous prefix matches", one per channel — pass at be5f676a and bf546815 and fail only at fb14b122. 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.
  • Two of the older cases pass either way by design, and are labelled controls rather than evidence: "accepts a preset provider once its declared env var is set" (the switch must still work) and "stays quiet about an ordinary Local → Cloud switch" (a guard on the run-mode note, not on old behaviour).

What this does not cover

  • The switch is global, not per chat. A /model from Telegram changes what the TUI and every other session run on. That is the existing model-selection contract, not something this command introduces: executeTurn overwrites every session's llm stamp from the global resolveLlmConfig at 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.
  • There is no /runmode verb. /model reports 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.
  • No unpin verb. /model <provider> <model-id> overwrites a pin, but nothing clears one back to "provider default" from a chat; restoreProviderDefaultChatModelInConfig is 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 /model can overwrite — but a config hand-edited to a bad pin still needs the TUI.
  • localModels.mode is not consulted, here or in bootstrap. An external-mode llama-server whose config still carries a localModels.managed.modelId from an earlier download reports that stale id, not "provider default" — exactly as resolveActiveModelName() does, so the channel agrees with every message_sent event 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 in bootstrap, not here. An external-mode entry with no managed id does read "provider default". The two further legs resolveActiveModelName() has — the operator --alias from the /props probe and the prompt-profile id — live inside bootstrap and are not on the runtime interface, so neither is reachable from a channel at all.
  • The report lists providers, not models. The issue asked for "what can be selected"; what this lists is the configured providers and their models. There is no /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.
  • No model-id validation on cloud providers. /model openrouter typo-9 is 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 /model fixes. (This is not the llama-server case above, where nothing ever rejects it.)
  • A bare token is a provider, never a model. /model gpt-5.4-mini is 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.
  • Error text is not clipped. The three unbounded names above are; the two error replies — Could not switch to <provider>: <reason> and the never-throws floor's Could 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 unknown activeTextProvider and 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.
  • A long-running turn blocks /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.
  • Embedding provider selection, /llm-style health checks, and adding a provider are still TUI-only.

plombeer31 and others added 6 commits September 10, 2026 18:41
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.
@plombeer31
plombeer31 merged commit 5c992db into main Sep 11, 2026
2 checks passed
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