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('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 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('routes /skills inspect 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 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('shows usage for an unknown /skills subcommand', () => { const ctx = buildCtx() createSlashHandler(ctx)('/skills zzz') expect(ctx.gateway.rpc).not.toHaveBeenCalled() expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills')) }) 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 ...]') 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 => ({ ...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 gateway: ReturnType local: ReturnType session: ReturnType transcript: ReturnType voice: ReturnType }