Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions tests/tui_gateway/test_gateway_ready_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""gateway.ready carries the active launch profile (#36081).

``hermes -p <name> --tui`` users must see the active profile at a glance in the
status bar, without waiting for a session to exist. The name rides additively
on the startup event both transports already emit:

- stdio: ``tui_gateway/entry.main`` writes it before entering the read loop;
- WS: ``tui_gateway/ws.handle_ws`` writes it right after connection accept.

Contract under test:

- named profile (HERMES_HOME under ``~/.hermes/profiles/<name>``) → the name;
- default home / unrecognized custom home → None, so the stock single-profile
UX renders no profile segment (mirrors the composer prefix suppression);
- key-additive: ``skin`` / ``change_events`` consumers are untouched — old
clients simply ignore the new key.
"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path

import pytest


@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME + Path.home() — the repo's profile-test pattern."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
return home


class TestResolveLaunchProfile:
def test_default_home_reports_none(self, hermes_home):
from tui_gateway.server import resolve_launch_profile

assert resolve_launch_profile() is None

def test_unrecognized_custom_home_reports_none(self, hermes_home, monkeypatch):
from tui_gateway.server import resolve_launch_profile

custom = hermes_home.parent / "custom-home"
custom.mkdir()
monkeypatch.setenv("HERMES_HOME", str(custom))

assert resolve_launch_profile() is None

def test_named_profile_reports_name(self, hermes_home, monkeypatch):
from tui_gateway.server import resolve_launch_profile

named = Path.home() / ".hermes" / "profiles" / "work"
named.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(named))

assert resolve_launch_profile() == "work"

def test_import_failure_fails_closed_to_none(self, hermes_home, monkeypatch):
"""A profiles-module hiccup must never break the ready write."""
from tui_gateway.server import resolve_launch_profile

named = Path.home() / ".hermes" / "profiles" / "work"
named.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(named))
monkeypatch.setitem(__import__("sys").modules, "hermes_cli.profiles", None)

assert resolve_launch_profile() is None


# ── stdio transport: entry.main's ready frame ────────────────────────────────


def _run_entry_main(monkeypatch) -> list[dict]:
"""Run entry.main() with stubbed collaborators; return every written frame.

Harness mirrors tests/tui_gateway/test_entry_picker_prewarm.py: stubbed
I/O collaborators, no subprocess, no real gateway; main() returns on the
genuine stdin EOF.
"""
import io

from tui_gateway import entry

writes: list[dict] = []

monkeypatch.setattr(entry, "_install_sidecar_publisher", lambda: None)
monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: None)
monkeypatch.setattr(entry, "_log_exit", lambda reason: None)
# Genuine EOF — empty stdin drops main() straight out of its read loop.
monkeypatch.setattr(entry.sys, "stdin", io.StringIO(""))
monkeypatch.setattr(entry, "handle_spurious_eof", lambda *a: False)
monkeypatch.setattr(entry.server, "_ensure_skin_watcher", lambda: None)

def _write_json(payload):
writes.append(payload)
return True

monkeypatch.setattr(entry, "write_json", _write_json)

# The prewarm helper is imported lazily from its own module inside main().
import hermes_cli.model_switch as ms

monkeypatch.setattr(ms, "prewarm_picker_cache_async", lambda: None)

entry.main()

return writes


def test_entry_ready_payload_carries_named_profile(monkeypatch, hermes_home):
from tui_gateway.server import resolve_skin

named = Path.home() / ".hermes" / "profiles" / "work"
named.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(named))

frames = _run_entry_main(monkeypatch)
ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready")
payload = ready["params"]["payload"]

assert payload["profile"] == "work"
# Additive: the pre-existing keys ride along untouched.
assert payload["change_events"] is True
assert payload["skin"] == resolve_skin()


def test_entry_ready_payload_omits_default_profile(monkeypatch, hermes_home):
from tui_gateway import entry

# Patch on entry itself — main() calls the name it imported at module load.
monkeypatch.setattr(entry, "resolve_skin", lambda: {"name": "default"})
frames = _run_entry_main(monkeypatch)

ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready")
payload = ready["params"]["payload"]

