Download ui-tui/src/__tests__/createSlashHandler.test.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 42.5 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/__tests__/createSlashHandler.test.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/ui-tui/src/__tests__/createSlashHandler.test.ts
-
curl -L -o createSlashHandler.test.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/__tests__/createSlashHandler.test.ts
42.5 kB
| 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' | |
| import * as ClipboardModule from '../lib/clipboard.js' | |
| import * as Osc52Module from '../lib/osc52.js' | |
| import * as TerminalSetupModule from '../lib/terminalSetup.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(() => { | |
| vi.restoreAllMocks() | |
| 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('prefers OSC52 copy for remote sessions', async () => { | |
| const writeClipboardText = vi.spyOn(ClipboardModule, 'writeClipboardText').mockResolvedValue(true) | |
| const writeOsc52Clipboard = vi.spyOn(Osc52Module, 'writeOsc52Clipboard').mockReturnValue(true) | |
| vi.spyOn(TerminalSetupModule, 'isRemoteShellSession').mockReturnValue(true) | |
| const ctx = buildCtx({ | |
| local: { | |
| ...buildLocal(), | |
| getHistoryItems: vi.fn(() => [{ role: 'assistant', text: 'remote answer' }]) | |
| } | |
| }) | |
| expect(createSlashHandler(ctx)('/copy')).toBe(true) | |
| await vi.waitFor(() => { | |
| expect(writeOsc52Clipboard).toHaveBeenCalledWith('remote answer') | |
| }) | |
| expect(writeClipboardText).not.toHaveBeenCalled() | |
| expect(ctx.transcript.sys).toHaveBeenCalledWith('sent OSC52 copy sequence (terminal support required)') | |
| }) | |
| it('keeps native-first copy in local tmux sessions', async () => { | |
| const tmuxBackup = process.env.TMUX | |
| process.env.TMUX = '/tmp/tmux-123/default,1,0' | |
| const writeClipboardText = vi.spyOn(ClipboardModule, 'writeClipboardText').mockResolvedValue(true) | |
| const writeOsc52Clipboard = vi.spyOn(Osc52Module, 'writeOsc52Clipboard').mockReturnValue(true) | |
| vi.spyOn(TerminalSetupModule, 'isRemoteShellSession').mockReturnValue(false) | |
| const ctx = buildCtx({ | |
| local: { | |
| ...buildLocal(), | |
| getHistoryItems: vi.fn(() => [{ role: 'assistant', text: 'local answer' }]) | |
| } | |
| }) | |
| expect(createSlashHandler(ctx)('/copy')).toBe(true) | |
| await vi.waitFor(() => { | |
| expect(writeClipboardText).toHaveBeenCalledWith('local answer') | |
| }) | |
| expect(writeOsc52Clipboard).not.toHaveBeenCalled() | |
| expect(ctx.transcript.sys).toHaveBeenCalledWith('copied to clipboard') | |
| process.env.TMUX = tmuxBackup | |
| }) | |
| 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.each([ | |
| ['/new sprint planning', 'new session started', 'sprint planning'], | |
| ['/clear', undefined, undefined] | |
| ])('skips the confirmation for %s when config disables it', (command, message, title) => { | |
| patchUiState({ destructiveSlashConfirm: false }) | |
| const ctx = buildCtx() | |
| expect(createSlashHandler(ctx)(command)).toBe(true) | |
| expect(getOverlayState().confirm).toBeNull() | |
| expect(ctx.session.newSession).toHaveBeenCalledWith(message, title) | |
| }) | |
| 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('skips the confirmation for the /reset alias when config disables it', () => { | |
| patchUiState({ destructiveSlashConfirm: false }) | |
| const ctx = buildCtx({ | |
| local: { | |
| catalog: { | |
| canon: { | |
| '/new': '/new', | |
| '/reset': '/new' | |
| } | |
| } | |
| } | |
| }) | |
| expect(createSlashHandler(ctx)('/reset')).toBe(true) | |
| expect(getOverlayState().confirm).toBeNull() | |
| expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', undefined) | |
| }) | |
| 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, sending the body but showing the invocation', async () => { | |
| const skillMessage = | |
| '[IMPORTANT: The user has invoked the "hermes-agent-dev" skill, indicating they want you to follow its instructions.\n' + | |
| 'The full skill content is loaded below.]\n\nUse 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', | |
| display: '/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.send).toHaveBeenCalledWith(skillMessage, true, '/hermes-agent-dev') | |
| }) | |
| // The expanded skill body is model-facing: no transcript line may carry it. | |
| for (const [line] of ctx.transcript.sys.mock.calls) { | |
| expect(line).not.toContain('Use this skill to do X') | |
| } | |
| }) | |
| 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') | |
| }) | |
| // A pasted PR thread / diff / log reaches a skill command as its argument. | |
| // parseSlashCommand used to split the whole line on `\s+` and rejoin with a | |
| // single space, so every line break was gone before the skill ran — and the | |
| // fallback command.dispatch carried that flattened text. | |
| it('carries a multi-line argument to the backend without flattening it', async () => { | |
| patchUiState({ sid: 'sid-abc' }) | |
| const arg = 'line one\nline two\n\n indented tail' | |
| const ctx = buildCtx({ | |
| gateway: { | |
| gw: { | |
| getLogTail: vi.fn(() => ''), | |
| kill: vi.fn(), | |
| request: vi.fn((method: string) => | |
| method === 'slash.exec' ? Promise.reject(new Error('skill command')) : Promise.resolve({}) | |
| ) | |
| }, | |
| rpc: vi.fn(() => Promise.resolve({})) | |
| } | |
| }) | |
| createSlashHandler(ctx)(`/pr-triage ${arg}`) | |
| expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', { | |
| command: `pr-triage ${arg}`, | |
| session_id: 'sid-abc' | |
| }) | |
| await vi.waitFor(() => { | |
| expect(ctx.gateway.gw.request).toHaveBeenCalledWith('command.dispatch', { | |
| arg, | |
| name: 'pr-triage', | |
| session_id: 'sid-abc' | |
| }) | |
| }) | |
| }) | |
| 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> | |
| } | |