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.
This commit is contained in:
Brooklyn Nicholson
2026-04-27 13:10:13 -05:00
parent 4f59510dd4
commit 8a33ed6136
3 changed files with 36 additions and 4 deletions

View File

@@ -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 <name> uses session.title RPC and bypasses slash.exec', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ pending: false, title: 'my title' }))

View File

@@ -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)]
}

View File

@@ -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()