assert payload["profile"] is None
assert payload["change_events"] is True
assert payload["skin"] == {"name": "default"}


# ── WebSocket transport: handle_ws's ready frame ─────────────────────────────


class _ReadyCaptureWS:
"""FakeWS that records every line written and disconnects immediately."""

def __init__(self):
self.lines: list[str] = []

async def accept(self):
pass

async def send_text(self, line):
self.lines.append(line)

async def receive_text(self):
import tui_gateway.ws as ws_mod

raise ws_mod._WebSocketDisconnect()

async def close(self):
pass


def _run_handle_ws(monkeypatch) -> list[dict]:
"""Drive the REAL handle_ws through its ready write; return decoded frames."""
import tui_gateway.ws as ws_mod

monkeypatch.setattr(
"tui_gateway.server._ensure_skin_watcher", lambda: None
)

fake = _ReadyCaptureWS()
asyncio.run(ws_mod.handle_ws(fake))

return [json.loads(line) for line in fake.lines]


def test_ws_ready_frame_carries_named_profile(monkeypatch, hermes_home):
import tui_gateway.server as server_mod

named = Path.home() / ".hermes" / "profiles" / "work"
named.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(named))
monkeypatch.setattr(server_mod, "resolve_skin", lambda: {"palette": "wired"})

frames = _run_handle_ws(monkeypatch)
ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready")
payload = ready["params"]["payload"]

assert payload["profile"] == "work"
assert payload["change_events"] is True
assert payload["skin"] == {"palette": "wired"}


def test_ws_ready_frame_reports_none_for_default_home(monkeypatch, hermes_home):
import tui_gateway.server as server_mod

monkeypatch.setattr(server_mod, "resolve_skin", lambda: {"name": "default"})

frames = _run_handle_ws(monkeypatch)
ready = next(f for f in frames if f.get("params", {}).get("type") == "gateway.ready")
payload = ready["params"]["payload"]

assert payload["profile"] is None
assert payload["change_events"] is True
16 changes: 14 additions & 2 deletions tui_gateway/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
from tui_gateway._stdin_recovery import handle_spurious_eof

from tui_gateway import server
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
from tui_gateway.server import (
_CRASH_LOG,
dispatch,
resolve_launch_profile,
resolve_skin,
write_json,
)
from tui_gateway.transport import TeeTransport

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -437,7 +443,13 @@ def main():
"params": {
"type": "gateway.ready",
# change_events: see tui_gateway/ws.py — clients demote legacy polls.
"payload": {"skin": resolve_skin(), "change_events": True},
# profile: active launch profile, or None on the default home so the
# status bar's profile segment stays hidden there (#36081).
"payload": {
"skin": resolve_skin(),
"change_events": True,
"profile": resolve_launch_profile(),
},
},
}):
_log_exit("startup write failed (broken stdout pipe before first event)")
Expand Down
18 changes: 18 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3746,6 +3746,24 @@ def resolve_skin() -> dict:
return {}


def resolve_launch_profile() -> str | None:
"""Active launch-profile name for the ``gateway.ready`` payload (#36081).

Returns the profile name when ``HERMES_HOME`` points into
``~/.hermes/profiles/<name>``; None for the default home and for
unrecognized custom homes — the stock single-profile UX must not grow a
profile segment, matching how the composer prompt prefix suppresses them.
Additive + optional: clients that don't know the key are unaffected.
"""
try:
from hermes_cli.profiles import get_active_profile_name

name = get_active_profile_name()
except Exception:
return None
return name if name not in ("", "custom", "default") else None


