diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 99f42b0af4..f7ba1c74be 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 b7cda00ff4..afffdac8f4 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 dba3548712..f9c3875668 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 0000000000..0479d0049d --- /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 a311fe93b6..540935e9a4 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 a31a4cbe43..3670f4d422 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 dbaecd4d3d..605d51213f 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 ──────────────────────────────────────────────────