Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the repo ships a single JavaScript runtime: child_process for the gateway, vitest for tests, an esbuild + Solid build step. Mouse selection copies the rendered text you highlight, and the clipboard path is crash-proofed (a broken copy pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ -> ui-opentui/ and updates the launcher/installer/Docker references.
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
/**
|
||
* Slash dispatch test (spec §5 Layer 3/4). Pure logic: parse + the dispatch
|
||
* ladder (client → slash.exec → command.dispatch) against a fake SlashContext.
|
||
*/
|
||
import { describe, expect, test } from 'vitest'
|
||
|
||
import {
|
||
dispatchSlash,
|
||
mapCompletions,
|
||
parseSlash,
|
||
planCompletion,
|
||
readReplaceFrom,
|
||
type SlashContext
|
||
} from '../logic/slash.ts'
|
||
import type { PickerItem, SessionItem } from '../logic/store.ts'
|
||
|
||
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
|
||
|
||
describe('mapCompletions', () => {
|
||
test('maps complete.slash items → candidates (display/meta default)', () => {
|
||
expect(
|
||
mapCompletions({ items: [{ display: '/compact', meta: 'compress', text: '/compact' }, { text: '/details' }] })
|
||
).toEqual([
|
||
{ display: '/compact', meta: 'compress', text: '/compact' },
|
||
{ display: '/details', meta: '', text: '/details' }
|
||
])
|
||
expect(mapCompletions({ items: [] })).toEqual([])
|
||
expect(mapCompletions(null)).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('planCompletion (items 5 + 13)', () => {
|
||
test('a slash line → complete.slash with the full text (name AND args)', () => {
|
||
expect(planCompletion('/mod')).toEqual({ from: 0, method: 'complete.slash', params: { text: '/mod' } })
|
||
// args too — the gateway completes e.g. /details section names
|
||
expect(planCompletion('/details thi')).toEqual({
|
||
from: 0,
|
||
method: 'complete.slash',
|
||
params: { text: '/details thi' }
|
||
})
|
||
})
|
||
|
||
test('a trailing path-like word → complete.path with that word + token start offset', () => {
|
||
expect(planCompletion('explain @src/fo')).toEqual({
|
||
from: 'explain '.length,
|
||
method: 'complete.path',
|
||
params: { word: '@src/fo' }
|
||
})
|
||
expect(planCompletion('cat ./rea')).toEqual({
|
||
from: 'cat '.length,
|
||
method: 'complete.path',
|
||
params: { word: './rea' }
|
||
})
|
||
expect(planCompletion('open ~/proj')).toEqual({
|
||
from: 'open '.length,
|
||
method: 'complete.path',
|
||
params: { word: '~/proj' }
|
||
})
|
||
})
|
||
|
||
test('plain prose / multiline → no completion', () => {
|
||
expect(planCompletion('just some words')).toBeNull()
|
||
expect(planCompletion('hello')).toBeNull()
|
||
expect(planCompletion('/cmd with\nnewline')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('readReplaceFrom', () => {
|
||
test('reads gateway replace_from, falls back when absent/non-number', () => {
|
||
expect(readReplaceFrom({ items: [], replace_from: 9 }, 0)).toBe(9)
|
||
expect(readReplaceFrom({ items: [] }, 4)).toBe(4)
|
||
expect(readReplaceFrom({ replace_from: 'nope' }, 7)).toBe(7)
|
||
expect(readReplaceFrom(null, 2)).toBe(2)
|
||
})
|
||
})
|
||
|
||
describe('parseSlash', () => {
|
||
test('splits name + arg; rejects non-slash / empty', () => {
|
||
expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' })
|
||
expect(parseSlash('/model anthropic/claude')).toEqual({ name: 'model', arg: 'anthropic/claude' })
|
||
expect(parseSlash('hello')).toBeNull()
|
||
expect(parseSlash('/')).toBeNull()
|
||
})
|
||
})
|
||
|
||
interface Probe {
|
||
ctx: SlashContext
|
||
calls: Array<{ method: string; params: Record<string, unknown> }>
|
||
system: string[]
|
||
submitted: string[]
|
||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||
paged: Array<{ title: string; text: string }>
|
||
switched: SessionItem[][]
|
||
pickers: Array<{ title: string; items: PickerItem[]; onPick: (value: string) => void }>
|
||
quit: { value: boolean }
|
||
cleared: { value: boolean }
|
||
dashboard: { value: boolean }
|
||
copied: number[]
|
||
copyN: { value: (n: number) => boolean }
|
||
}
|
||
|
||
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
|
||
const calls: Probe['calls'] = []
|
||
const system: string[] = []
|
||
const submitted: string[] = []
|
||
const confirmed: Probe['confirmed'] = []
|
||
const paged: Probe['paged'] = []
|
||
const switched: Probe['switched'] = []
|
||
const pickers: Probe['pickers'] = []
|
||
const quit = { value: false }
|
||
const cleared = { value: false }
|
||
const dashboard = { value: false }
|
||
const copied: number[] = []
|
||
const copyN: Probe['copyN'] = { value: () => false }
|
||
const ctx: SlashContext = {
|
||
clearTranscript: () => (cleared.value = true),
|
||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||
copyResponse: n => {
|
||
copied.push(n)
|
||
return copyN.value(n)
|
||
},
|
||
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||
openDashboard: () => (dashboard.value = true),
|
||
openPager: (title, text) => paged.push({ text, title }),
|
||
openPicker: p => pickers.push(p),
|
||
openSwitcher: sessions => switched.push(sessions),
|
||
pushSystem: text => system.push(text),
|
||
quit: () => (quit.value = true),
|
||
request: (method, params) => {
|
||
calls.push({ method, params })
|
||
return request(method, params)
|
||
},
|
||
sessionId: () => 'sid-1',
|
||
submit: text => submitted.push(text)
|
||
}
|
||
return {
|
||
calls,
|
||
cleared,
|
||
confirmed,
|
||
copied,
|
||
copyN,
|
||
ctx,
|
||
dashboard,
|
||
paged,
|
||
pickers,
|
||
quit,
|
||
submitted,
|
||
switched,
|
||
system
|
||
}
|
||
}
|
||
|
||
describe('dispatchSlash — client commands', () => {
|
||
test('/quit quits without hitting the gateway', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
await dispatchSlash('/quit', p.ctx)
|
||
expect(p.quit.value).toBe(true)
|
||
expect(p.calls).toHaveLength(0)
|
||
})
|
||
|
||
test('/clear opens a confirm; running onConfirm clears the transcript', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
await dispatchSlash('/clear', p.ctx)
|
||
expect(p.confirmed).toHaveLength(1)
|
||
expect(p.cleared.value).toBe(false)
|
||
p.confirmed[0]!.onConfirm()
|
||
expect(p.cleared.value).toBe(true)
|
||
})
|
||
|
||
test('/logs opens the pager with the recent ring lines', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
await dispatchSlash('/logs', p.ctx)
|
||
expect(p.paged[0]?.title).toBe('Logs')
|
||
expect(p.paged[0]?.text).toContain('session created')
|
||
})
|
||
|
||
test('/sessions (and /resume) open the switcher with session.list rows', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
await dispatchSlash('/sessions', p.ctx)
|
||
expect(p.switched).toHaveLength(1)
|
||
expect(p.switched[0]).toEqual(FAKE_SESSIONS)
|
||
const p2 = makeCtx(async () => ({}))
|
||
await dispatchSlash('/resume', p2.ctx)
|
||
expect(p2.switched).toHaveLength(1)
|
||
})
|
||
|
||
test('/model (bare) opens a picker of authenticated providers’ models; pick switches', async () => {
|
||
const p = makeCtx(async method => {
|
||
if (method === 'model.options')
|
||
return {
|
||
model: 'claude-sonnet-4.6',
|
||
providers: [
|
||
{
|
||
authenticated: true,
|
||
models: ['claude-sonnet-4.6', 'claude-opus-4.6'],
|
||
name: 'Anthropic',
|
||
slug: 'anthropic'
|
||
},
|
||
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
|
||
]
|
||
}
|
||
return { output: 'switched' }
|
||
})
|
||
await dispatchSlash('/model', p.ctx)
|
||
expect(p.pickers).toHaveLength(1)
|
||
expect(p.pickers[0]!.title).toBe('Switch model')
|
||
// only the authenticated provider's models; current is marked
|
||
expect(p.pickers[0]!.items.map(i => i.value)).toEqual(['claude-sonnet-4.6', 'claude-opus-4.6'])
|
||
expect(p.pickers[0]!.items[0]!.label).toContain('✓')
|
||
// picking switches via slash.exec `model <name>`
|
||
p.pickers[0]!.onPick('claude-opus-4.6')
|
||
await Promise.resolve()
|
||
expect(p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6')).toBe(true)
|
||
})
|
||
|
||
test('/model <name> switches directly without opening the picker', async () => {
|
||
const p = makeCtx(async () => ({ output: 'ok' }))
|
||
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
|
||
expect(p.pickers).toHaveLength(0)
|
||
expect(p.calls[0]).toEqual({
|
||
method: 'slash.exec',
|
||
params: { command: 'model anthropic/claude-opus-4.6', session_id: 'sid-1' }
|
||
})
|
||
})
|
||
|
||
test('/copy copies via copyResponse; no system line on success', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
p.copyN.value = () => true
|
||
await dispatchSlash('/copy', p.ctx)
|
||
expect(p.copied).toEqual([1])
|
||
expect(p.system).toHaveLength(0)
|
||
})
|
||
|
||
test('/copy 2 passes the n-th index through', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
p.copyN.value = () => true
|
||
await dispatchSlash('/copy 2', p.ctx)
|
||
expect(p.copied).toEqual([2])
|
||
})
|
||
|
||
test('/copy when nothing to copy pushes a system notice', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
p.copyN.value = () => false
|
||
await dispatchSlash('/copy', p.ctx)
|
||
expect(p.system).toContain('Nothing to copy yet.')
|
||
})
|
||
|
||
test('/agents (and /tasks) open the agents dashboard', async () => {
|
||
const p = makeCtx(async () => ({}))
|
||
await dispatchSlash('/agents', p.ctx)
|
||
expect(p.dashboard.value).toBe(true)
|
||
const p2 = makeCtx(async () => ({}))
|
||
await dispatchSlash('/tasks', p2.ctx)
|
||
expect(p2.dashboard.value).toBe(true)
|
||
})
|
||
|
||
test('/skills opens a picker flattened from skills.manage list', async () => {
|
||
const p = makeCtx(async method =>
|
||
method === 'skills.manage' ? { skills: { media: ['ffmpeg', 'whisper'], web: ['firecrawl'] } } : {}
|
||
)
|
||
await dispatchSlash('/skills', p.ctx)
|
||
expect(p.pickers).toHaveLength(1)
|
||
expect(p.pickers[0]!.title).toBe('Skills')
|
||
expect(p.pickers[0]!.items.map(i => i.value).sort()).toEqual(['ffmpeg', 'firecrawl', 'whisper'])
|
||
})
|
||
|
||
test('/help renders the gateway catalog', async () => {
|
||
const p = makeCtx(async method =>
|
||
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
|
||
)
|
||
await dispatchSlash('/help', p.ctx)
|
||
expect(p.calls[0]?.method).toBe('commands.catalog')
|
||
expect(p.system.join('\n')).toContain('/model — switch model')
|
||
})
|
||
})
|
||
|
||
describe('dispatchSlash — server ladder', () => {
|
||
test('unknown command → slash.exec; SHORT output shown as a system line', async () => {
|
||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
|
||
await dispatchSlash('/status', p.ctx)
|
||
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
|
||
expect(p.system).toContain('all good')
|
||
expect(p.paged).toHaveLength(0)
|
||
})
|
||
|
||
test('LONG slash.exec output opens the pager (titled by command)', async () => {
|
||
const longText = Array.from({ length: 6 }, (_, i) => `output line ${i}`).join('\n')
|
||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: longText } : {}))
|
||
await dispatchSlash('/status', p.ctx)
|
||
expect(p.paged).toHaveLength(1)
|
||
expect(p.paged[0]?.title).toBe('Status')
|
||
expect(p.paged[0]?.text).toContain('output line 5')
|
||
expect(p.system).toHaveLength(0)
|
||
})
|
||
|
||
test('slash.exec rejects → command.dispatch; send result submits a user turn', async () => {
|
||
const p = makeCtx(async method => {
|
||
if (method === 'slash.exec') throw new Error('not a worker command')
|
||
if (method === 'command.dispatch') return { type: 'send', message: 'run the thing' }
|
||
return {}
|
||
})
|
||
await dispatchSlash('/dothing', p.ctx)
|
||
expect(p.calls.map(c => c.method)).toEqual(['slash.exec', 'command.dispatch'])
|
||
expect(p.submitted).toEqual(['run the thing'])
|
||
})
|
||
|
||
test('command.dispatch exec → system output', async () => {
|
||
const p = makeCtx(async method => {
|
||
if (method === 'slash.exec') throw new Error('reject')
|
||
return { type: 'exec', output: 'done' }
|
||
})
|
||
await dispatchSlash('/whatever', p.ctx)
|
||
expect(p.system).toContain('done')
|
||
})
|
||
})
|