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.
This commit is contained in:
@@ -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()
|
||||
|
||||
88
ui-tui/src/__tests__/slashParity.test.ts
Normal file
88
ui-tui/src/__tests__/slashParity.test.ts
Normal file
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<ProcessStopResponse>('process.stop', {})
|
||||
.then(
|
||||
ctx.guarded<ProcessStopResponse>(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<ReloadMcpResponse>('reload.mcp', { session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<ReloadMcpResponse>(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<string, unknown> = { action }
|
||||
if (action === 'connect') {
|
||||
payload.url = rest.join(' ').trim() || 'http://localhost:9222'
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<BrowserManageResponse>('browser.manage', payload)
|
||||
.then(
|
||||
ctx.guarded<BrowserManageResponse>(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<RollbackListResponse>('rollback.list', { session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<RollbackListResponse>(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 <checkpoint>')
|
||||
}
|
||||
return ctx.gateway
|
||||
.rpc<RollbackDiffResponse>('rollback.diff', { hash, session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<RollbackDiffResponse>(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<RollbackRestoreResponse>('rollback.restore', {
|
||||
...(filePath ? { file_path: filePath } : {}),
|
||||
hash,
|
||||
session_id: ctx.sid
|
||||
})
|
||||
.then(
|
||||
ctx.guarded<RollbackRestoreResponse>(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)',
|
||||
|
||||
@@ -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<ConfigGetValueResponse>('config.get', { key: 'fast', session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<ConfigGetValueResponse>(r =>
|
||||
ctx.transcript.sys(`fast mode: ${r.value === 'fast' ? 'fast' : 'normal'}`)
|
||||
)
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'fast', session_id: ctx.sid, value: mode })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(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<ConfigGetValueResponse>('config.get', { key: 'busy' })
|
||||
.then(
|
||||
ctx.guarded<ConfigGetValueResponse>(r => {
|
||||
const current = r.value || 'interrupt'
|
||||
ctx.transcript.sys(`busy input mode: ${current}`)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'busy', value: mode })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(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',
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user