hermes-agent/ui-tui/src/__tests__/createSlashHandler.test.ts
Brooklyn Nicholson d8fcab4736 feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps
The SDK the desktop app already has, ported to the TUI: a WidgetApp contract
(id/help/mode/init/reduce/render/usage), a registry, and a host that owns the
active widget, routes input to its reducer, and renders it. The grid-test and
dialog-test debug surfaces are reimplemented as widget apps instead of bespoke
overlay state, and slash commands are generated from the registry. Input for an
open widget is owned by the active app (supersedes the demo-only stacked-modal
routing) — the single active widget enforces topmost-owns-input structurally.
2026-07-21 20:59:52 -05:00

1084 lines
37 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { DASHBOARD_EXIT_DISABLED_MESSAGE, DASHBOARD_UPDATE_DISABLED_MESSAGE } from '../app/slash/commands/core.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import type * as EnvModule from '../config/env.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
// DASHBOARD_TUI_MODE resolves once at module load from HERMES_TUI_DASHBOARD,
// so toggling process.env in a test body can't move it. Mock just that one
// export (everything else stays real) and flip the holder per test.
const envState = { dashboardTuiMode: false }
vi.mock('../config/env.js', async importActual => {
const actual = await importActual<typeof EnvModule>()
return {
...actual,
get DASHBOARD_TUI_MODE() {
return envState.dashboardTuiMode
}
}
})
describe('createSlashHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
envState.dashboardTuiMode = false
})
it('opens the unified sessions overlay for /resume', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/resume')).toBe(true)
expect(getOverlayState().sessions).toBe(true)
})
it('resumes a prior session by id when /resume has an argument', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
expect(ctx.session.resumeById).toHaveBeenCalledWith('sid-old')
expect(getOverlayState().sessions).toBe(false)
})
it('opens the unified sessions overlay locally even when the current session is busy', () => {
patchUiState({ busy: true, sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/sessions')).toBe(true)
expect(getOverlayState().sessions).toBe(true)
expect(ctx.session.guardBusySessionSwitch).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('blocks immediate resume-by-id while a turn is busy', () => {
patchUiState({ busy: true, sid: 'sid-abc' })
const ctx = buildCtx({ session: { ...buildSession(), guardBusySessionSwitch: vi.fn(() => true) } })
expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
expect(ctx.session.guardBusySessionSwitch).toHaveBeenCalled()
expect(ctx.session.resumeById).not.toHaveBeenCalled()
})
it('treats /session (singular) as an alias of the sessions overlay', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/session')).toBe(true)
expect(getOverlayState().sessions).toBe(true)
})
it('opens the grid-test overlay locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true)
expect(getOverlayState().widget).toMatchObject({ appId: 'grid-test' })
expect(getOverlayState().widget?.state).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('opens the grid-test streams demo via /grid-test streams', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/grid-test streams')).toBe(true)
expect(getOverlayState().widget?.state).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/redraw')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
})
it('opens the editor locally for /prompt without slash worker fallback', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/prompt')).toBe(true)
expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /compose to the editor and seeds inline text', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/compose draft text')).toBe(true)
expect(ctx.composer.setInput).toHaveBeenCalledWith('draft text')
expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1)
})
it('exits locally for /quit', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/quit')).toBe(true)
expect(ctx.session.die).toHaveBeenCalledTimes(1)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('keeps hosted dashboard chat alive for /exit', () => {
envState.dashboardTuiMode = true
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/exit')).toBe(true)
expect(ctx.session.die).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith(DASHBOARD_EXIT_DISABLED_MESSAGE)
})
it('keeps /quit available outside hosted dashboard chat', () => {
envState.dashboardTuiMode = false
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/quit')).toBe(true)
expect(ctx.session.die).toHaveBeenCalledTimes(1)
})
it('handles /update locally and exits with code 42 via dieWithCode', () => {
vi.useFakeTimers()
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/update')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('exiting TUI to run update...')
// Advance past the 100ms setTimeout
vi.advanceTimersByTime(150)
expect(ctx.session.dieWithCode).toHaveBeenCalledWith(42)
vi.useRealTimers()
})
it('refuses /update in hosted dashboard chat instead of killing the PTY', () => {
vi.useFakeTimers()
envState.dashboardTuiMode = true
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/update')).toBe(true)
expect(ctx.session.dieWithCode).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith(DASHBOARD_UPDATE_DISABLED_MESSAGE)
vi.advanceTimersByTime(150)
expect(ctx.session.dieWithCode).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('routes /status to live session.status instead of slash worker', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
})
})
it('keeps typed /model switches session-scoped by default', async () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'x-model' }))
}
})
expect(createSlashHandler(ctx)('/model x-model')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'x-model'
})
})
it('opens the model picker with refresh for /model --refresh', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/model --refresh')).toBe(true)
expect(getOverlayState().modelPicker).toEqual({ refresh: true })
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('honors TUI picker session scope without adding --global', async () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'anthropic/claude-sonnet-4.6' }))
}
})
expect(
createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`)
).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'anthropic/claude-sonnet-4.6 --provider openrouter --session'
})
})
it('does not duplicate --global for explicit persistent model switches', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
createSlashHandler(ctx)('/model x-model --global')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
confirm_expensive_model: false,
key: 'model',
session_id: 'sid-abc',
value: 'x-model --global'
})
})
it('applies /reasoning hide to the thinking section immediately', async () => {
patchUiState({ sections: { thinking: 'expanded' }, showReasoning: true, sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'hide' }))
}
})
expect(createSlashHandler(ctx)('/reasoning hide')).toBe(true)
await vi.waitFor(() => {
expect(getUiState().showReasoning).toBe(false)
expect(getUiState().sections.thinking).toBe('hidden')
})
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'reasoning',
session_id: 'sid-abc',
value: 'hide'
})
})
it('applies /reasoning show to the thinking section immediately', async () => {
patchUiState({ sections: { thinking: 'hidden' }, showReasoning: false, sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
...buildGateway(),
rpc: vi.fn(() => Promise.resolve({ value: 'show' }))
}
})
expect(createSlashHandler(ctx)('/reasoning show')).toBe(true)
await vi.waitFor(() => {
expect(getUiState().showReasoning).toBe(true)
expect(getUiState().sections.thinking).toBe('expanded')
})
})
it('reads /reasoning status for the active session', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/reasoning')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.get', {
key: 'reasoning',
session_id: 'sid-abc'
})
})
it.each(['low', 'max', 'ultra'])('sends plain /reasoning %s without a scope (session default)', effort => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)(`/reasoning ${effort}`)).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'reasoning',
session_id: 'sid-abc',
value: effort
})
})
it('sends /reasoning <level> --global as global config.set', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/reasoning high --global')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'reasoning',
scope: 'global',
session_id: 'sid-abc',
value: 'high'
})
})
it('strips /reasoning session flags before config.set', () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/reasoning low --session')).toBe(true)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'reasoning',
scope: 'session',
session_id: 'sid-abc',
value: 'low'
})
})
it('opens the skills hub locally for bare /skills', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/skills')).toBe(true)
expect(getOverlayState().skillsHub).toBe(true)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /skills install <name> to skills.manage without opening overlay', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
expect(getOverlayState().skillsHub).toBe(false)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'install',
query: 'foo'
})
})
it('opens the pet picker for /pet list only', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/pet list')).toBe(true)
expect(getOverlayState().petPicker).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
resetOverlayState()
expect(createSlashHandler(ctx)('/pet')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'pet' }))
resetOverlayState()
expect(createSlashHandler(ctx)('/pet toggle')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
'slash.exec',
expect.objectContaining({ command: 'pet toggle' })
)
})
it('routes /pet <slug> to the slash worker without opening the picker', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/pet boba')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'pet boba' }))
})
it('routes /skills inspect <name> to skills.manage', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills inspect my-skill')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'inspect',
query: 'my-skill'
})
})
it('routes /skills search <query> to skills.manage', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills search vibe')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'search',
query: 'vibe'
})
})
it('routes /skills browse [page] to skills.manage with a numeric page', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills browse 3')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'browse',
page: 3
})
})
it('delegates non-native /skills subcommands to slash.exec', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills check')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'skills check',
session_id: null
})
})
it('passes /new <title> through to the session lifecycle', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/new sprint planning')
getOverlayState().confirm?.onConfirm()
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('routes the /reset catalog alias through the local fresh-session lifecycle', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/new': '/new',
'/reset': '/new'
}
}
}
})
createSlashHandler(ctx)('/reset')
getOverlayState().confirm?.onConfirm()
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', undefined)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('keeps visible scrollback when branching a TUI session', async () => {
patchUiState({ sid: 'sid-parent' })
const rpc = vi.fn(() => Promise.resolve({ session_id: 'sid-branch', title: 'branch title' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/branch branch title')).toBe(true)
expect(rpc).toHaveBeenCalledWith('session.branch', { name: 'branch title', session_id: 'sid-parent' })
await vi.waitFor(() => {
expect(getUiState().sid).toBe('sid-branch')
expect(ctx.transcript.sys).toHaveBeenCalledWith('branched → branch title')
})
expect(ctx.transcript.setHistoryItems).not.toHaveBeenCalled()
})
it('reloads skills in the live gateway and refreshes the catalog', async () => {
const rpc = vi.fn((method: string) => {
if (method === 'skills.reload') {
return Promise.resolve({ output: '42 skill(s) available' })
}
if (method === 'commands.catalog') {
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
}
return Promise.resolve({})
})
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/reload-skills')
expect(rpc).toHaveBeenCalledWith('skills.reload', {})
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
expect(ctx.local.setCatalog).toHaveBeenCalledWith(
expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
)
})
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
// Regressions from Copilot review on #19835: /voice output + frontend
// binding state must both track the gateway's fresh ``record_key`` on
// every response, or a config edit shows the new shortcut in text
// while push-to-talk still fires the old one until the next mtime
// poll (~5s).
it('/voice status renders the gateway record_key and pushes it into frontend state', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'ctrl+space', tts: false }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice status')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Record key: Ctrl+Space')
})
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space' })
)
})
it('/voice on renders the configured binding for the start/stop hint', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'alt+r', tts: false }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice on')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
})
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
})
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: false, tts: false }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice status')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Record key: Ctrl+B')
})
})
// Round-2 Copilot review on #19835: a response missing ``record_key``
// (e.g. the old tts branch, or any future branch that forgets to
// include it) MUST NOT clobber the user's cached binding back to
// Ctrl+B. The label still renders the default for display; the
// frontend state keeps whatever was last authoritatively set.
it('/voice tts without record_key does not clobber cached frontend binding', async () => {
const rpc = vi.fn(() => Promise.resolve({ enabled: true, tts: true }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/voice tts')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice TTS enabled.')
})
expect(ctx.voice.setVoiceRecordKey).not.toHaveBeenCalled()
})
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(getUiState().detailsModeCommandOverride).toBe(true)
expect(getUiState().sections).toEqual({
thinking: 'expanded',
tools: 'expanded',
subagents: 'expanded',
activity: 'expanded'
})
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode',
value: 'expanded'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded')
})
it('sets a per-section override and persists it under details_mode.<section>', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/details activity hidden')).toBe(true)
expect(getUiState().sections.activity).toBe('hidden')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
key: 'details_mode.activity',
value: 'hidden'
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details activity: hidden')
})
it('clears a per-section override on /details <section> reset', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/details tools expanded')
expect(getUiState().sections.tools).toBe('expanded')
createSlashHandler(ctx)('/details tools reset')
expect(getUiState().sections.tools).toBeUndefined()
expect(ctx.gateway.rpc).toHaveBeenLastCalledWith('config.set', {
key: 'details_mode.tools',
value: ''
})
expect(ctx.transcript.sys).toHaveBeenCalledWith('details tools: reset')
})
it('rejects unknown section modes with a usage hint', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/details tools blink')
expect(getUiState().sections.tools).toBeUndefined()
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /details <section> [hidden|collapsed|expanded|reset]')
})
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.each([
['/browser status', 'browser.manage', { action: 'status', session_id: null }],
['/browser connect', 'browser.manage', { action: 'connect', session_id: null, url: 'http://127.0.0.1:9222' }],
['/reload-mcp', 'reload.mcp', { session_id: null }],
['/reload', 'reload.env', {}],
['/stop', 'process.stop', {}],
['/fast status', 'config.get', { key: 'fast', session_id: null }],
['/busy status', 'config.get', { key: 'busy' }],
['/indicator', 'config.get', { key: 'indicator' }]
])('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('renders browser connect progress messages from the gateway', async () => {
const rpc = vi.fn(() =>
Promise.resolve({
connected: false,
messages: [
"Chromium-family browser isn't running with remote debugging — attempting to launch...",
'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
],
url: 'http://127.0.0.1:9222'
})
)
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/browser connect')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenCalledWith(
'checking Chromium-family browser remote debugging at http://127.0.0.1:9222...'
)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(
"Chromium-family browser isn't running with remote debugging — attempting to launch..."
)
expect(ctx.transcript.sys).toHaveBeenCalledWith(
'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
)
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('browser connect failed')
})
})
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('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
})
it('rejects unknown indicator styles before hitting the gateway', () => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /indicator [ascii|emoji|kaomoji|unicode]')
})
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('/later')).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('lets exact catalog commands win over longer prefix matches', async () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/profile': '/profile',
'/plugins': '/plugins'
}
}
}
})
expect(createSlashHandler(ctx)('/profile')).toBe(true)
await vi.waitFor(() => {
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'profile',
session_id: null
})
})
expect(ctx.transcript.sys).not.toHaveBeenCalledWith(expect.stringContaining('ambiguous command'))
})
it('keeps ambiguous prefix handling when there is no exact catalog match', () => {
const ctx = buildCtx({
local: {
catalog: {
canon: {
'/status': '/status',
'/statusbar': '/statusbar'
}
}
}
})
expect(createSlashHandler(ctx)('/stat')).toBe(true)
expect(ctx.transcript.sys).toHaveBeenCalledWith('ambiguous command: /status, /statusbar')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
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 command.dispatch payloads returned directly by slash.exec', async () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.resolve({
message: 'complete all the steps and provide a final report',
notice: '⊙ Goal set (20-turn budget): complete all the steps and provide a final report',
type: 'send'
})
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/goal complete all the steps and provide a final report')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(
'⊙ Goal set (20-turn budget): complete all the steps and provide a final report'
)
})
expect(ctx.transcript.send).toHaveBeenCalledWith('complete all the steps and provide a final report')
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('/goal: no output')
expect(ctx.gateway.gw.request).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
})
it('/history pages the current TUI transcript (user + assistant)', () => {
const ctx = buildCtx({
local: {
...buildLocal(),
getHistoryItems: vi.fn(() => [
{ role: 'user', text: 'hello' },
{ role: 'system', text: 'ignore me' },
{ role: 'assistant', text: 'hi there' },
{ role: 'user', text: 'test' }
])
}
})
createSlashHandler(ctx)('/history')
expect(ctx.transcript.page).toHaveBeenCalledTimes(1)
const [body, title] = ctx.transcript.page.mock.calls[0]!
expect(title).toBe('History')
expect(body).toContain('[You #1]')
expect(body).toContain('hello')
expect(body).toContain('[Hermes #2]')
expect(body).toContain('hi there')
expect(body).toContain('[You #3]')
expect(body).not.toContain('ignore me')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('/history reports empty state without paging', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/history')
expect(ctx.transcript.page).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet')
})
it('/save forwards to session.save RPC and reports the returned file', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ file: '/tmp/hermes_conversation_test.json' }))
const ctx = buildCtx({
gateway: { ...buildGateway(), rpc },
local: {
...buildLocal(),
getHistoryItems: vi.fn(() => [
{ role: 'system', text: 'intro' },
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'hi there' }
])
}
})
createSlashHandler(ctx)('/save')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(rpc).toHaveBeenCalledWith('session.save', { session_id: 'sid-abc' })
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('conversation saved to: /tmp/hermes_conversation_test.json')
})
})
it('/save reports empty state without calling the RPC or slash worker', () => {
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/save')
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet')
})
it('/save without an active session tells the user instead of hitting the RPC', () => {
// sid stays null (default) but there IS visible conversation
const rpc = vi.fn(() => Promise.resolve({}))
const ctx = buildCtx({
gateway: { ...buildGateway(), rpc },
local: {
...buildLocal(),
getHistoryItems: vi.fn(() => [{ role: 'user', text: 'hello' }])
}
})
createSlashHandler(ctx)('/save')
expect(rpc).not.toHaveBeenCalled()
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' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/title my title')
expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc', title: 'my title' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('session title set: my title')
})
})
it('/title with no args fetches and displays the current title', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ title: 'demo title' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/title')
expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('title: demo title')
})
})
})
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,
openEditor: vi.fn(async () => {}),
paste: vi.fn(),
queueRef: { current: [] as string[] },
selection: { copySelection: vi.fn(async () => '') },
setInput: vi.fn()
})
const buildGateway = () => ({
gw: {
getLogTail: vi.fn(() => ''),
kill: 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(),
setCatalog: vi.fn()
})
const buildSession = () => ({
closeSession: vi.fn(() => Promise.resolve(null)),
die: vi.fn(),
dieWithCode: vi.fn(),
guardBusySessionSwitch: vi.fn(() => false),
newLiveSession: vi.fn(),
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(),
setVoiceRecordKey: vi.fn(),
setVoiceTts: 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>
}