eslint --fix autofixes plus manual repairs: max-len line splits (fake-api handlers, notifier/slots JSDoc, spec signatures), charAt over non-null-asserted indexing in slash detect/menu cores, Array.from for code-point capping, typeof assertions for unbound-method in specs, generic getByRole for the send-button cast, effect disposer void-wrap in command register, and dropped unused type imports.
716 lines
28 KiB
TypeScript
716 lines
28 KiB
TypeScript
/**
|
|
* Slash pipeline spec over the split architecture. SlashService keeps only
|
|
* the source roster (duplicate throw, disposal dropping live menu groups in
|
|
* every session controller) and per-session controller resolution; all
|
|
* interaction — track → menu store, pick execution via the scoped input
|
|
* events, keyboard arbitration, space/enter adjudication, and the
|
|
* scope-birth roster warm — is SlashController behavior, tested on a real
|
|
* session scope (createScope).
|
|
*/
|
|
import { Context } from 'cordis'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { SlashController, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
|
import type {
|
|
BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,
|
|
ReferenceInsert, SlashCandidate, SlashPick, SlashSource, SourceRoster, TriggerChar,
|
|
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
|
|
|
const sid = (k: string): SessionId => k as SessionId
|
|
|
|
interface PendingFetch {
|
|
resolve: (items: readonly SlashCandidate[]) => void
|
|
reject: (err: unknown) => void
|
|
query: string
|
|
signal: AbortSignal
|
|
session: ClientSessionContext
|
|
}
|
|
|
|
/** Deferred-candidates source: settle each fetch by hand; warm is a spy. */
|
|
function deferredSource(trigger: TriggerChar, name: string, over: Partial<SlashSource> = {}) {
|
|
const pending: PendingFetch[] = []
|
|
const warm = vi.fn()
|
|
const source: SlashSource = {
|
|
trigger,
|
|
name,
|
|
candidates: (session, req) => new Promise<readonly SlashCandidate[]>((resolve, reject) => {
|
|
pending.push({ resolve, reject, query: req.query, signal: req.signal, session })
|
|
}),
|
|
onPick: () => undefined,
|
|
warm,
|
|
...over,
|
|
}
|
|
return { source, pending, warm }
|
|
}
|
|
|
|
/** Source whose candidates resolve immediately; picks are recorded. */
|
|
function readySource(
|
|
trigger: TriggerChar, name: string, items: readonly SlashCandidate[], onPick?: (pick: SlashPick) => PickOutcome,
|
|
) {
|
|
const picks: SlashPick[] = []
|
|
const source: SlashSource = {
|
|
trigger,
|
|
name,
|
|
candidates: () => Promise.resolve(items),
|
|
onPick: (pick) => {
|
|
picks.push(pick)
|
|
return onPick?.(pick)
|
|
},
|
|
}
|
|
return { source, picks }
|
|
}
|
|
|
|
const claimOf = (token: string): CommandClaim =>
|
|
({ token, submit: () => Promise.resolve({ kind: 'success' }) })
|
|
|
|
/** One microtask hop: lets settled candidate promises flow into the store. */
|
|
const tick = () => Promise.resolve()
|
|
|
|
/** Direct controller bench: real scope tag + live roster array. */
|
|
function controllerBench(sources: SlashSource[] = [], key = 'a') {
|
|
const root = new Context()
|
|
const scope = createScope(root, sid(key))
|
|
const roster: SourceRoster = {
|
|
sources: trigger => sources.filter(s => s.trigger === trigger),
|
|
all: () => sources,
|
|
}
|
|
const controller = new SlashController({ actx: scope.ctx, sessionId: sid(key), roster })
|
|
return { root, actx: scope.ctx, controller, sources }
|
|
}
|
|
|
|
/** Real-service bench: a sessions face resolving scope tags to session ids. */
|
|
async function serviceBench() {
|
|
const root = new Context()
|
|
root.provide('sessions', {
|
|
scopeOf: (c: Context) => scopeOf(c),
|
|
})
|
|
await root.plugin(SlashService).await()
|
|
const slash = root.get('slash') as SlashService
|
|
const mint = (key: string) => {
|
|
const scope = createScope(root, sid(key))
|
|
return { actx: scope.ctx, fiber: scope.fiber }
|
|
}
|
|
return { root, slash, mint }
|
|
}
|
|
|
|
describe('registerSource', () => {
|
|
it('throws on a duplicate (trigger, name); same name across triggers is fine', async () => {
|
|
const { slash } = await serviceBench()
|
|
slash.registerSource(readySource('/', 'command', []).source)
|
|
expect(() => slash.registerSource(readySource('/', 'command', []).source))
|
|
.toThrow(/already registered/)
|
|
slash.registerSource(readySource('@', 'command', []).source)
|
|
})
|
|
|
|
it('disposal frees the name and drops the live menu group in every session controller', async () => {
|
|
const { slash, mint } = await serviceBench()
|
|
const a = readySource('/', 'alpha', [{ name: 'one' }])
|
|
const b = deferredSource('/', 'beta')
|
|
slash.registerSource(a.source)
|
|
const disposeB = slash.registerSource(b.source)
|
|
|
|
const ca = slash.sessionOf(mint('a').actx)
|
|
const cb = slash.sessionOf(mint('b').actx)
|
|
ca.track('/o', 2, { tier: 'plain' }, 1)
|
|
cb.track('/o', 2, { tier: 'plain' }, 1)
|
|
await tick()
|
|
expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
|
|
expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
|
|
|
|
disposeB()
|
|
expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
|
|
expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
|
|
// The name is free again, and a stale double-dispose stays a no-op.
|
|
disposeB()
|
|
slash.registerSource(deferredSource('/', 'beta').source)
|
|
})
|
|
|
|
it('HMR shape: dispose of the registering fiber removes the source', async () => {
|
|
const { root, slash, mint } = await serviceBench()
|
|
const controller = slash.sessionOf(mint('a').actx)
|
|
const fiber = root.plugin({
|
|
apply(pluginCtx: Context) {
|
|
pluginCtx.effect(
|
|
() => slash.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source),
|
|
'test: slash source',
|
|
)
|
|
},
|
|
})
|
|
await fiber.await()
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
await tick()
|
|
expect(controller.menu.getSnapshot().open).toBe(true)
|
|
|
|
await fiber.dispose()
|
|
// Group dropped with the fiber; a fresh track finds no sources → closed.
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('sessionOf', () => {
|
|
it('resolves lazily: same scope → same resident controller; another session → its own', async () => {
|
|
const { slash, mint } = await serviceBench()
|
|
const a = mint('a')
|
|
const first = slash.sessionOf(a.actx)
|
|
expect(slash.sessionOf(a.actx)).toBe(first)
|
|
expect(slash.sessionOf(mint('b').actx)).not.toBe(first)
|
|
})
|
|
|
|
it('throws off an unscoped context', async () => {
|
|
const { root, slash } = await serviceBench()
|
|
expect(() => slash.sessionOf(root)).toThrow(/requires a session scope/)
|
|
})
|
|
|
|
it('warms the roster once at controller birth with the session projection', async () => {
|
|
const { slash, mint } = await serviceBench()
|
|
const cmd = deferredSource('/', 'command')
|
|
const sub = deferredSource('@', 'subagent')
|
|
slash.registerSource(cmd.source)
|
|
slash.registerSource(sub.source)
|
|
const a = mint('a')
|
|
slash.sessionOf(a.actx)
|
|
expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
|
expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
|
// Re-resolution of the resident controller never re-warms.
|
|
slash.sessionOf(a.actx)
|
|
expect(cmd.warm).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('the scope disposer removes and disposes the controller; a re-mint resolves fresh', async () => {
|
|
const { slash, mint } = await serviceBench()
|
|
slash.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source)
|
|
const a = mint('a')
|
|
const controller = slash.sessionOf(a.actx)
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
await tick()
|
|
expect(controller.menu.getSnapshot().open).toBe(true)
|
|
|
|
await a.fiber.dispose()
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
|
|
const again = mint('a')
|
|
expect(slash.sessionOf(again.actx)).not.toBe(controller)
|
|
})
|
|
|
|
it('two sessions are isolated: one menu opening never touches the other', async () => {
|
|
const { slash, mint } = await serviceBench()
|
|
const src = deferredSource('/', 'command')
|
|
slash.registerSource(src.source)
|
|
const ca = slash.sessionOf(mint('a').actx)
|
|
const cb = slash.sessionOf(mint('b').actx)
|
|
|
|
ca.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(ca.menu.getSnapshot().open).toBe(true)
|
|
expect(cb.menu.getSnapshot().open).toBe(false)
|
|
|
|
src.pending[0]!.resolve([{ name: 'goal' }])
|
|
await tick()
|
|
expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
|
|
expect(cb.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('track', () => {
|
|
it('drives seed → pending → ready through the store', async () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const skill = deferredSource('/', 'skill')
|
|
const { controller } = controllerBench([cmd.source, skill.source])
|
|
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
let state = controller.menu.getSnapshot()
|
|
expect(state.open).toBe(true)
|
|
expect(state.groups).toEqual([
|
|
{ source: 'command', status: 'pending', items: [] },
|
|
{ source: 'skill', status: 'pending', items: [] },
|
|
])
|
|
|
|
cmd.pending[0]!.resolve([{ name: 'goal' }])
|
|
await tick()
|
|
state = controller.menu.getSnapshot()
|
|
expect(state.groups[0]).toEqual({ source: 'command', status: 'ready', items: [{ name: 'goal' }] })
|
|
expect(state.groups[1]!.status).toBe('pending')
|
|
expect(state.highlight).toEqual({ source: 'command', index: 0 })
|
|
})
|
|
|
|
it('stamps the caller draftRev into the hit span', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 7)
|
|
expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
|
|
})
|
|
|
|
it('candidates receive the session projection, identity only', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
|
|
})
|
|
|
|
it('query refinement supersedes the old generation and aborts its fetch', async () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
const gen1 = controller.menu.getSnapshot().generation
|
|
controller.track('/go', 3, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
|
|
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
|
|
|
// A late settle of the aborted fetch is dropped even before the
|
|
// generation gate: the group stays pending until the live fetch lands.
|
|
cmd.pending[0]!.resolve([{ name: 'stale' }])
|
|
await tick()
|
|
expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
|
|
cmd.pending[1]!.resolve([{ name: 'goal' }])
|
|
await tick()
|
|
expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
|
|
})
|
|
|
|
it('same hit re-track refreshes the span stamp without refetching', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
// Same token under the caret, later revision (an edit past the caret).
|
|
controller.track('/g x', 2, { tier: 'plain' }, 2)
|
|
expect(cmd.pending).toHaveLength(1)
|
|
expect(controller.menu.getSnapshot().generation).toBe(1)
|
|
})
|
|
|
|
it('no live trigger closes the menu and aborts the fetch', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
controller.track('hello', 5, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('a trigger with no registered sources never opens', () => {
|
|
const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
|
|
controller.track('@w', 2, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('trigger switch reseeds the roster', () => {
|
|
const { controller } = controllerBench([
|
|
deferredSource('/', 'command').source,
|
|
deferredSource('@', 'subagent').source,
|
|
])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
|
|
controller.track('@w', 2, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
|
|
})
|
|
|
|
it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const skill = deferredSource('/', 'skill')
|
|
const { controller } = controllerBench([cmd.source, skill.source])
|
|
controller.track('/zzz', 4, { tier: 'plain' }, 1)
|
|
cmd.pending[0]!.resolve([])
|
|
await tick()
|
|
expect(controller.menu.getSnapshot().open).toBe(true)
|
|
skill.pending[0]!.resolve([])
|
|
await tick()
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('a rejecting source logs and silently drops its group', async () => {
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
try {
|
|
const cmd = deferredSource('/', 'command')
|
|
const skill = deferredSource('/', 'skill')
|
|
const { controller } = controllerBench([cmd.source, skill.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
skill.pending[0]!.reject(new Error('boom'))
|
|
cmd.pending[0]!.resolve([{ name: 'goal' }])
|
|
await tick()
|
|
const state = controller.menu.getSnapshot()
|
|
expect(state.groups.map(g => g.source)).toEqual(['command'])
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('scope-birth warm', () => {
|
|
it('construction warms every source once with the session projection', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const sub = deferredSource('@', 'subagent')
|
|
controllerBench([cmd.source, sub.source])
|
|
expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
|
expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
|
|
})
|
|
|
|
it('hook-less sources are skipped', () => {
|
|
const bare: SlashSource = {
|
|
trigger: '/',
|
|
name: 'bare',
|
|
candidates: () => Promise.resolve([]),
|
|
onPick: () => undefined,
|
|
}
|
|
const cmd = deferredSource('/', 'command')
|
|
// No throw on the hook-less source; the implementing one still warms.
|
|
controllerBench([bare, cmd.source])
|
|
expect(cmd.warm).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('dispose inerts every verb', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
controller.dispose()
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
expect(cmd.pending[0]!.signal.aborted).toBe(true)
|
|
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
expect(controller.arbitrate('down', false)).toBe('pass')
|
|
expect(controller.onSpace()).toBe(false)
|
|
controller.pick('command', 0)
|
|
})
|
|
})
|
|
|
|
describe('pick / scoped input events', () => {
|
|
function pickBench(outcomeOf: (pick: SlashPick) => PickOutcome) {
|
|
const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
|
|
const bench = controllerBench([cmd.source])
|
|
const begins: BeginCommandRequest[] = []
|
|
const inserts: InsertReferenceRequest[] = []
|
|
bench.actx.on('slash/input-begin-command', (req) => {
|
|
begins.push(req)
|
|
return true
|
|
})
|
|
bench.actx.on('slash/input-insert-reference', (req) => {
|
|
inserts.push(req)
|
|
return true
|
|
})
|
|
bench.controller.track('/g', 2, { tier: 'plain' }, 3)
|
|
return { ...bench, cmd, begins, inserts }
|
|
}
|
|
|
|
it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
|
|
const claim = claimOf('/goal ')
|
|
const { controller, cmd, begins } = pickBench(() => ({ claim }))
|
|
await tick()
|
|
controller.pick('command', 0)
|
|
expect(cmd.picks).toHaveLength(1)
|
|
expect(cmd.picks[0]).toMatchObject({
|
|
candidate: { name: 'goal' },
|
|
session: { sessionId: sid('a') },
|
|
position: 'leading',
|
|
via: 'menu',
|
|
span: { start: 0, end: 2, draftRev: 3 },
|
|
})
|
|
expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('routes an insert outcome through the scoped insert-reference event', async () => {
|
|
const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
|
|
const { controller, inserts } = pickBench(() => ({ insert }))
|
|
await tick()
|
|
controller.pick('command', 1)
|
|
expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
|
|
})
|
|
|
|
it('routes a text outcome through the scoped insert-text event (decision 21) and closes the menu', async () => {
|
|
const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
|
|
const texts: Array<{ text: string; span: unknown }> = []
|
|
actx.on('slash/input-insert-text', (req) => {
|
|
texts.push(req)
|
|
return true
|
|
})
|
|
await tick()
|
|
controller.pick('command', 0)
|
|
expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('a text outcome the input declines answers false on the space path', async () => {
|
|
const src: SlashSource = {
|
|
trigger: '/',
|
|
name: 'command',
|
|
candidates: () => Promise.resolve([]),
|
|
onPick: () => undefined,
|
|
matchSpace: () => ({ text: '/goal ' }),
|
|
}
|
|
const { controller, actx } = controllerBench([src])
|
|
actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
|
|
controller.track('/goal', 5, { tier: 'plain' }, 1)
|
|
expect(controller.onSpace()).toBe(false)
|
|
})
|
|
|
|
it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
|
|
const claim = claimOf('/goal ')
|
|
const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
|
|
const { root, controller } = controllerBench([cmd.source])
|
|
const foreign: BeginCommandRequest[] = []
|
|
const rootSeen: BeginCommandRequest[] = []
|
|
createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
|
|
foreign.push(req)
|
|
return true
|
|
})
|
|
// Untagged root listeners are admitted globally (the carrier contract).
|
|
root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
|
|
controller.track('/g', 2, { tier: 'plain' }, 3)
|
|
await tick()
|
|
controller.pick('command', 0)
|
|
expect(foreign).toHaveLength(0)
|
|
expect(rootSeen).toHaveLength(1)
|
|
})
|
|
|
|
it("'handled' and undefined outcomes only close the menu", async () => {
|
|
const { controller, begins, inserts } = pickBench(() => 'handled')
|
|
await tick()
|
|
controller.pick('command', 0)
|
|
expect(begins).toHaveLength(0)
|
|
expect(inserts).toHaveLength(0)
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('closed menu / vanished candidate picks are no-ops', async () => {
|
|
const { controller, cmd } = pickBench(() => undefined)
|
|
await tick()
|
|
controller.pick('command', 9)
|
|
controller.pick('ghost', 0)
|
|
expect(cmd.picks).toHaveLength(0)
|
|
expect(controller.menu.getSnapshot().open).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('lexicon', () => {
|
|
function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): SlashSource {
|
|
return {
|
|
trigger,
|
|
name,
|
|
candidates: () => Promise.resolve([]),
|
|
onPick: () => undefined,
|
|
...(hasHook ? { lexicon: () => roll } : {}),
|
|
}
|
|
}
|
|
|
|
it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
|
|
const seen: unknown[] = []
|
|
const skill: SlashSource = {
|
|
trigger: '/',
|
|
name: 'skill',
|
|
candidates: () => Promise.resolve([]),
|
|
onPick: () => undefined,
|
|
lexicon: (projection) => {
|
|
seen.push(projection)
|
|
return ['commit-helper', 'review']
|
|
},
|
|
}
|
|
const { controller } = controllerBench([
|
|
lexSource('/', 'command', undefined, false), // no hook: not polled
|
|
skill,
|
|
lexSource('@', 'subagent', ['worker-1']),
|
|
])
|
|
const rolls = controller.lexicon()
|
|
expect([...rolls.keys()]).toEqual(['/', '@'])
|
|
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
|
|
expect(rolls.get('@')).toEqual(['worker-1'])
|
|
expect(seen).toEqual([{ sessionId: sid('a') }])
|
|
})
|
|
|
|
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
|
|
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
|
|
expect(controller.lexicon().size).toBe(0)
|
|
})
|
|
|
|
it('two sources on one trigger concatenate in registration order', () => {
|
|
const { controller } = controllerBench([
|
|
lexSource('/', 'skill', ['b', 'a']),
|
|
lexSource('/', 'prompt', ['c']),
|
|
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
|
|
])
|
|
const rolls = controller.lexicon()
|
|
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
|
|
expect(rolls.has('@')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('arbitrate', () => {
|
|
async function menuBench() {
|
|
const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
|
|
const { controller } = controllerBench([cmd.source])
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
await tick()
|
|
return { controller, cmd }
|
|
}
|
|
|
|
it('up/down move the highlight and are consumed', async () => {
|
|
const { controller } = await menuBench()
|
|
expect(controller.arbitrate('down', false)).toBe('consumed')
|
|
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
|
|
expect(controller.arbitrate('up', false)).toBe('consumed')
|
|
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
|
})
|
|
|
|
it('enter picks the highlight through the pipeline', async () => {
|
|
const { controller, cmd } = await menuBench()
|
|
expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
|
|
expect(cmd.picks[0]!.candidate.name).toBe('goal')
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('escape closes and consumes', async () => {
|
|
const { controller } = await menuBench()
|
|
expect(controller.arbitrate('escape', false)).toBe('consumed')
|
|
expect(controller.menu.getSnapshot().open).toBe(false)
|
|
})
|
|
|
|
it('IME composition passes every key untouched', async () => {
|
|
const { controller } = await menuBench()
|
|
for (const key of ['up', 'down', 'enter', 'escape'] as const) {
|
|
expect(controller.arbitrate(key, true)).toBe('pass')
|
|
}
|
|
expect(controller.menu.getSnapshot().open).toBe(true)
|
|
})
|
|
|
|
it('closed menu passes; an open menu without a highlight passes enter', () => {
|
|
const cmd = deferredSource('/', 'command')
|
|
const { controller } = controllerBench([cmd.source])
|
|
expect(controller.arbitrate('enter', false)).toBe('pass')
|
|
// Open with the only group still pending: nothing to pick yet.
|
|
controller.track('/g', 2, { tier: 'plain' }, 1)
|
|
expect(controller.arbitrate('enter', false)).toBe('pass')
|
|
})
|
|
})
|
|
|
|
describe('onSpace', () => {
|
|
function spaceSource(name: string, answer: PickOutcome, calls: string[]): SlashSource {
|
|
return {
|
|
trigger: '/',
|
|
name,
|
|
candidates: () => Promise.resolve([]),
|
|
onPick: () => undefined,
|
|
matchSpace: (_session, token) => {
|
|
calls.push(`${name}:${token}`)
|
|
return answer
|
|
},
|
|
}
|
|
}
|
|
|
|
it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
|
|
const calls: string[] = []
|
|
const claim = claimOf('/goal ')
|
|
const { controller, actx } = controllerBench([
|
|
// Hook-less source: never polled, so it must not shadow the order below.
|
|
{ trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
|
|
spaceSource('first', undefined, calls),
|
|
spaceSource('second', { claim }, calls),
|
|
spaceSource('third', { claim: claimOf('/x ') }, calls),
|
|
])
|
|
const begins: BeginCommandRequest[] = []
|
|
actx.on('slash/input-begin-command', (req) => {
|
|
begins.push(req)
|
|
return true
|
|
})
|
|
controller.track('/goal', 5, { tier: 'plain' }, 1)
|
|
expect(controller.onSpace()).toBe(true)
|
|
expect(calls).toEqual(['first:/goal', 'second:/goal'])
|
|
expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
|
|
})
|
|
|
|
it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
|
|
const calls: string[] = []
|
|
const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
|
|
declined.actx.on('slash/input-begin-command', () => undefined)
|
|
declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
|
|
expect(declined.controller.onSpace()).toBe(false)
|
|
|
|
const handled = controllerBench([spaceSource('command', 'handled', calls)])
|
|
const begins: BeginCommandRequest[] = []
|
|
handled.actx.on('slash/input-begin-command', (req) => {
|
|
begins.push(req)
|
|
return true
|
|
})
|
|
handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
|
|
expect(handled.controller.onSpace()).toBe(true)
|
|
expect(begins).toHaveLength(0)
|
|
})
|
|
|
|
it('answers false off a non-leading hit or with no tracked hit', () => {
|
|
const calls: string[] = []
|
|
const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
|
|
expect(controller.onSpace()).toBe(false)
|
|
|
|
controller.track('say /goal', 9, { tier: 'plain' }, 1)
|
|
expect(controller.onSpace()).toBe(false)
|
|
expect(calls).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('adjudicate', () => {
|
|
const enterSource = (
|
|
trigger: TriggerChar, name: string,
|
|
matchEnter?: SlashSource['matchEnter'],
|
|
): SlashSource => ({
|
|
trigger,
|
|
name,
|
|
candidates: () => Promise.resolve([]),
|
|
onPick: () => undefined,
|
|
...(matchEnter !== undefined ? { matchEnter } : {}),
|
|
})
|
|
|
|
it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
|
|
const calls: string[] = []
|
|
const claim = claimOf('/goal ')
|
|
const { controller } = controllerBench([
|
|
enterSource('/', 'silent'),
|
|
enterSource('/', 'first', (session, line) => {
|
|
expect(session).toEqual({ sessionId: sid('a') })
|
|
calls.push(`first:${line}`)
|
|
return Promise.resolve(undefined)
|
|
}),
|
|
enterSource('/', 'second', (_session, line) => {
|
|
calls.push(`second:${line}`)
|
|
return Promise.resolve({ claim })
|
|
}),
|
|
enterSource('/', 'third', () => {
|
|
calls.push('third')
|
|
return Promise.resolve('handled')
|
|
}),
|
|
])
|
|
const result = await controller.adjudicate('/goal make it fast', new AbortController().signal)
|
|
expect(result).toEqual({ claim })
|
|
expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
|
|
})
|
|
|
|
it('skips sources of another trigger; all-undefined answers undefined', async () => {
|
|
const atHook = vi.fn(() => Promise.resolve('handled' as const))
|
|
const { controller } = controllerBench([
|
|
enterSource('@', 'subagent', atHook),
|
|
enterSource('/', 'command', () => Promise.resolve(undefined)),
|
|
])
|
|
await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined()
|
|
expect(atHook).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('a rejecting source rejects the whole adjudication', async () => {
|
|
const { controller } = controllerBench([
|
|
enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
|
|
enterSource('/', 'late', () => Promise.resolve('handled')),
|
|
])
|
|
await expect(controller.adjudicate('/goal x', new AbortController().signal))
|
|
.rejects.toThrow('warmup failed')
|
|
})
|
|
|
|
it('an aborted attempt signal stops the poll', async () => {
|
|
const hook = vi.fn(() => Promise.resolve(undefined))
|
|
const { controller } = controllerBench([enterSource('/', 'command', hook)])
|
|
const abort = new AbortController()
|
|
abort.abort(new Error('attempt released'))
|
|
await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released')
|
|
expect(hook).not.toHaveBeenCalled()
|
|
})
|
|
})
|