hermes-agent/ui-tui/src/__tests__/createSlashHandler.test.ts
kshitijk4poor abc95338c2 fix(tui): slash.exec _pending_input commands, tool ANSI, terminal title
Additional TUI fixes discovered in the same audit:

1. /plan slash command was silently lost — process_command() queues the
   plan skill invocation onto _pending_input which nobody reads in the
   slash worker subprocess.  Now intercepted in slash.exec and routed
   through command.dispatch with a new 'send' dispatch type.

   Same interception added for /retry, /queue, /steer as safety nets
   (these already have correct TUI-local handlers in core.ts, but the
   server-side guard prevents regressions if the local handler is
   bypassed).

2. Tool results were stripping ANSI escape codes — the messageLine
   component used stripAnsi() + plain <Text> for tool role messages,
   losing all color/styling from terminal, search_files, etc.  Now
   uses <Ansi> component (already imported) when ANSI is detected.

3. Terminal tab title now shows model + busy status via useTerminalTitle
   hook from @hermes/ink (was never used).  Users can identify Hermes
   tabs and see at a glance whether the agent is busy or ready.

4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch
   parser + createSlashHandler handler for commands that need to inject
   a message into the conversation (plan, queue fallback, steer fallback).
2026-04-18 09:30:48 -07:00

253 lines
7.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { getUiState, resetUiState } from '../app/uiStore.js'
describe('createSlashHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
})
it('opens the resume picker locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/resume')).toBe(true)
expect(getOverlayState().picker).toBe(true)
})
it('cycles details mode and persists it', async () => {
const ctx = buildCtx()
expect(getUiState().detailsMode).toBe('collapsed')
expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
expect(getUiState().detailsMode).toBe('expanded')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode',
value: 'expanded'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded')
})
it('shows tool enable usage when names are missing', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/tools enable')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(1, 'usage: /tools enable <name> [name ...]')
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(2, 'built-in toolset: /tools enable web')
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
})
it('drops stale slash.exec output after a newer slash', async () => {
let resolveLate: (v: { output?: string }) => void
let slashExecCalls = 0
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
slashExecCalls += 1
if (slashExecCalls === 1) {
return new Promise<{ output?: string }>(res => {
resolveLate = res
})
}
return Promise.resolve({ output: 'fresh' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/slow')).toBe(true)
expect(h('/fast')).toBe(true)
resolveLate!({ output: 'too late' })
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalled()
})
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late')
})
it('dispatches command.dispatch with typed alias', async () => {
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('no'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'alias', target: 'help' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/zzz')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
})
it('resolves unique local aliases through the catalog', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/h': '/help',
'/help': '/help'
}
}
}
})
expect(createSlashHandler(ctx)('/h')).toBe(true)
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
it('falls through to command.dispatch for skill commands and sends the message', async () => {
const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('skill command: use command.dispatch'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/hermes-agent-dev')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev')
})
expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage)
})
it('handles send-type dispatch for /plan command', async () => {
const planMessage = 'Plan skill content loaded'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('pending-input command'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'send', message: planMessage })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/plan create a REST API')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage)
})
})
})
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
...overrides,
slashFlightRef: overrides.slashFlightRef ?? { current: 0 },
composer: { ...buildComposer(), ...overrides.composer },
gateway: { ...buildGateway(), ...overrides.gateway },
local: { ...buildLocal(), ...overrides.local },
session: { ...buildSession(), ...overrides.session },
transcript: { ...buildTranscript(), ...overrides.transcript },
voice: { ...buildVoice(), ...overrides.voice }
})
const buildComposer = () => ({
enqueue: vi.fn(),
hasSelection: false,
paste: vi.fn(),
queueRef: { current: [] as string[] },
selection: { copySelection: vi.fn(() => '') },
setInput: vi.fn()
})
const buildGateway = () => ({
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn(() => Promise.resolve({}))
},
rpc: vi.fn(() => Promise.resolve({}))
})
const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
maybeWarn: vi.fn()
})
const buildSession = () => ({
closeSession: vi.fn(() => Promise.resolve(null)),
die: vi.fn(),
guardBusySessionSwitch: vi.fn(() => false),
newSession: vi.fn(),
resetVisibleHistory: vi.fn(),
resumeById: vi.fn(),
setSessionStartedAt: vi.fn()
})
const buildTranscript = () => ({
page: vi.fn(),
panel: vi.fn(),
send: vi.fn(),
setHistoryItems: vi.fn(),
sys: vi.fn(),
trimLastExchange: vi.fn(items => items)
})
const buildVoice = () => ({
setVoiceEnabled: vi.fn()
})
interface Ctx {
slashFlightRef: { current: number }
composer: ReturnType<typeof buildComposer>
gateway: ReturnType<typeof buildGateway>
local: ReturnType<typeof buildLocal>
session: ReturnType<typeof buildSession>
transcript: ReturnType<typeof buildTranscript>
voice: ReturnType<typeof buildVoice>
}