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 ...]') 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)) }) }) 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 }