diff --git a/tests/conftest.py b/tests/conftest.py index 662324dce6dc..48de35834a2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,12 @@ import pytest +# Tests must never inherit interactive voice/TTS state from the Hermes TUI that +# launched them. Set these before test modules import gateway code; per-test +# monkeypatches are too late for background threads that can outlive teardown. +os.environ["HERMES_VOICE"] = "0" +os.environ["HERMES_VOICE_TTS"] = "0" + # Ensure project root is importable PROJECT_ROOT = Path(__file__).parent.parent if str(PROJECT_ROOT) not in sys.path: @@ -345,6 +351,8 @@ def _hermetic_environment(tmp_path, monkeypatch): # 2. Blank behavioral HERMES_* vars that could change test semantics. for name in _HERMES_BEHAVIORAL_VARS: monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("HERMES_VOICE", "0") + monkeypatch.setenv("HERMES_VOICE_TTS", "0") # Honcho's fallback host/config resolution legitimately reads the user's # global ~/.honcho/config.json. Keep HOME stable (subprocess tests depend diff --git a/tests/test_live_system_guard_self_test.py b/tests/test_live_system_guard_self_test.py index 3bbe8c9f3b0c..8744b34260be 100644 --- a/tests/test_live_system_guard_self_test.py +++ b/tests/test_live_system_guard_self_test.py @@ -18,6 +18,7 @@ from __future__ import annotations import os +import shutil import signal import subprocess @@ -26,6 +27,15 @@ # A guaranteed-foreign PID: PID 1 (init). Owned by root, not us, and # always exists. A sane guard refuses to signal it. FOREIGN_PID = 1 +SYSTEMCTL_UNAVAILABLE = pytest.mark.skipif( + shutil.which("systemctl") is None, + reason="systemctl is unavailable on this platform", +) + + +def test_voice_tts_is_disabled_for_every_test_process(): + assert os.environ.get("HERMES_VOICE") == "0" + assert os.environ.get("HERMES_VOICE_TTS") == "0" # ──────────────────── kill primitives ───────────────────────── @@ -204,6 +214,7 @@ def test_subprocess_killall_hermes_blocked(): # ──────────────────── pass-through cases (must NOT raise) ────── +@SYSTEMCTL_UNAVAILABLE def test_systemctl_status_passes_through(): """Read-only systemctl probes (status/show/list-units) are fine.""" # Run with check=False so we don't fail on the gateway's exit code. @@ -216,6 +227,7 @@ def test_systemctl_status_passes_through(): assert r is not None # Did not raise — the guard let it through. +@SYSTEMCTL_UNAVAILABLE def test_systemctl_show_passes_through(): r = subprocess.run( ["systemctl", "--user", "show", "hermes-gateway", "--no-pager"], @@ -226,6 +238,7 @@ def test_systemctl_show_passes_through(): assert r is not None +@SYSTEMCTL_UNAVAILABLE def test_systemctl_list_units_passes_through(): r = subprocess.run( ["systemctl", "--user", "list-units", "fake-not-real-unit*", "--no-pager"], @@ -236,6 +249,7 @@ def test_systemctl_list_units_passes_through(): assert r is not None +@SYSTEMCTL_UNAVAILABLE def test_systemctl_unrelated_unit_passes_through(): """systemctl restart of a non-hermes unit is allowed (we only protect hermes).""" # Use --dry-run so we don't actually try to restart anything; just diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 786587b07905..2169558c31eb 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1,5 +1,6 @@ import json import os +import platform import subprocess import sys import threading @@ -8129,6 +8130,7 @@ def test_session_activate_returns_inflight_stream_before_completion(monkeypatch) started = threading.Event() release = threading.Event() done = threading.Event() + monkeypatch.setattr(server, "_voice_tts_enabled", lambda: False) class _Agent: model = "model-live" @@ -8201,9 +8203,15 @@ def _emit(event, sid, payload=None): {"role": "user", "text": "write a long answer"}, {"role": "assistant", "text": "partial answer complete"}, ] + run_thread = server._sessions["sid-live"]["_run_thread"] + run_thread.join(2) + assert not run_thread.is_alive(), "prompt worker outlived test monkeypatches" finally: release.set() - done.wait(2) + run_thread = server._sessions.get("sid-live", {}).get("_run_thread") + if run_thread is not None: + run_thread.join(2) + assert not run_thread.is_alive(), "prompt worker survived test cleanup" server._sessions.pop("sid-live", None) @@ -8737,15 +8745,22 @@ def _opener(url, timeout=2.0): # noqa: ARG001 — match urllib signature return _Resp() import urllib.request + import webbrowser + + def _forbid_browser_side_effect(*_args, **_kwargs): + raise AssertionError("browser launch side effect escaped test isolation") monkeypatch.setattr(urllib.request, "urlopen", _opener) + monkeypatch.setattr(subprocess, "Popen", _forbid_browser_side_effect) + monkeypatch.setattr("builtins.open", _forbid_browser_side_effect) + monkeypatch.setattr(webbrowser, "open", _forbid_browser_side_effect) launched = ChromeDebugLaunch(launched=True) with patch.dict(sys.modules, {"tools.browser_tool": fake}): with ( patch( "hermes_cli.browser_connect.launch_chrome_debug", return_value=launched, - ), + ) as launch_chrome_debug, patch("hermes_cli.browser_connect.local_port_in_use", return_value=False), ): resp = server.handle_request( @@ -8759,6 +8774,7 @@ def _opener(url, timeout=2.0): # noqa: ARG001 — match urllib signature "Chromium-family browser launched and listening on port 9222", ] assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222" + launch_chrome_debug.assert_called_once_with(9222, platform.system()) def test_browser_manage_connect_finds_ipv6_only_browser(monkeypatch): @@ -10696,6 +10712,63 @@ def _boom(): assert usage["model"] == "x" +def test_get_usage_reports_the_agents_active_credential_label(): + """Report the entry actually installed on this agent, not pool current().""" + entries = [ + types.SimpleNamespace(label="personal", runtime_api_key="personal-token", access_token=""), + types.SimpleNamespace(label="work", runtime_api_key="work-token", access_token=""), + ] + pool = types.SimpleNamespace( + entries=lambda: entries, + # A shared pool may point at a subagent's lease instead. + current=lambda: entries[1], + ) + agent = types.SimpleNamespace( + model="x", + api_key="personal-token", + _credential_pool=pool, + ) + + usage = server._get_usage(agent) + + assert usage["credential_label"] == "personal" + + +def test_get_usage_clears_credential_label_for_single_entry_pool(): + entry = types.SimpleNamespace(label="personal", runtime_api_key="token", access_token="") + agent = types.SimpleNamespace( + model="x", + api_key="token", + _credential_pool=types.SimpleNamespace(entries=lambda: [entry]), + ) + + usage = server._get_usage(agent) + + # Usage snapshots are merged by the TUI, so an explicit empty value clears + # identity left by a previous provider/model. + assert usage["credential_label"] == "" + + +def test_get_usage_sanitizes_credential_label_for_single_line_status_chrome(): + entries = [ + types.SimpleNamespace( + label="personal\naccount\x1b", + runtime_api_key="personal-token", + access_token="", + ), + types.SimpleNamespace(label="work", runtime_api_key="work-token", access_token=""), + ] + agent = types.SimpleNamespace( + model="x", + api_key="personal-token", + _credential_pool=types.SimpleNamespace(entries=lambda: entries), + ) + + usage = server._get_usage(agent) + + assert usage["credential_label"] == "personal account" + + def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch): """#48305: switching models from the TUI must NOT destroy sibling keys under `model:` (model_slots, model_fallback, etc.). _persist_model_switch now uses diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 801f855910ca..3a3e125a15ac 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3581,6 +3581,39 @@ def _sync_session_key_after_compress( pass +def _active_credential_label(agent) -> str: + """Return the label for the pooled credential installed on ``agent``. + + Pool ``current()`` is process-global and may reflect a subagent lease, so + identify the entry by the live key on this agent instead. Single-entry + pools are intentionally omitted to keep ordinary status chrome uncluttered. + """ + try: + pool = getattr(agent, "_credential_pool", None) + entries = list(pool.entries()) if pool is not None else [] + if len(entries) < 2: + return "" + + live_key = getattr(agent, "api_key", None) + if not live_key or callable(live_key): + return "" + + for entry in entries: + entry_key = ( + getattr(entry, "runtime_api_key", None) + or getattr(entry, "access_token", None) + ) + if entry_key == live_key: + raw_label = str(getattr(entry, "label", "") or "") + printable = "".join( + char if char.isprintable() else " " for char in raw_label + ) + return " ".join(printable.split()) + except Exception: + pass + return "" + + def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) usage = { @@ -3593,6 +3626,9 @@ def _get_usage(agent) -> dict: "total": g("session_total_tokens"), "calls": g("session_api_calls"), } + # The TUI merges usage snapshots, so always include the field. An empty + # value clears stale identity chrome after a model/provider switch. + usage["credential_label"] = _active_credential_label(agent) comp = getattr(agent, "context_compressor", None) if comp: # context_used is the *current-window* occupancy. Do NOT fall back to diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 7d5f93a51d0f..9dcf949c2f34 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -1,7 +1,8 @@ +import { stringWidth } from '@hermes/ink' import React from 'react' import { describe, expect, it, vi } from 'vitest' -import { StatusRule } from '../components/appChrome.js' +import { credentialStatusLabel, StatusRule } from '../components/appChrome.js' import { DEFAULT_THEME } from '../theme.js' type ReactNodeLike = React.ReactNode @@ -104,6 +105,46 @@ const baseProps = { voiceLabel: '' } +describe('StatusRule active credential label', () => { + it('renders the active pooled-account label beside the model', () => { + const element = StatusRule({ + ...baseProps, + credentialLabel: 'personal' + }) + + expect(textContent(element)).toContain('opus 4.8 · personal') + }) + + it('does not add an account separator when no pooled label is available', () => { + const element = StatusRule({ ...baseProps }) + + expect(textContent(element)).toContain('opus 4.8') + expect(textContent(element)).not.toContain('opus 4.8 ·') + }) + + it('hides account identity before the compact-context breakpoint', () => { + const element = StatusRule({ + ...baseProps, + cols: 44, + credentialLabel: 'personal' + }) + + expect(textContent(element)).not.toContain('personal') + }) + + it.each([ + 'personal-account-with-an-excessively-long-name', + 'averylongaddress@example.com', + '個人用アカウント非常に長い名前', + '🧑🏽‍💻🚀✨ personal account' + ])('bounds long and wide labels without splitting graphemes: %s', label => { + const rendered = credentialStatusLabel(label) + + expect(rendered).toMatch(/…$/u) + expect(stringWidth(rendered)).toBeLessThanOrEqual(20) + }) +}) + describe('StatusRule background-subagent indicator', () => { it('renders ⛓ N on a wide terminal when subagents are running', () => { const element = StatusRule({ diff --git a/ui-tui/src/__tests__/appLayoutModel.test.ts b/ui-tui/src/__tests__/appLayoutModel.test.ts new file mode 100644 index 000000000000..7443e0c38e52 --- /dev/null +++ b/ui-tui/src/__tests__/appLayoutModel.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { statusModel } from '../components/appLayout.js' + +describe('statusModel', () => { + it('uses the atomic runtime model when present', () => { + expect(statusModel('claude-fallback', 'gpt-primary')).toBe('claude-fallback') + }) + + it.each(['', ' ', undefined])('falls back to session info for an absent runtime model: %j', model => { + expect(statusModel(model, 'gpt-primary')).toBe('gpt-primary') + }) +}) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 9103877bacc1..3ae67ccf1dc9 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -66,6 +66,27 @@ describe('createGatewayEventHandler', () => { patchUiState({ showReasoning: true }) }) + it('merges fallback model and credential identity from one completion snapshot', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { + text: 'fallback answer', + usage: { + calls: 1, + credential_label: 'work', + input: 10, + model: 'claude-sonnet', + output: 5, + total: 15 + } + }, + type: 'message.complete' + } as any) + + expect(getUiState().usage).toMatchObject({ credential_label: 'work', model: 'claude-sonnet' }) + }) + it('archives incomplete todos into transcript flow at end of turn so they scroll up', () => { const appended: Msg[] = [] diff --git a/ui-tui/src/__tests__/sessionUsageCommand.test.ts b/ui-tui/src/__tests__/sessionUsageCommand.test.ts new file mode 100644 index 000000000000..b78ed958edff --- /dev/null +++ b/ui-tui/src/__tests__/sessionUsageCommand.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { sessionCommands } from '../app/slash/commands/session.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import type { SessionUsageResponse } from '../gatewayTypes.js' + +const usageCommand = sessionCommands.find(command => command.name === 'usage')! + +describe('/usage state refresh', () => { + beforeEach(() => resetUiState()) + + it('preserves and refreshes runtime identity with the counter snapshot', async () => { + patchUiState({ + usage: { + calls: 1, + credential_label: 'personal', + input: 10, + model: 'gpt-primary', + output: 5, + total: 15 + } + }) + + const response: SessionUsageResponse = { + calls: 2, + credential_label: 'work', + input: 20, + model: 'gpt-fallback', + output: 10, + total: 30 + } + + const rpc = vi.fn(() => Promise.resolve(response)) + + const ctx = { + gateway: { rpc }, + sid: 'sid-usage', + stale: () => false, + transcript: { panel: vi.fn(), sys: vi.fn() } + } + + usageCommand.run('', ctx as any, 'usage') + await rpc.mock.results[0]?.value + await Promise.resolve() + + expect(getUiState().usage).toMatchObject({ + calls: 2, + credential_label: 'work', + input: 20, + model: 'gpt-fallback', + output: 10, + total: 30 + }) + }) +}) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 47922c5f7add..93df1cb68b4f 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -580,9 +580,8 @@ export const sessionCommands: SlashCommand[] = [ const sys = ctx.transcript.sys if (r) { - patchUiState({ - usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } - }) + const { credits_lines: _creditsLines, ...usage } = r + patchUiState(state => ({ ...state, usage: { ...state.usage, ...usage } })) } // Nous balance block is agent-independent (a portal fetch), so it shows diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 14d43dd367dd..264a9c98c03a 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -374,8 +374,32 @@ const shortModelLabel = (model: string) => .replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2') .trim() -const modelLabel = (model: string, effort?: string, fast?: boolean) => - [shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ') +const CREDENTIAL_LABEL_MAX_WIDTH = 20 +const credentialSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + +export function credentialStatusLabel(label: string, maxWidth = CREDENTIAL_LABEL_MAX_WIDTH) { + if (stringWidth(label) <= maxWidth) { + return label + } + + let value = '' + + for (const { segment } of credentialSegmenter.segment(label)) { + if (stringWidth(`${value}${segment}…`) > maxWidth) { + break + } + + value += segment + } + + return `${value}…` +} + +const modelLabel = (model: string, effort?: string, fast?: boolean, credentialLabel?: string) => { + const modelText = [shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ') + + return credentialLabel ? `${modelText} · ${credentialStatusLabel(credentialLabel)}` : modelText +} export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { const [active, setActive] = useState(false) @@ -404,6 +428,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { export function StatusRule({ cwdLabel, + credentialLabel, cols, busy, status, @@ -438,7 +463,7 @@ export function StatusRule({ : '' const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : '' - const modelText = modelLabel(model, modelReasoningEffort, modelFast) + const modelText = modelLabel(model, modelReasoningEffort, modelFast, segs.compactCtx ? undefined : credentialLabel) // A credits notice replaces the status/verb slot, but only when idle — // while busy the FaceTicker always wins (R1 render priority). The notice @@ -775,6 +800,7 @@ interface StatusRuleProps { liveSessionCount: number busy: boolean cols: number + credentialLabel?: string cwdLabel: string model: string modelFast?: boolean diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ebf7a672d02d..cb087e1b9add 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -33,6 +33,8 @@ import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' import { TextInput, type TextInputMouseApi } from './textInput.js' +export const statusModel = (usageModel?: string, infoModel?: string) => usageModel?.trim() || infoModel || '' + // Box geometry, kept here so the transcript's reservation math matches the // rendered overlay exactly. const PET_BOTTOM = 3 // rows the pet floats above the screen bottom (over the composer) @@ -476,11 +478,12 @@ const StatusRulePane = memo(function StatusRulePane({ bgCount={ui.bgTasks.size} busy={ui.busy} cols={composer.cols} + credentialLabel={ui.usage.credential_label} cwdLabel={status.cwdLabel} indicatorStyle={ui.indicatorStyle} lastTurnEndedAt={status.lastTurnEndedAt} liveSessionCount={ui.liveSessionCount} - model={ui.info?.model ?? ''} + model={statusModel(ui.usage.model, ui.info?.model)} modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'} modelReasoningEffort={ui.info?.reasoning_effort} notice={ui.notice} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 953b8a812579..67d794243baa 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -243,6 +243,7 @@ export interface SessionUsageResponse { cache_write?: number calls?: number compressions?: number + credential_label?: string context_max?: number context_percent?: number context_used?: number diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 7ba16eda93da..0b770255fbf9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -181,6 +181,7 @@ export interface Usage { active_subagents?: number calls: number compressions?: number + credential_label?: string context_max?: number context_percent?: number context_used?: number @@ -188,6 +189,7 @@ export interface Usage { cost_usd?: number dev_credits_spent_micros?: number input: number + model?: string output: number reasoning?: number total: number diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index f6dcfdf81895..d9572d32f479 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -207,6 +207,7 @@ The per-skin status-bar colors and thresholds are shared with the classic CLI The status line also shows: +- **Model and active pooled account** — the model name is followed by the selected credential label when that provider has multiple pooled credentials, for example `gpt 5.6 sol · personal`. The label updates if Hermes rotates credentials after a rate-limit, quota, or authentication failure. Single-credential providers remain uncluttered, and narrow terminals hide the label before essential context information. OAuth labels may be derived from account email claims, so use short non-sensitive labels such as `personal` or `work` when screen-sharing the TUI. - **Working directory with git branch** — `~/projects/hermes-agent (docs/two-week-gap-sweep)`. The branch suffix updates when you `git checkout` in a side terminal (mtime-cached) so the TUI reflects your actual active branch, not whatever it was at launch. - **Per-prompt elapsed time** — `⏱ 12s/3m 45s` while the turn is running (live), frozen to `⏲ 32s / 3m 45s` after the turn completes. First number is time since last user message; second is total session duration. Resets on every new prompt. - **`🗜️ N`** — number of times the running session has been auto-compressed. Appears once the first compression fires.