mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 02:21:47 +00:00
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:
parent
d5a89283b7
commit
a4cb3ef66c
7 changed files with 594 additions and 11 deletions
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue