From a4cb3ef66ca1927a5dc6c941d582bb88d844c1a9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:20:08 -0500 Subject: [PATCH 1/8] fix(tui): make mutating slash paths native and lifecycle-safe Route /browser, /reload-mcp, /rollback, /stop, /fast, and /busy through direct TUI RPC handlers so state changes hit the live gateway session instead of slash-worker fallback. Add TUI session finalize/reset parity hooks (memory commit + plugin boundaries) and parity matrix tests to keep mutating commands off fallback. --- tests/test_tui_gateway_server.py | 115 +++++++++++++ tui_gateway/server.py | 115 ++++++++++++- .../src/__tests__/createSlashHandler.test.ts | 18 +- ui-tui/src/__tests__/slashParity.test.ts | 88 ++++++++++ ui-tui/src/app/slash/commands/ops.ts | 155 ++++++++++++++++++ ui-tui/src/app/slash/commands/session.ts | 77 +++++++++ ui-tui/src/gatewayTypes.ts | 37 ++++- 7 files changed, 594 insertions(+), 11 deletions(-) create mode 100644 ui-tui/src/__tests__/slashParity.test.ts diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 99f42b0a..f7ba1c74 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -274,6 +274,69 @@ def _session(agent=None, **extra): } +def test_session_close_commits_memory_and_fires_finalize_hook(monkeypatch): + calls = {"hooks": []} + + agent = types.SimpleNamespace(session_id="session-key") + agent.commit_memory_session = lambda history: calls.setdefault("history", history) + server._sessions["sid"] = _session( + agent=agent, history=[{"role": "user", "content": "hello"}] + ) + monkeypatch.setattr( + server, + "_notify_session_boundary", + lambda event, session_id: calls["hooks"].append((event, session_id)), + ) + + try: + resp = server.handle_request( + {"id": "1", "method": "session.close", "params": {"session_id": "sid"}} + ) + assert resp["result"]["closed"] is True + assert calls["history"] == [{"role": "user", "content": "hello"}] + assert ("on_session_finalize", "session-key") in calls["hooks"] + finally: + server._sessions.pop("sid", None) + + +def test_init_session_fires_reset_hook(monkeypatch): + hooks = [] + + class _FakeWorker: + def __init__(self, key, model): + self.key = key + + def close(self): + return None + + monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr( + server, + "_notify_session_boundary", + lambda event, session_id: hooks.append((event, session_id)), + ) + + import tools.approval as _approval + + monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) + monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) + + sid = "sid" + try: + server._init_session( + sid, + "session-key", + types.SimpleNamespace(model="x"), + history=[], + cols=80, + ) + assert ("on_session_reset", "session-key") in hooks + finally: + server._sessions.pop(sid, None) + + def test_session_title_queues_when_db_row_not_ready(monkeypatch): class _FakeDB: def get_session_title(self, _key): @@ -604,6 +667,58 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions.clear() +def test_config_set_fast_updates_live_agent_and_config(monkeypatch): + writes = [] + emits = [] + agent = types.SimpleNamespace(service_tier=None) + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value))) + monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "fast"}, + } + ) + assert resp["result"]["value"] == "fast" + assert agent.service_tier == "priority" + assert ("agent.service_tier", "fast") in writes + assert ("session.info", "sid", {"model": "x"}) in emits + finally: + server._sessions.pop("sid", None) + + +def test_config_busy_get_and_set(monkeypatch): + writes = [] + + monkeypatch.setattr( + server, + "_load_cfg", + lambda: {"display": {"busy_input_mode": "steer"}}, + ) + monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value))) + + get_resp = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "busy"}} + ) + assert get_resp["result"]["value"] == "steer" + + set_resp = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"key": "busy", "value": "interrupt"}, + } + ) + assert set_resp["result"]["value"] == "interrupt" + assert ("display.busy_input_mode", "interrupt") in writes + + def test_config_get_statusbar_survives_non_dict_display(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"}) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b7cda00f..afffdac8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -251,11 +251,60 @@ class _SlashWorker: pass -atexit.register( - lambda: [ - s.get("slash_worker") and s["slash_worker"].close() for s in _sessions.values() - ] -) +def _load_busy_input_mode() -> str: + raw = ( + str((_load_cfg().get("display") or {}).get("busy_input_mode", "") or "") + .strip() + .lower() + ) + return raw if raw in {"queue", "steer", "interrupt"} else "interrupt" + + +def _notify_session_boundary(event_type: str, session_id: str | None) -> None: + """Fire session lifecycle hooks with CLI parity.""" + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + + _invoke_hook(event_type, session_id=session_id, platform="tui") + except Exception: + pass + + +def _finalize_session(session: dict | None) -> None: + """Best-effort finalize hook + memory commit for a session.""" + if not session or session.get("_finalized"): + return + session["_finalized"] = True + + agent = session.get("agent") + lock = session.get("history_lock") + if lock is not None: + with lock: + history = list(session.get("history", [])) + else: + history = list(session.get("history", [])) + if agent is not None and history and hasattr(agent, "commit_memory_session"): + try: + agent.commit_memory_session(history) + except Exception: + pass + + session_id = getattr(agent, "session_id", None) or session.get("session_key") + _notify_session_boundary("on_session_finalize", session_id) + + +def _shutdown_sessions() -> None: + for session in list(_sessions.values()): + _finalize_session(session) + try: + worker = session.get("slash_worker") + if worker: + worker.close() + except Exception: + pass + + +atexit.register(_shutdown_sessions) # ── Plumbing ────────────────────────────────────────────────────────── @@ -1420,6 +1469,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): except Exception: pass _wire_callbacks(sid) + _notify_session_boundary("on_session_reset", key) _emit("session.info", sid, _session_info(agent)) @@ -1637,6 +1687,7 @@ def _(rid, params: dict) -> dict: pass _wire_callbacks(sid) + _notify_session_boundary("on_session_reset", key) info = _session_info(agent) warn = _probe_credentials(agent) @@ -1960,6 +2011,7 @@ def _(rid, params: dict) -> dict: session = _sessions.pop(sid, None) if not session: return _ok(rid, {"closed": False}) + _finalize_session(session) try: from tools.approval import unregister_gateway_notify @@ -2827,6 +2879,39 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5001, str(e)) + if key == "fast": + raw = str(value or "").strip().lower() + if session and session.get("agent") is not None: + current_fast = getattr(session["agent"], "service_tier", None) == "priority" + else: + current_fast = _load_service_tier() == "priority" + + if raw in ("", "toggle"): + nv = "normal" if current_fast else "fast" + elif raw in {"status"}: + nv = "fast" if current_fast else "normal" + elif raw in {"fast", "on"}: + nv = "fast" + elif raw in {"normal", "off"}: + nv = "normal" + else: + return _err(rid, 4002, f"unknown fast mode: {value}") + + _write_config_key("agent.service_tier", nv) + if session and session.get("agent") is not None: + session["agent"].service_tier = "priority" if nv == "fast" else None + _emit("session.info", params.get("session_id", ""), _session_info(session["agent"])) + return _ok(rid, {"key": key, "value": nv}) + + if key == "busy": + raw = str(value or "").strip().lower() + if raw in ("", "status"): + return _ok(rid, {"key": key, "value": _load_busy_input_mode()}) + if raw not in {"queue", "steer", "interrupt"}: + return _err(rid, 4002, f"unknown busy mode: {value}") + _write_config_key("display.busy_input_mode", raw) + return _ok(rid, {"key": key, "value": raw}) + if key == "verbose": cycle = ["off", "new", "all", "verbose"] cur = ( @@ -3100,6 +3185,22 @@ def _(rid, params: dict) -> dict: else "hide" ) return _ok(rid, {"value": effort, "display": display}) + if key == "fast": + return _ok( + rid, + { + "value": "fast" + if (session := _sessions.get(params.get("session_id", ""))) + and getattr(session.get("agent"), "service_tier", None) == "priority" + else ( + "fast" + if _load_service_tier() == "priority" + else "normal" + ), + }, + ) + if key == "busy": + return _ok(rid, {"value": _load_busy_input_mode()}) if key == "details_mode": allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) raw = ( @@ -4126,10 +4227,6 @@ def _(rid, params: dict) -> dict: # Skill slash commands and _pending_input commands must NOT go through the # slash worker — see _PENDING_INPUT_COMMANDS definition above. - # (/browser connect/disconnect also uses _pending_input for context - # notes, but the actual browser operations need the slash worker's - # env-var side effects, so they stay in slash.exec — only the context - # note to the model is lost, which is low-severity.) _cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split() _cmd_base = _cmd_parts[0] if _cmd_parts else "" diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index dba35487..f9c38756 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -192,6 +192,22 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue') }) + it.each([ + ['/browser status', 'browser.manage', { action: 'status' }], + ['/reload-mcp', 'reload.mcp', { session_id: null }], + ['/rollback', 'rollback.list', { session_id: null }], + ['/stop', 'process.stop', {}], + ['/fast status', 'config.get', { key: 'fast', session_id: null }], + ['/busy status', 'config.get', { key: 'busy' }] + ])('routes %s through native RPC (no slash worker)', (command, method, params) => { + const rpc = vi.fn(() => Promise.resolve({})) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + expect(createSlashHandler(ctx)(command)).toBe(true) + expect(rpc).toHaveBeenCalledWith(method, params) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + it('drops stale slash.exec output after a newer slash', async () => { let resolveLate: (v: { output?: string }) => void let slashExecCalls = 0 @@ -222,7 +238,7 @@ describe('createSlashHandler', () => { const h = createSlashHandler(ctx) expect(h('/slow')).toBe(true) - expect(h('/fast')).toBe(true) + expect(h('/later')).toBe(true) resolveLate!({ output: 'too late' }) await vi.waitFor(() => { expect(ctx.transcript.sys).toHaveBeenCalled() diff --git a/ui-tui/src/__tests__/slashParity.test.ts b/ui-tui/src/__tests__/slashParity.test.ts new file mode 100644 index 00000000..0479d004 --- /dev/null +++ b/ui-tui/src/__tests__/slashParity.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { SLASH_COMMANDS } from '../app/slash/registry.js' + +type CommandRoute = 'fallback' | 'local' | 'native' + +const NATIVE_MUTATING_COMMANDS = new Set([ + 'browser', + 'busy', + 'fast', + 'reload-mcp', + 'rollback', + 'stop' +]) + +const MUTATING_COMMANDS = [ + 'background', + 'branch', + 'browser', + 'busy', + 'clear', + 'compress', + 'fast', + 'model', + 'new', + 'personality', + 'queue', + 'reasoning', + 'reload-mcp', + 'retry', + 'rollback', + 'steer', + 'stop', + 'title', + 'tools', + 'undo', + 'verbose', + 'voice', + 'yolo' +] as const + +const loadCommandRegistryNames = (): string[] => { + const here = dirname(fileURLToPath(import.meta.url)) + const source = readFileSync(resolve(here, '../../../hermes_cli/commands.py'), 'utf8') + const names = [...source.matchAll(/CommandDef\("([^"]+)"/g)].map(match => match[1]!) + + return [...new Set(names)] +} + +const LOCAL_COMMAND_NAMES = new Set( + SLASH_COMMANDS.flatMap(command => [command.name, ...(command.aliases ?? [])].map(name => name.toLowerCase())) +) + +const classifyRoute = (name: string): CommandRoute => { + const normalized = name.toLowerCase() + if (NATIVE_MUTATING_COMMANDS.has(normalized)) { + return 'native' + } + if (LOCAL_COMMAND_NAMES.has(normalized)) { + return 'local' + } + return 'fallback' +} + +describe('slash parity matrix', () => { + it('classifies each command registry command as local/native/fallback', () => { + const routes = Object.fromEntries(loadCommandRegistryNames().map(name => [name, classifyRoute(name)])) + + expect(routes['model']).toBe('local') + expect(routes['browser']).toBe('native') + expect(routes['reload-mcp']).toBe('native') + expect(routes['rollback']).toBe('native') + expect(routes['stop']).toBe('native') + }) + + it('keeps every mutating command off slash-worker fallback', () => { + const routes = Object.fromEntries(loadCommandRegistryNames().map(name => [name, classifyRoute(name)])) + + for (const name of MUTATING_COMMANDS) { + expect(routes[name], `missing command in registry: ${name}`).toBeDefined() + expect(routes[name], `mutating command must not fallback: ${name}`).not.toBe('fallback') + } + }) +}) diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index a311fe93..540935e9 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,5 +1,11 @@ import type { + BrowserManageResponse, DelegationPauseResponse, + ProcessStopResponse, + ReloadMcpResponse, + RollbackDiffResponse, + RollbackListResponse, + RollbackRestoreResponse, SlashExecResponse, SpawnTreeListResponse, SpawnTreeLoadResponse, @@ -50,6 +56,155 @@ interface SkillsBrowseResponse { } export const opsCommands: SlashCommand[] = [ + { + help: 'stop background processes', + name: 'stop', + run: (_arg, ctx) => { + ctx.gateway + .rpc('process.stop', {}) + .then( + ctx.guarded(r => { + const killed = Number(r.killed ?? 0) + const noun = killed === 1 ? 'process' : 'processes' + ctx.transcript.sys(`stopped ${killed} background ${noun}`) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + aliases: ['reload_mcp'], + help: 'reload MCP servers in the live session', + name: 'reload-mcp', + run: (_arg, ctx) => { + ctx.gateway + .rpc('reload.mcp', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + ctx.transcript.sys(r.status === 'reloaded' ? 'MCP servers reloaded' : 'reload complete') + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'manage browser CDP connection [connect|disconnect|status]', + name: 'browser', + run: (arg, ctx) => { + const trimmed = arg.trim() + const [rawAction, ...rest] = trimmed ? trimmed.split(/\s+/) : ['status'] + const action = (rawAction || 'status').toLowerCase() + + if (!['connect', 'disconnect', 'status'].includes(action)) { + return ctx.transcript.sys('usage: /browser [connect|disconnect|status] [url]') + } + + const payload: Record = { action } + if (action === 'connect') { + payload.url = rest.join(' ').trim() || 'http://localhost:9222' + } + + ctx.gateway + .rpc('browser.manage', payload) + .then( + ctx.guarded(r => { + if (action === 'status') { + return ctx.transcript.sys( + r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected' + ) + } + if (action === 'connect') { + return ctx.transcript.sys( + r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed' + ) + } + ctx.transcript.sys('browser disconnected') + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'list, diff, or restore checkpoints', + name: 'rollback', + run: (arg, ctx) => { + const trimmed = arg.trim() + const [first = '', ...rest] = trimmed.split(/\s+/).filter(Boolean) + const lower = first.toLowerCase() + + if (!trimmed || lower === 'list' || lower === 'ls') { + return ctx.gateway + .rpc('rollback.list', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (!r.enabled) { + return ctx.transcript.sys('checkpoints are not enabled') + } + const checkpoints = r.checkpoints ?? [] + if (!checkpoints.length) { + return ctx.transcript.sys('no checkpoints found') + } + ctx.transcript.panel('Rollback checkpoints', [ + { + rows: checkpoints.map((c, idx) => [ + `${idx + 1}. ${c.hash.slice(0, 10)}`, + [c.timestamp, c.message].filter(Boolean).join(' · ') || '(no metadata)' + ]) + } + ]) + }) + ) + .catch(ctx.guardedErr) + } + + if (lower === 'diff') { + const hash = rest[0] + if (!hash) { + return ctx.transcript.sys('usage: /rollback diff ') + } + return ctx.gateway + .rpc('rollback.diff', { hash, session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const body = (r.rendered || r.diff || '').trim() + if (!body && !r.stat) { + return ctx.transcript.sys('no changes since this checkpoint') + } + const text = [r.stat || '', body].filter(Boolean).join('\n\n') + ctx.transcript.page(text, 'Rollback diff') + }) + ) + .catch(ctx.guardedErr) + } + + const hash = first + const filePath = rest.join(' ').trim() + return ctx.gateway + .rpc('rollback.restore', { + ...(filePath ? { file_path: filePath } : {}), + hash, + session_id: ctx.sid + }) + .then( + ctx.guarded(r => { + if (!r.success) { + return ctx.transcript.sys(`rollback failed: ${r.error || r.message || 'unknown error'}`) + } + const target = filePath || 'workspace' + const detail = r.reason || r.message || r.restored_to || 'restored' + ctx.transcript.sys(`rollback restored ${target}: ${detail}`) + if ((r.history_removed ?? 0) > 0) { + ctx.transcript.setHistoryItems(prev => ctx.transcript.trimLastExchange(prev)) + } + }) + ) + .catch(ctx.guardedErr) + } + }, + { aliases: ['tasks'], help: 'open the spawn-tree dashboard (live audit + kill/pause controls)', diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index a31a4cbe..3670f4d4 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -307,6 +307,83 @@ export const sessionCommands: SlashCommand[] = [ } }, + { + help: 'toggle fast mode [normal|fast|status]', + name: 'fast', + run: (arg, ctx) => { + const mode = arg.trim().toLowerCase() + const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle']) + if (!valid.has(mode)) { + return ctx.transcript.sys('usage: /fast [normal|fast|status]') + } + + if (!mode || mode === 'status') { + return ctx.gateway + .rpc('config.get', { key: 'fast', session_id: ctx.sid }) + .then( + ctx.guarded(r => + ctx.transcript.sys(`fast mode: ${r.value === 'fast' ? 'fast' : 'normal'}`) + ) + ) + .catch(ctx.guardedErr) + } + + ctx.gateway + .rpc('config.set', { key: 'fast', session_id: ctx.sid, value: mode }) + .then( + ctx.guarded(r => { + const next = r.value === 'fast' ? 'fast' : 'normal' + ctx.transcript.sys(`fast mode: ${next}`) + patchUiState(state => ({ + ...state, + info: state.info + ? { + ...state.info, + fast: next === 'fast', + service_tier: next === 'fast' ? 'priority' : '' + } + : state.info + })) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'control busy enter mode [queue|steer|interrupt|status]', + name: 'busy', + run: (arg, ctx) => { + const mode = arg.trim().toLowerCase() + const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt']) + if (!valid.has(mode)) { + return ctx.transcript.sys('usage: /busy [queue|steer|interrupt|status]') + } + + if (!mode || mode === 'status') { + return ctx.gateway + .rpc('config.get', { key: 'busy' }) + .then( + ctx.guarded(r => { + const current = r.value || 'interrupt' + ctx.transcript.sys(`busy input mode: ${current}`) + }) + ) + .catch(ctx.guardedErr) + } + + ctx.gateway + .rpc('config.set', { key: 'busy', value: mode }) + .then( + ctx.guarded(r => { + const next = r.value || mode + ctx.transcript.sys(`busy input mode: ${next}`) + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'cycle verbose tool-output mode (updates live agent)', name: 'verbose', diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index dbaecd4d..605d5121 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -288,7 +288,42 @@ export interface ModelOptionsResponse { // ── MCP ────────────────────────────────────────────────────────────── export interface ReloadMcpResponse { - ok?: boolean + status?: string +} + +export interface ProcessStopResponse { + killed?: number +} + +export interface BrowserManageResponse { + connected?: boolean + url?: string +} + +export interface RollbackCheckpoint { + hash: string + message?: string + timestamp?: string +} + +export interface RollbackListResponse { + checkpoints?: RollbackCheckpoint[] + enabled?: boolean +} + +export interface RollbackDiffResponse { + diff?: string + rendered?: string + stat?: string +} + +export interface RollbackRestoreResponse { + error?: string + history_removed?: number + message?: string + reason?: string + restored_to?: string + success?: boolean } // ── Subagent events ────────────────────────────────────────────────── From 487da4b72b2e2a4f100553e6498eecc810bf2053 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:25:21 -0500 Subject: [PATCH 2/8] chore(ui-tui): apply npm run fix formatting pass Run ui-tui lint autofix + prettier and commit the resulting formatting-only changes for the parity PR branch. --- ui-tui/src/__tests__/createSlashHandler.test.ts | 6 ++---- ui-tui/src/__tests__/slashParity.test.ts | 12 ++++-------- ui-tui/src/app/slash/commands/core.ts | 2 +- ui-tui/src/app/slash/commands/ops.ts | 13 +++++++++++++ ui-tui/src/app/slash/commands/session.ts | 7 ++++--- ui-tui/src/components/modelPicker.tsx | 4 +--- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index f9c38756..ebacf228 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' -import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' describe('createSlashHandler', () => { beforeEach(() => { @@ -55,9 +55,7 @@ describe('createSlashHandler', () => { }) expect( - createSlashHandler(ctx)( - `/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}` - ) + createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`) ).toBe(true) expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { key: 'model', diff --git a/ui-tui/src/__tests__/slashParity.test.ts b/ui-tui/src/__tests__/slashParity.test.ts index 0479d004..13c8aa02 100644 --- a/ui-tui/src/__tests__/slashParity.test.ts +++ b/ui-tui/src/__tests__/slashParity.test.ts @@ -8,14 +8,7 @@ import { SLASH_COMMANDS } from '../app/slash/registry.js' type CommandRoute = 'fallback' | 'local' | 'native' -const NATIVE_MUTATING_COMMANDS = new Set([ - 'browser', - 'busy', - 'fast', - 'reload-mcp', - 'rollback', - 'stop' -]) +const NATIVE_MUTATING_COMMANDS = new Set(['browser', 'busy', 'fast', 'reload-mcp', 'rollback', 'stop']) const MUTATING_COMMANDS = [ 'background', @@ -57,12 +50,15 @@ const LOCAL_COMMAND_NAMES = new Set( const classifyRoute = (name: string): CommandRoute => { const normalized = name.toLowerCase() + if (NATIVE_MUTATING_COMMANDS.has(normalized)) { return 'native' } + if (LOCAL_COMMAND_NAMES.has(normalized)) { return 'local' } + return 'fallback' } diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 91f06bb5..2cad70b9 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -6,8 +6,8 @@ import type { ConfigGetValueResponse, ConfigSetResponse, SessionSaveResponse, - SessionTitleResponse, SessionSteerResponse, + SessionTitleResponse, SessionUndoResponse } from '../../../gatewayTypes.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 540935e9..772cc2fd 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -102,6 +102,7 @@ export const opsCommands: SlashCommand[] = [ } const payload: Record = { action } + if (action === 'connect') { payload.url = rest.join(' ').trim() || 'http://localhost:9222' } @@ -115,11 +116,13 @@ export const opsCommands: SlashCommand[] = [ r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected' ) } + if (action === 'connect') { return ctx.transcript.sys( r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed' ) } + ctx.transcript.sys('browser disconnected') }) ) @@ -143,10 +146,13 @@ export const opsCommands: SlashCommand[] = [ if (!r.enabled) { return ctx.transcript.sys('checkpoints are not enabled') } + const checkpoints = r.checkpoints ?? [] + if (!checkpoints.length) { return ctx.transcript.sys('no checkpoints found') } + ctx.transcript.panel('Rollback checkpoints', [ { rows: checkpoints.map((c, idx) => [ @@ -162,17 +168,21 @@ export const opsCommands: SlashCommand[] = [ if (lower === 'diff') { const hash = rest[0] + if (!hash) { return ctx.transcript.sys('usage: /rollback diff ') } + return ctx.gateway .rpc('rollback.diff', { hash, session_id: ctx.sid }) .then( ctx.guarded(r => { const body = (r.rendered || r.diff || '').trim() + if (!body && !r.stat) { return ctx.transcript.sys('no changes since this checkpoint') } + const text = [r.stat || '', body].filter(Boolean).join('\n\n') ctx.transcript.page(text, 'Rollback diff') }) @@ -182,6 +192,7 @@ export const opsCommands: SlashCommand[] = [ const hash = first const filePath = rest.join(' ').trim() + return ctx.gateway .rpc('rollback.restore', { ...(filePath ? { file_path: filePath } : {}), @@ -193,9 +204,11 @@ export const opsCommands: SlashCommand[] = [ if (!r.success) { return ctx.transcript.sys(`rollback failed: ${r.error || r.message || 'unknown error'}`) } + const target = filePath || 'workspace' const detail = r.reason || r.message || r.restored_to || 'restored' ctx.transcript.sys(`rollback restored ${target}: ${detail}`) + if ((r.history_removed ?? 0) > 0) { ctx.transcript.setHistoryItems(prev => ctx.transcript.trimLastExchange(prev)) } diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 3670f4d4..ef0bf2da 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,4 +1,5 @@ import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' +import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { BackgroundStartResponse, ConfigGetValueResponse, @@ -10,7 +11,6 @@ import type { VoiceToggleResponse } from '../../../gatewayTypes.js' import { fmtK } from '../../../lib/text.js' -import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' @@ -27,8 +27,7 @@ const persistedModelArg = (arg: string) => { return !trimmed || GLOBAL_MODEL_FLAG_RE.test(trimmed) ? trimmed : `${trimmed} --global` } -const stripTuiSessionFlag = (trimmed: string) => - trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() +const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() const modelValueForConfigSet = (arg: string) => { const trimmed = arg.trim() @@ -313,6 +312,7 @@ export const sessionCommands: SlashCommand[] = [ run: (arg, ctx) => { const mode = arg.trim().toLowerCase() const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle']) + if (!valid.has(mode)) { return ctx.transcript.sys('usage: /fast [normal|fast|status]') } @@ -356,6 +356,7 @@ export const sessionCommands: SlashCommand[] = [ run: (arg, ctx) => { const mode = arg.trim().toLowerCase() const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt']) + if (!valid.has(mode)) { return ctx.transcript.sys('usage: /busy [queue|steer|interrupt|status]') } diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index b5882a13..8164147f 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -112,9 +112,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const model = models[modelIdx] if (provider && model) { - onSelect( - `${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}` - ) + onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`) } else { setStage('provider') } From a13449a40acad95fc733c1d198896791944e99a3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:30:30 -0500 Subject: [PATCH 3/8] fix(tui): address Copilot review feedback on mutating command parity Harden busy mode config reads against invalid display config shapes and align /fast help+usage text with accepted aliases, with regression coverage for non-dict display values. --- tests/test_tui_gateway_server.py | 10 ++++++++++ tui_gateway/server.py | 9 ++++----- ui-tui/src/app/slash/commands/session.ts | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index f7ba1c74..44a66ae0 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -729,6 +729,16 @@ def test_config_get_statusbar_survives_non_dict_display(monkeypatch): assert resp["result"]["value"] == "top" +def test_config_get_busy_survives_non_dict_display(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"display": "broken"}) + + resp = server.handle_request( + {"id": "1", "method": "config.get", "params": {"key": "busy"}} + ) + + assert resp["result"]["value"] == "interrupt" + + def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch): import yaml diff --git a/tui_gateway/server.py b/tui_gateway/server.py index afffdac8..68d1cfca 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -252,11 +252,10 @@ class _SlashWorker: def _load_busy_input_mode() -> str: - raw = ( - str((_load_cfg().get("display") or {}).get("busy_input_mode", "") or "") - .strip() - .lower() - ) + display = _load_cfg().get("display") + if not isinstance(display, dict): + display = {} + raw = str(display.get("busy_input_mode", "") or "").strip().lower() return raw if raw in {"queue", "steer", "interrupt"} else "interrupt" diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index ef0bf2da..0f201d48 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -307,14 +307,14 @@ export const sessionCommands: SlashCommand[] = [ }, { - help: 'toggle fast mode [normal|fast|status]', + help: 'toggle fast mode [normal|fast|status|on|off|toggle]', name: 'fast', run: (arg, ctx) => { const mode = arg.trim().toLowerCase() const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle']) if (!valid.has(mode)) { - return ctx.transcript.sys('usage: /fast [normal|fast|status]') + return ctx.transcript.sys('usage: /fast [normal|fast|status|on|off|toggle]') } if (!mode || mode === 'status') { From b8556eb15ebb663de0d2a9be944b74de7140768d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:47:42 -0500 Subject: [PATCH 4/8] fix(tui): address fast-mode live sync review feedback Make `config.set fast status` read-only and keep live agent request overrides in sync with fast-mode toggles so runtime API kwargs match the selected mode. --- tests/test_tui_gateway_server.py | 64 ++++++++++++++++++++++++++++++-- tui_gateway/server.py | 38 ++++++++++++++----- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 44a66ae0..03d44c05 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -627,7 +627,9 @@ def test_session_create_drops_pending_title_on_valueerror(monkeypatch): monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) - resp = server.handle_request({"id": "1", "method": "session.create", "params": {"cols": 80}}) + resp = server.handle_request( + {"id": "1", "method": "session.create", "params": {"cols": 80}} + ) sid = resp["result"]["session_id"] session = server._sessions[sid] session["pending_title"] = "duplicate title" @@ -670,12 +672,22 @@ def test_config_set_yolo_toggles_session_scope(): def test_config_set_fast_updates_live_agent_and_config(monkeypatch): writes = [] emits = [] - agent = types.SimpleNamespace(service_tier=None) + agent = types.SimpleNamespace( + model="openai/gpt-5.4", + request_overrides={"foo": "bar", "speed": "slow"}, + service_tier=None, + ) server._sessions["sid"] = _session(agent=agent) - monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value))) + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) + monkeypatch.setattr( + "hermes_cli.models.resolve_fast_mode_overrides", + lambda _model_id: {"service_tier": "priority"}, + ) try: resp = server.handle_request( @@ -687,8 +699,50 @@ def test_config_set_fast_updates_live_agent_and_config(monkeypatch): ) assert resp["result"]["value"] == "fast" assert agent.service_tier == "priority" + assert agent.request_overrides == { + "foo": "bar", + "service_tier": "priority", + } assert ("agent.service_tier", "fast") in writes assert ("session.info", "sid", {"model": "x"}) in emits + + resp_normal = server.handle_request( + { + "id": "2", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "normal"}, + } + ) + assert resp_normal["result"]["value"] == "normal" + assert agent.service_tier is None + assert agent.request_overrides == {"foo": "bar"} + assert ("agent.service_tier", "normal") in writes + finally: + server._sessions.pop("sid", None) + + +def test_config_set_fast_status_is_non_mutating(monkeypatch): + writes = [] + emits = [] + agent = types.SimpleNamespace(service_tier="priority") + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) + monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "status"}, + } + ) + assert resp["result"]["value"] == "fast" + assert writes == [] + assert emits == [] finally: server._sessions.pop("sid", None) @@ -701,7 +755,9 @@ def test_config_busy_get_and_set(monkeypatch): "_load_cfg", lambda: {"display": {"busy_input_mode": "steer"}}, ) - monkeypatch.setattr(server, "_write_config_key", lambda path, value: writes.append((path, value))) + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) get_resp = server.handle_request( {"id": "1", "method": "config.get", "params": {"key": "busy"}} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 68d1cfca..a4805867 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2885,10 +2885,14 @@ def _(rid, params: dict) -> dict: else: current_fast = _load_service_tier() == "priority" + if raw in {"status"}: + return _ok( + rid, + {"key": key, "value": "fast" if current_fast else "normal"}, + ) + if raw in ("", "toggle"): nv = "normal" if current_fast else "fast" - elif raw in {"status"}: - nv = "fast" if current_fast else "normal" elif raw in {"fast", "on"}: nv = "fast" elif raw in {"normal", "off"}: @@ -2898,8 +2902,23 @@ def _(rid, params: dict) -> dict: _write_config_key("agent.service_tier", nv) if session and session.get("agent") is not None: - session["agent"].service_tier = "priority" if nv == "fast" else None - _emit("session.info", params.get("session_id", ""), _session_info(session["agent"])) + agent = session["agent"] + agent.service_tier = "priority" if nv == "fast" else None + current_overrides = dict(getattr(agent, "request_overrides", {}) or {}) + current_overrides.pop("service_tier", None) + current_overrides.pop("speed", None) + if nv == "fast": + from hermes_cli.models import resolve_fast_mode_overrides + + current_overrides.update( + resolve_fast_mode_overrides(getattr(agent, "model", None)) or {} + ) + agent.request_overrides = current_overrides + _emit( + "session.info", + params.get("session_id", ""), + _session_info(agent), + ) return _ok(rid, {"key": key, "value": nv}) if key == "busy": @@ -3188,13 +3207,12 @@ def _(rid, params: dict) -> dict: return _ok( rid, { - "value": "fast" - if (session := _sessions.get(params.get("session_id", ""))) - and getattr(session.get("agent"), "service_tier", None) == "priority" - else ( + "value": ( "fast" - if _load_service_tier() == "priority" - else "normal" + if (session := _sessions.get(params.get("session_id", ""))) + and getattr(session.get("agent"), "service_tier", None) + == "priority" + else ("fast" if _load_service_tier() == "priority" else "normal") ), }, ) From 4a08f1015a6e13f429ff801d70ffff5ae091094f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 12:55:41 -0500 Subject: [PATCH 5/8] fix(tui): reject fast mode for unsupported live models Match classic CLI parity by refusing to enable fast mode when the active model cannot produce fast request overrides, avoiding a misleading fast status with no runtime effect. --- tests/test_tui_gateway_server.py | 34 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 27 ++++++++++++++++--------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 03d44c05..a76cc179 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -747,6 +747,40 @@ def test_config_set_fast_status_is_non_mutating(monkeypatch): server._sessions.pop("sid", None) +def test_config_set_fast_rejects_unsupported_model(monkeypatch): + writes = [] + agent = types.SimpleNamespace( + model="unsupported-model", + request_overrides={}, + service_tier=None, + ) + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) + monkeypatch.setattr( + "hermes_cli.models.resolve_fast_mode_overrides", + lambda _model_id: None, + ) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "fast"}, + } + ) + assert resp["error"]["code"] == 4002 + assert "not available" in resp["error"]["message"] + assert agent.service_tier is None + assert agent.request_overrides == {} + assert writes == [] + finally: + server._sessions.pop("sid", None) + + def test_config_busy_get_and_set(monkeypatch): writes = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a4805867..604a8d5e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2880,8 +2880,9 @@ def _(rid, params: dict) -> dict: if key == "fast": raw = str(value or "").strip().lower() - if session and session.get("agent") is not None: - current_fast = getattr(session["agent"], "service_tier", None) == "priority" + agent = session.get("agent") if session else None + if agent is not None: + current_fast = getattr(agent, "service_tier", None) == "priority" else: current_fast = _load_service_tier() == "priority" @@ -2900,19 +2901,27 @@ def _(rid, params: dict) -> dict: else: return _err(rid, 4002, f"unknown fast mode: {value}") + overrides = None + if nv == "fast": + from hermes_cli.models import resolve_fast_mode_overrides + + target_model = getattr(agent, "model", None) if agent is not None else _resolve_model() + overrides = resolve_fast_mode_overrides(target_model) + if not overrides: + return _err( + rid, + 4002, + "fast mode is not available for this model", + ) + _write_config_key("agent.service_tier", nv) - if session and session.get("agent") is not None: - agent = session["agent"] + if agent is not None: agent.service_tier = "priority" if nv == "fast" else None current_overrides = dict(getattr(agent, "request_overrides", {}) or {}) current_overrides.pop("service_tier", None) current_overrides.pop("speed", None) if nv == "fast": - from hermes_cli.models import resolve_fast_mode_overrides - - current_overrides.update( - resolve_fast_mode_overrides(getattr(agent, "model", None)) or {} - ) + current_overrides.update(overrides) agent.request_overrides = current_overrides _emit( "session.info", From 4f59510dd47429ef5ca40ff6deb8a63fbe67b9cd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 13:00:11 -0500 Subject: [PATCH 6/8] fix(tui): tighten fast-mode support validation Distinguish missing model from unsupported model before enabling fast mode and cover both cases so config and live agent state remain untouched on invalid fast toggles. --- tests/test_tui_gateway_server.py | 30 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 12 ++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a76cc179..8d7b9536 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -781,6 +781,36 @@ def test_config_set_fast_rejects_unsupported_model(monkeypatch): server._sessions.pop("sid", None) +def test_config_set_fast_rejects_missing_model(monkeypatch): + writes = [] + agent = types.SimpleNamespace( + model="", + request_overrides={}, + service_tier=None, + ) + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr( + server, "_write_config_key", lambda path, value: writes.append((path, value)) + ) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": {"session_id": "sid", "key": "fast", "value": "fast"}, + } + ) + assert resp["error"]["code"] == 4002 + assert "without a selected model" in resp["error"]["message"] + assert agent.service_tier is None + assert agent.request_overrides == {} + assert writes == [] + finally: + server._sessions.pop("sid", None) + + def test_config_busy_get_and_set(monkeypatch): writes = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 604a8d5e..2a1456e4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2905,9 +2905,17 @@ def _(rid, params: dict) -> dict: if nv == "fast": from hermes_cli.models import resolve_fast_mode_overrides - target_model = getattr(agent, "model", None) if agent is not None else _resolve_model() + target_model = ( + getattr(agent, "model", None) if agent is not None else _resolve_model() + ) + if not target_model: + return _err( + rid, + 4002, + "fast mode is not available without a selected model", + ) overrides = resolve_fast_mode_overrides(target_model) - if not overrides: + if overrides is None: return _err( rid, 4002, From 8a33ed613615edb64c1a6244de256f089f690eec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 13:10:13 -0500 Subject: [PATCH 7/8] fix(tui): address rollback guard and parity registry review Load slash command names from the Python registry instead of regex-parsing source, and guard native rollback when no TUI session is active. --- .../src/__tests__/createSlashHandler.test.ts | 21 ++++++++++++++++++- ui-tui/src/__tests__/slashParity.test.ts | 15 ++++++++++--- ui-tui/src/app/slash/commands/ops.ts | 4 ++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index de48c4bb..0ba81cd9 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -193,7 +193,6 @@ describe('createSlashHandler', () => { it.each([ ['/browser status', 'browser.manage', { action: 'status' }], ['/reload-mcp', 'reload.mcp', { session_id: null }], - ['/rollback', 'rollback.list', { session_id: null }], ['/stop', 'process.stop', {}], ['/fast status', 'config.get', { key: 'fast', session_id: null }], ['/busy status', 'config.get', { key: 'busy' }] @@ -206,6 +205,16 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) + it('routes /rollback through native RPC when a session is active', () => { + patchUiState({ sid: 'sid-abc' }) + const rpc = vi.fn(() => Promise.resolve({})) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + expect(createSlashHandler(ctx)('/rollback')).toBe(true) + expect(rpc).toHaveBeenCalledWith('rollback.list', { session_id: 'sid-abc' }) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + it('drops stale slash.exec output after a newer slash', async () => { let resolveLate: (v: { output?: string }) => void let slashExecCalls = 0 @@ -412,6 +421,16 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to save') }) + it('/rollback without an active session tells the user instead of hitting the RPC', () => { + const rpc = vi.fn(() => Promise.resolve({})) + const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) + + createSlashHandler(ctx)('/rollback') + + expect(rpc).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to rollback') + }) + it('/title uses session.title RPC and bypasses slash.exec', async () => { patchUiState({ sid: 'sid-abc' }) const rpc = vi.fn(() => Promise.resolve({ pending: false, title: 'my title' })) diff --git a/ui-tui/src/__tests__/slashParity.test.ts b/ui-tui/src/__tests__/slashParity.test.ts index 13c8aa02..333793ce 100644 --- a/ui-tui/src/__tests__/slashParity.test.ts +++ b/ui-tui/src/__tests__/slashParity.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -38,8 +38,17 @@ const MUTATING_COMMANDS = [ const loadCommandRegistryNames = (): string[] => { const here = dirname(fileURLToPath(import.meta.url)) - const source = readFileSync(resolve(here, '../../../hermes_cli/commands.py'), 'utf8') - const names = [...source.matchAll(/CommandDef\("([^"]+)"/g)].map(match => match[1]!) + + const names = JSON.parse( + execFileSync( + process.env.PYTHON ?? 'python3', + [ + '-c', + 'import json; from hermes_cli.commands import COMMAND_REGISTRY; print(json.dumps([c.name for c in COMMAND_REGISTRY]))' + ], + { cwd: resolve(here, '../../..'), encoding: 'utf8' } + ) + ) as string[] return [...new Set(names)] } diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 772cc2fd..ef1b2406 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -134,6 +134,10 @@ export const opsCommands: SlashCommand[] = [ help: 'list, diff, or restore checkpoints', name: 'rollback', run: (arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session — nothing to rollback') + } + const trimmed = arg.trim() const [first = '', ...rest] = trimmed.split(/\s+/).filter(Boolean) const lower = first.toLowerCase() From ed4f7f0ba3660e2bd7f4495891c148238149a656 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 13:19:11 -0500 Subject: [PATCH 8/8] test(tui): skip slash parity matrix when Python registry is unavailable Keep the parity test backed by the real Python command registry while avoiding hard failures in Node-only Vitest environments that cannot import hermes_cli.commands. --- ui-tui/src/__tests__/slashParity.test.ts | 52 ++++++++++++++++-------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/ui-tui/src/__tests__/slashParity.test.ts b/ui-tui/src/__tests__/slashParity.test.ts index 333793ce..efd7e5f7 100644 --- a/ui-tui/src/__tests__/slashParity.test.ts +++ b/ui-tui/src/__tests__/slashParity.test.ts @@ -8,6 +8,11 @@ import { SLASH_COMMANDS } from '../app/slash/registry.js' type CommandRoute = 'fallback' | 'local' | 'native' +interface CommandRegistryLoad { + error?: string + names: string[] +} + const NATIVE_MUTATING_COMMANDS = new Set(['browser', 'busy', 'fast', 'reload-mcp', 'rollback', 'stop']) const MUTATING_COMMANDS = [ @@ -36,23 +41,34 @@ const MUTATING_COMMANDS = [ 'yolo' ] as const -const loadCommandRegistryNames = (): string[] => { +const loadCommandRegistryNames = (): CommandRegistryLoad => { const here = dirname(fileURLToPath(import.meta.url)) - const names = JSON.parse( - execFileSync( - process.env.PYTHON ?? 'python3', - [ - '-c', - 'import json; from hermes_cli.commands import COMMAND_REGISTRY; print(json.dumps([c.name for c in COMMAND_REGISTRY]))' - ], - { cwd: resolve(here, '../../..'), encoding: 'utf8' } - ) - ) as string[] + try { + const names = JSON.parse( + execFileSync( + process.env.PYTHON ?? 'python3', + [ + '-c', + 'import json; from hermes_cli.commands import COMMAND_REGISTRY; print(json.dumps([c.name for c in COMMAND_REGISTRY]))' + ], + { cwd: resolve(here, '../../..'), encoding: 'utf8' } + ) + ) as string[] - return [...new Set(names)] + return { names: [...new Set(names)] } + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + names: [] + } + } } +const commandRegistry = loadCommandRegistryNames() +const registryIt = commandRegistry.error ? it.skip : it +const skipReason = commandRegistry.error ? commandRegistry.error.split('\n')[0] : '' + const LOCAL_COMMAND_NAMES = new Set( SLASH_COMMANDS.flatMap(command => [command.name, ...(command.aliases ?? [])].map(name => name.toLowerCase())) ) @@ -72,8 +88,12 @@ const classifyRoute = (name: string): CommandRoute => { } describe('slash parity matrix', () => { - it('classifies each command registry command as local/native/fallback', () => { - const routes = Object.fromEntries(loadCommandRegistryNames().map(name => [name, classifyRoute(name)])) + if (commandRegistry.error) { + it.skip(`Python command registry unavailable: ${skipReason}`, () => {}) + } + + registryIt('classifies each command registry command as local/native/fallback', () => { + const routes = Object.fromEntries(commandRegistry.names.map(name => [name, classifyRoute(name)])) expect(routes['model']).toBe('local') expect(routes['browser']).toBe('native') @@ -82,8 +102,8 @@ describe('slash parity matrix', () => { expect(routes['stop']).toBe('native') }) - it('keeps every mutating command off slash-worker fallback', () => { - const routes = Object.fromEntries(loadCommandRegistryNames().map(name => [name, classifyRoute(name)])) + registryIt('keeps every mutating command off slash-worker fallback', () => { + const routes = Object.fromEntries(commandRegistry.names.map(name => [name, classifyRoute(name)])) for (const name of MUTATING_COMMANDS) { expect(routes[name], `missing command in registry: ${name}`).toBeDefined()