Files
hermes-agent/tests/hermes_cli/test_cli_init.py
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00

800 lines
31 KiB
Python

"""Tests for HermesCLI initialization -- catches configuration bugs
that only manifest at runtime (not in mocked unit tests)."""
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from agent.i18n import t
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
import importlib
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
if config_overrides:
_clean_config.update(config_overrides)
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
try:
with patch.dict(sys.modules, prompt_toolkit_stubs), \
patch.dict("os.environ", clean_env, clear=False):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}):
return _cli_mod.HermesCLI(**kwargs)
finally:
# The reload above re-executed cli.py while prompt_toolkit was stubbed
# with MagicMocks, permanently rebinding cli's module globals
# (``_pt_print``, ``_PT_ANSI``, …) to those mocks. ``patch.dict``
# restores ``sys.modules`` on exit, but NOT the names the reloaded
# module already bound — so ``sys.modules["cli"]`` is left with a
# mock ``_pt_print``, and ``cli._cprint`` then silently no-ops for
# every later test (one half of the order-dependent
# ``test_resume_quiet_stderr`` full-suite failure; the other half is
# the prompt_toolkit output cache reset in this dir's conftest).
# Reload once more with the real modules visible so cli's globals
# rebind cleanly.
import cli as _cli_restore
importlib.reload(_cli_restore)
class TestMaxTurnsResolution:
"""max_turns must always resolve to a positive integer, never None."""
def test_explicit_max_turns_honored(self):
cli = _make_cli(max_turns=25)
assert cli.max_turns == 25
def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self):
cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77})
assert cli_obj.max_turns == 77
class TestFallbackChainInit:
def test_merges_new_and_legacy_fallback_config(self):
cli = _make_cli(config_overrides={
"fallback_providers": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
],
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
})
assert cli._fallback_model == [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
{"provider": "nous", "model": "Hermes-4"},
]
class TestBusyInputMode:
def test_busy_input_mode_queue_is_honored(self):
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
assert cli.busy_input_mode == "queue"
def test_queue_command_works_while_busy(self):
"""When agent is running, /queue should still put the prompt in _pending_input."""
cli = _make_cli()
cli._agent_running = True
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_command_can_edit_remove_move_and_clear_pending_items(self):
cli = _make_cli()
cli.process_command("/queue first prompt")
cli.process_command("/queue second prompt")
cli.process_command("/queue edit 2 replacement prompt")
assert cli._pending_input_items() == ["first prompt", "replacement prompt"]
cli.process_command("/queue move 2 1")
assert cli._pending_input_items() == ["replacement prompt", "first prompt"]
cli.process_command("/queue rm 2")
assert cli._pending_input_items() == ["replacement prompt"]
cli.process_command("/queue clear")
assert cli._pending_input_items() == []
# queue.Queue bookkeeping must stay consistent after clear
assert cli._pending_input.unfinished_tasks == 0
assert cli._pending_input.empty()
def test_queue_command_preserves_prompts_that_start_with_non_subcommand_words(self):
cli = _make_cli()
cli.process_command("/queue maybe run later")
assert cli._pending_input.get_nowait() == "maybe run later"
def test_queue_add_allows_prompts_that_start_with_management_words(self):
cli = _make_cli()
cli.process_command("/queue add clear the logs after tests")
assert cli._pending_input.get_nowait() == "clear the logs after tests"
def test_queue_edit_preserves_voice_sentinel(self):
cli = _make_cli()
# Import AFTER _make_cli: the harness reloads the cli module, so the
# sentinel class must come from the same (reloaded) module the
# instance's handler compares against.
import cli as _cli_mod
cli._pending_input.put(_cli_mod._VoiceInputMessage("spoken prompt"))
cli.process_command("/queue edit 1 corrected prompt")
items = cli._pending_input_items()
assert len(items) == 1
assert isinstance(items[0], _cli_mod._VoiceInputMessage)
assert str(items[0]) == "corrected prompt"
def test_queue_out_of_range_indices_leave_queue_untouched(self):
cli = _make_cli()
cli.process_command("/queue only item")
cli.process_command("/queue rm 5")
cli.process_command("/queue edit 3 nope")
cli.process_command("/queue move 1 9")
assert cli._pending_input_items() == ["only item"]
def test_interrupt_mode_routes_busy_enter_to_interrupt(self):
"""In interrupt mode (default), Enter while busy goes to _interrupt_queue."""
cli = _make_cli()
cli._agent_running = True
text = "redirect"
if cli.busy_input_mode == "queue":
cli._pending_input.put(text)
else:
cli._interrupt_queue.put(text)
assert cli._interrupt_queue.get_nowait() == "redirect"
assert cli._pending_input.empty()
class TestPromptToolkitTerminalCompatibility:
@pytest.mark.platforms("linux")
def test_lf_enter_binding_respects_multiline_shortcuts(self):
"""Ctrl+J is reserved by default, with legacy LF-submit available as an opt-out.
Some thin POSIX PTYs deliver plain Enter as LF/c-j instead of CR/enter.
The default keeps c-j free for multiline input; disabling multiline
shortcuts restores c-j → submit on bare local POSIX terminals. Windows,
WSL, SSH sessions, Windows Terminal, and Ghostty always reserve c-j for
the Ctrl+Enter/Ctrl+J newline binding. See issue #22379.
The native-Windows arm of this behaviour is
``test_windows_leaves_ctrl_j_unbound`` below — it has to run on a real
Windows host, because ``_bind_prompt_submit_keys`` delegates to
``_preserve_ctrl_enter_newline()``, which short-circuits on
``sys.platform == "win32"``. Faking that here would assert the literal
in the ``if`` and nothing about how prompt_toolkit actually delivers
keys on a Windows console.
"""
import os as _os
from unittest.mock import patch as _patch
from prompt_toolkit.key_binding import KeyBindings
from cli import _bind_prompt_submit_keys
def submit_handler(event):
return None
# Default: Enter submits while c-j stays free for the newline binding.
# (Runs on the POSIX CI job; the native-Windows arm is the marked test
# below, so no sys.platform fake is needed here.)
with _patch.dict(_os.environ, {}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Legacy opt-out: bare POSIX LF/c-j submits for thin PTYs.
kb = KeyBindings()
_bind_prompt_submit_keys(
kb,
submit_handler,
multiline_shortcuts_enabled=False,
)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert bindings[("c-j",)] is submit_handler
# POSIX over SSH: c-j stays free so Ctrl+Enter (sent as LF by
# Windows Terminal / Kitty / mintty over SSH) inserts a newline.
with _patch.dict(_os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a
# stable env marker. Keep c-j free so Ctrl+J inserts a newline.
with _patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
@pytest.mark.platforms("windows")
def test_windows_leaves_ctrl_j_unbound(self):
"""On native Windows only enter submits; c-j is free for the newline
binding added separately in the prompt setup."""
from prompt_toolkit.key_binding import KeyBindings
from cli import _bind_prompt_submit_keys
def submit_handler(event):
return None
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
def test_cpr_warning_callback_is_disabled(self):
from cli import _disable_prompt_toolkit_cpr_warning
renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None)
app = SimpleNamespace(renderer=renderer)
_disable_prompt_toolkit_cpr_warning(app)
assert renderer.cpr_not_supported_callback is None
@pytest.mark.platforms("linux")
def test_cpr_gating_posix_suppresses_without_ssh(self, monkeypatch):
"""POSIX suppresses CPR without SSH.
The native-Windows arm (``_terminal_may_leak_cpr() is False``, plus
the ``PROMPT_TOOLKIT_NO_CPR`` override that outranks it) lives in
``tests/hermes_cli/test_cpr_local_leak.py`` under ``platforms("windows")``, where it
runs against a real Windows console.
"""
from cli import _terminal_may_leak_cpr
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"):
monkeypatch.delenv(var, raising=False)
assert _terminal_may_leak_cpr() is True
monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1")
assert _terminal_may_leak_cpr() is True
class TestHistoryDisplay:
def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys):
cli = _make_cli()
cli.conversation_history = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1"}, {"id": "call_2"}],
},
{"role": "tool", "content": "tool output 1"},
{"role": "tool", "content": "tool output 2"},
{"role": "assistant", "content": "All set."},
{"role": "user", "content": "A" * 250},
]
cli.show_history()
output = capsys.readouterr().out
assert "[You #1]" in output
assert "[Hermes #2]" in output
assert "(requested 2 tool calls)" in output
assert "[Tools]" in output
assert "(2 tool messages hidden)" in output
assert "[Hermes #3]" in output
assert "[You #4]" in output
assert "[You #5]" not in output
assert "A" * 250 in output
assert "A" * 250 + "..." not in output
def test_resume_without_target_lists_recent_sessions(self, capsys):
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "current",
"title": "Current",
"preview": "Current preview",
"last_active": 0,
},
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
cli._handle_resume_command("/resume")
output = capsys.readouterr().out
assert t("cli.session.recent_header") in output
assert "Checking Running Hermes Agent" in output
def test_sessions_command_no_args_lists_recent_sessions(self, capsys):
"""/sessions with no args prints the recent-sessions table (TUI parity).
Regression test: `sessions` was registered in the central command
registry and surfaced by /help and tab-completion, but the classic
CLI dispatcher had no elif branch for it, so the canonical name fell
through and printed `Unknown command: sessions`.
"""
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
# Drive it through the public dispatcher to also lock in the
# process_command wiring, not just the handler in isolation.
cli.process_command("/sessions")
output = capsys.readouterr().out
assert "Unknown command" not in output
assert t("cli.session.recent_header") in output
assert "Checking Running Hermes Agent" in output
assert "20260401_201329_d85961" in output
def test_sessions_with_target_delegates_to_resume(self):
"""/sessions <id_or_title> behaves identically to /resume <id_or_title>.
We intercept `_handle_resume_command` rather than the full resume
machinery (which would otherwise require simulating an entire session
switch). The contract under test is the dispatch wiring.
"""
cli = _make_cli()
with patch.object(cli, "_handle_resume_command") as mock_resume:
cli.process_command("/sessions Checking Running Hermes Agent")
mock_resume.assert_called_once_with(
"/resume Checking Running Hermes Agent"
)
class TestNestedDictModelDefaultPairing:
"""A dict-valued ``model.default`` must keep its nested provider paired.
``model.default: {provider: ..., model: ...}`` canonicalizes to the string
model AND the nested provider, so ``HermesCLI`` routes the model through
that provider instead of discarding it and falling back to the outer
merged ``model.provider`` (``"auto"`` — authoritative at runtime
resolution, which would route the model through the wrong active
provider).
"""
def test_nested_dict_default_keeps_provider_paired(self):
cli = _make_cli(config_overrides={
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "auto",
},
})
assert cli.model == "nested-default-model"
assert cli.requested_provider == "nous"
assert cli.provider == "nous"
def test_nested_dict_model_alias_keeps_provider_paired(self):
cli = _make_cli(config_overrides={
"model": {
"model": {"provider": "openai", "model": "nested-alias-model"},
"provider": "auto",
},
})
assert cli.model == "nested-alias-model"
assert cli.requested_provider == "openai"
assert cli.provider == "openai"
def test_flat_string_default_still_uses_outer_provider(self):
cli = _make_cli(config_overrides={
"model": {
"default": "flat-default-model",
"provider": "auto",
},
})
assert cli.model == "flat-default-model"
assert cli.requested_provider == "auto"
assert cli.provider == "auto"
def test_nested_provider_does_not_override_explicit_provider_arg(self):
cli = _make_cli(
config_overrides={
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "auto",
},
},
provider="anthropic",
)
assert cli.model == "nested-default-model"
assert cli.requested_provider == "anthropic"
assert cli.provider == "anthropic"
def test_whoami_command_is_dispatched_and_prints_cli_access(self, capsys):
"""/whoami is advertised in classic CLI help and must not fall through.
Regression test: the command existed in the shared registry, so it
appeared in /help and completion, but classic CLI dispatch lacked a
matching branch and printed `Unknown command: /whoami`.
"""
cli = _make_cli()
cli.process_command("/whoami")
output = capsys.readouterr().out
assert "Unknown command" not in output
assert output.strip()
def test_provider_prefixed_startup_model_overrides_stale_provider(self):
cli = _make_cli(
config_overrides={
"model": {
"default": "anthropic/claude-opus-4.6",
"provider": "anthropic",
},
"providers": {
"nous": {
"base_url": "https://inference-api.nousresearch.com/v1",
},
},
},
model="nous/deepseek-v4-pro",
)
assert cli.model == "deepseek-v4-pro"
assert cli.requested_provider == "nous"
class TestRootLevelProviderOverride:
"""Root-level provider/base_url in config.yaml must NOT override model.provider."""
def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch):
"""model.provider takes priority — root-level provider is only a fallback."""
import hermes_yaml as yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"provider": "opencode-go", # stale root-level key
"model": {
"default": "google/gemini-3-flash-preview",
"provider": "openrouter", # correct canonical key
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "openrouter"
def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch):
"""Legacy root-level provider still populates model.provider in the CLI loader."""
import hermes_yaml as yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"provider": "opencode-go", # stale root key
"model": {
"default": "google/gemini-3-flash-preview",
# no explicit model.provider — defaults provide "auto"
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "opencode-go"
def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch):
"""Legacy root-level base_url still populates model.base_url in the CLI loader."""
import hermes_yaml as yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"base_url": "https://example.com/v1",
"model": {
"default": "google/gemini-3-flash-preview",
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["base_url"] == "https://example.com/v1"
def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch):
"""Classic CLI must expose terminal.vercel_runtime to terminal_tool.py."""
import hermes_yaml as yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TERMINAL_VERCEL_RUNTIME", raising=False)
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"terminal": {
"backend": "vercel_sandbox",
"vercel_runtime": "python3.13",
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["terminal"]["vercel_runtime"] == "python3.13"
assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
def test_normalize_root_model_keys_moves_to_model(self):
"""_normalize_root_model_keys migrates root keys into model section."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"provider": "opencode-go",
"base_url": "https://example.com/v1",
"model": {
"default": "some-model",
},
}
result = _normalize_root_model_keys(config)
# Root keys removed
assert "provider" not in result
assert "base_url" not in result
# Migrated into model section
assert result["model"]["provider"] == "opencode-go"
assert result["model"]["base_url"] == "https://example.com/v1"
def test_normalize_root_model_keys_does_not_override_existing(self):
"""Existing model.provider is never overridden by root-level key."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"provider": "stale-provider",
"model": {
"default": "some-model",
"provider": "correct-provider",
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["provider"] == "correct-provider"
assert "provider" not in result # root key still cleaned up
# --- model-id alias canonicalization (issue #34500) -------------------
# ``model.name`` / ``model.model`` must canonicalize to ``model.default``
# so the runtime resolver (and ~14 other readers) never sends an empty
# ``model=`` to the backend. Precedence: default > model > name.
def test_normalize_model_alias_to_default(self):
"""model.model becomes model.default."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"model": "via-model-key"}})
assert result["model"]["default"] == "via-model-key"
assert "model" not in result["model"]
def test_normalize_model_wins_over_name(self):
"""Precedence: model > name when both are aliases and default is empty."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"model": "m-key", "name": "n-key"}})
assert result["model"]["default"] == "m-key"
assert "model" not in result["model"] and "name" not in result["model"]
# --- dict-valued model.default flattening (PR #83902 follow-up) --------
# ``model.default: {provider: ..., model: ...}`` must flatten into a string
# ``model.default`` plus ``model.provider`` at the load chokepoint so every
# reader (doctor, status, fallback picker, prompt-size, context-switch
# guard) sees plain strings instead of a nested dict that crashes
# ``.strip()``/``.lower()`` or routes the model through the wrong provider.
def test_nested_dict_default_flattens_model_and_provider(self):
"""dict model.default -> string default + provider, no outer provider set."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
},
})
assert result["model"]["default"] == "nested-default-model"
assert result["model"]["provider"] == "nous"
def test_nested_dict_default_provider_wins_over_auto(self):
"""Nested provider replaces the merged default "auto"."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "auto",
},
})
assert result["model"]["default"] == "nested-default-model"
assert result["model"]["provider"] == "nous"
def test_nested_dict_default_never_overrides_explicit_provider(self):
"""An explicitly configured model.provider beats the nested provider."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "anthropic",
},
})
assert result["model"]["default"] == "nested-default-model"
assert result["model"]["provider"] == "anthropic"
def test_nested_dict_model_alias_flattens_to_default(self):
"""dict model.model alias also flattens (default > model > name)."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"model": {"provider": "openai", "model": "nested-alias-model"},
},
})
assert result["model"]["default"] == "nested-alias-model"
assert result["model"]["provider"] == "openai"
assert "model" not in result["model"]
def test_flat_string_default_untouched(self):
"""Plain string defaults keep existing behavior exactly."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {"default": "flat-default-model", "provider": "auto"},
})
assert result["model"]["default"] == "flat-default-model"
assert result["model"]["provider"] == "auto"
class TestPluginToolsetStartupValidation:
"""A toolset that is merely *not registered yet* must not be reported as unknown.
Plugins register their toolsets during background discovery, while the CLI validates
the configured list during construction -- i.e. before that thread has landed. Judging
by the live registry alone therefore flags every configured plugin toolset as a typo on
every launch, including one-shot/quiet runs whose stdout is machine-parsed.
"""
@staticmethod
def _init_toolsets(monkeypatch, toolsets, *, registry, plugin_keys):
import cli as _cli_mod
stub = object.__new__(_cli_mod.HermesCLI)
printed: list[str] = []
stub._console_print = printed.append
monkeypatch.setattr(_cli_mod, "validate_toolset", lambda name: name in registry)
monkeypatch.setattr(_cli_mod, "CLI_CONFIG", {"agent": {}})
monkeypatch.setattr(
"hermes_cli.plugins.get_plugin_toolset_keys_nowait",
lambda: set(plugin_keys),
)
stub._init_toolsets(list(toolsets))
return stub, printed
def test_plugin_toolset_not_yet_registered_is_not_flagged(self, monkeypatch):
stub, printed = self._init_toolsets(
monkeypatch,
["terminal", "voice_stack"],
registry={"terminal"},
plugin_keys={"voice_stack"},
)
assert printed == []
# The configured list is kept verbatim; only the false warning is silenced.
assert stub.enabled_toolsets == ["terminal", "voice_stack"]
def test_real_typo_still_warns(self, monkeypatch):
_, printed = self._init_toolsets(
monkeypatch,
["terminal", "voice_stak"],
registry={"terminal"},
plugin_keys={"voice_stack"},
)
assert len(printed) == 1
assert "voice_stak" in printed[0]
assert "voice_stack" not in printed[0]
@pytest.mark.parametrize(
"env, expected",
[
({"OPENROUTER_API_KEY": "", "OPENAI_API_KEY": "fake-openai-project-key"}, ""),
({"OPENROUTER_API_KEY": "", "OPENAI_API_KEY": "sk-or-v1-fake-in-openai-var"}, "sk-or-v1-fake-in-openai-var"),
({"OPENROUTER_API_KEY": "sk-or-v1-fake-router", "OPENAI_API_KEY": "fake-openai-project-key"}, "sk-or-v1-fake-router"),
],
ids=["real-openai-key-not-seeded", "sk-or-in-openai-var", "openrouter-key-wins"],
)
def test_openrouter_startup_key_never_holds_a_real_openai_key(env, expected):
# The startup key can reach /model before the first turn resolves runtime,
# so it must already follow the sk-or- gate for openrouter.ai.
cli = _make_cli(env_overrides=env)
assert (cli.api_key or "") == expected