# Signature of the last skin broadcast: (name, active user-file mtime). Lets the
# per-tool reconcile fire ``skin.changed`` on any real move — a name switch OR a
# live color edit to the active skin — and nothing else.
Expand Down
9 changes: 8 additions & 1 deletion tui_gateway/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ async def handle_ws(
# (#60800). The skin payload is small (a dict of strings/arrays),
# so the to_thread overhead is negligible.
skin_payload = await asyncio.to_thread(server.resolve_skin)
profile_payload = await asyncio.to_thread(server.resolve_launch_profile)
ready_ok = await transport.write_async(
{
"jsonrpc": "2.0",
Expand All @@ -350,7 +351,13 @@ async def handle_ws(
# change_events: this backend broadcasts pet.changed /
# cron.changed / sessions.changed, so clients can demote
# their legacy polls to slow backstops.
"payload": {"skin": skin_payload, "change_events": True},
# profile: active launch profile, None on the default home
# (#36081) — same additive contract as the stdio entry.
"payload": {
"skin": skin_payload,
"change_events": True,
"profile": profile_payload,
},
},
}
)
Expand Down
45 changes: 45 additions & 0 deletions ui-tui/src/__tests__/appChromeStatusRule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,48 @@ describe('StatusRule idle-since read-out', () => {
expect(findComponentByName(element, 'IdleSince')).toBeNull()
})
})

describe('StatusRule active profile segment', () => {
it('is hidden by default (no profile reported → stock UX unchanged)', () => {
const element = StatusRule({ ...baseProps })

expect(textContent(element)).not.toContain('mlperf')
})

it('shows the named profile between the context bar and the clocks', () => {
const element = StatusRule({
...baseProps,
profileName: 'mlperf',
sessionStartedAt: Date.now() - 60_000
})

expect(textContent(element)).toContain('mlperf')
// The elapsed clock still renders after it — ordering sanity, not width.
expect(textContent(element)).toContain('%')
})

it('styles the segment like its muted neighbours (muted colour, truncate-end)', () => {
const element = StatusRule({ ...baseProps, profileName: 'mlperf' })

const leaf = findElementWithText(element, 'mlperf')

expect(leaf?.props.color).toBe(DEFAULT_THEME.color.muted)
expect(leaf?.props.wrap).toBe('truncate-end')
})

it.each(['default', 'custom'])('suppresses the non-profile name "%s" like the composer prefix does', name => {
const element = StatusRule({ ...baseProps, profileName: name })

// The names are common words — assert on the separator-prefixed segment
// shape so a stray occurrence elsewhere can't false-positive.
expect(textContent(element)).not.toContain(` │ ${name}`)
})

it('drops the whole segment below its breakpoint instead of truncating mid-name', () => {
const element = StatusRule({ ...baseProps, cols: 44, profileName: 'mlperf' })

expect(textContent(element)).not.toContain('mlperf')
// Essentials survive untouched.
expect(textContent(element)).toContain('opus 4.8')
})
})
22 changes: 22 additions & 0 deletions ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,28 @@ describe('createGatewayEventHandler', () => {
expect(resumeById).not.toHaveBeenCalled()
})

it('gateway.ready records the launch profile for the status bar', () => {
const ctx = buildCtx([])

createGatewayEventHandler(ctx)({
payload: { profile: 'mlperf', skin: {} },
type: 'gateway.ready'
} as any)

expect(getUiState().launchProfile).toBe('mlperf')
})

it('gateway.ready without a profile keeps launchProfile null (default home)', () => {
const ctx = buildCtx([])

createGatewayEventHandler(ctx)({
payload: { profile: null },
type: 'gateway.ready'
} as any)

expect(getUiState().launchProfile).toBeNull()
})

it('on gateway.ready after a crash, resumes the recovered session once and skips forge', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
Expand Down
3 changes: 3 additions & 0 deletions ui-tui/src/__tests__/statusRule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe('statusBarSegments', () => {
expect(s).toEqual({
compactCtx: false,
bar: true,
profile: true,
duration: true,
compressions: true,
voice: true,
Expand All @@ -78,13 +79,15 @@ describe('statusBarSegments', () => {

expect(s.compactCtx).toBe(true)
expect(s.bar).toBe(false)
expect(s.profile).toBe(false)
expect(s.duration).toBe(false)
})

it('sheds tail segments in priority order as the terminal narrows', () => {
// the context bar is the last of the tail to go.
const order: (keyof ReturnType<typeof statusBarSegments>)[] = [
'bar',
'profile',
'duration',
'compressions',
'voice',
Expand Down
Loading
Loading