service.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. /**
  2. * Slash pipeline spec over the split architecture. SlashService keeps only
  3. * the source roster (duplicate throw, disposal dropping live menu groups in
  4. * every session controller) and per-session controller resolution; all
  5. * interaction — track → menu store, pick execution via the scoped input
  6. * events, keyboard arbitration, space/enter adjudication, and the
  7. * scope-birth roster warm — is SlashController behavior, tested on a real
  8. * session scope (createScope).
  9. */
  10. import { Context } from 'cordis'
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  14. import { SlashController, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
  15. import type {
  16. BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,
  17. ReferenceInsert, SlashCandidate, SlashPick, SlashSource, SourceRoster, TriggerChar,
  18. } from '@deepseek-ai/dsh-client-ui-slash/client'
  19. const sid = (k: string): SessionId => k as SessionId
  20. interface PendingFetch {
  21. resolve: (items: readonly SlashCandidate[]) => void
  22. reject: (err: unknown) => void
  23. query: string
  24. signal: AbortSignal
  25. session: ClientSessionContext
  26. }
  27. /** Deferred-candidates source: settle each fetch by hand; warm is a spy. */
  28. function deferredSource(trigger: TriggerChar, name: string, over: Partial<SlashSource> = {}) {
  29. const pending: PendingFetch[] = []
  30. const warm = vi.fn()
  31. const source: SlashSource = {
  32. trigger,
  33. name,
  34. candidates: (session, req) => new Promise<readonly SlashCandidate[]>((resolve, reject) => {
  35. pending.push({ resolve, reject, query: req.query, signal: req.signal, session })
  36. }),
  37. onPick: () => undefined,
  38. warm,
  39. ...over,
  40. }
  41. return { source, pending, warm }
  42. }
  43. /** Source whose candidates resolve immediately; picks are recorded. */
  44. function readySource(
  45. trigger: TriggerChar, name: string, items: readonly SlashCandidate[], onPick?: (pick: SlashPick) => PickOutcome,
  46. ) {
  47. const picks: SlashPick[] = []
  48. const source: SlashSource = {
  49. trigger,
  50. name,
  51. candidates: () => Promise.resolve(items),
  52. onPick: (pick) => {
  53. picks.push(pick)
  54. return onPick?.(pick)
  55. },
  56. }
  57. return { source, picks }
  58. }
  59. const claimOf = (token: string): CommandClaim =>
  60. ({ token, submit: () => Promise.resolve({ kind: 'success' }) })
  61. /** One microtask hop: lets settled candidate promises flow into the store. */
  62. const tick = () => Promise.resolve()
  63. /** Direct controller bench: real scope tag + live roster array. */
  64. function controllerBench(sources: SlashSource[] = [], key = 'a') {
  65. const root = new Context()
  66. const scope = createScope(root, sid(key))
  67. const roster: SourceRoster = {
  68. sources: trigger => sources.filter(s => s.trigger === trigger),
  69. all: () => sources,
  70. }
  71. const controller = new SlashController({ actx: scope.ctx, sessionId: sid(key), roster })
  72. return { root, actx: scope.ctx, controller, sources }
  73. }
  74. /** Real-service bench: a sessions face resolving scope tags to session ids. */
  75. async function serviceBench() {
  76. const root = new Context()
  77. root.provide('sessions', {
  78. scopeOf: (c: Context) => scopeOf(c),
  79. })
  80. await root.plugin(SlashService).await()
  81. const slash = root.get('slash') as SlashService
  82. const mint = (key: string) => {
  83. const scope = createScope(root, sid(key))
  84. return { actx: scope.ctx, fiber: scope.fiber }
  85. }
  86. return { root, slash, mint }
  87. }
  88. describe('registerSource', () => {
  89. it('throws on a duplicate (trigger, name); same name across triggers is fine', async () => {
  90. const { slash } = await serviceBench()
  91. slash.registerSource(readySource('/', 'command', []).source)
  92. expect(() => slash.registerSource(readySource('/', 'command', []).source))
  93. .toThrow(/already registered/)
  94. slash.registerSource(readySource('@', 'command', []).source)
  95. })
  96. it('disposal frees the name and drops the live menu group in every session controller', async () => {
  97. const { slash, mint } = await serviceBench()
  98. const a = readySource('/', 'alpha', [{ name: 'one' }])
  99. const b = deferredSource('/', 'beta')
  100. slash.registerSource(a.source)
  101. const disposeB = slash.registerSource(b.source)
  102. const ca = slash.sessionOf(mint('a').actx)
  103. const cb = slash.sessionOf(mint('b').actx)
  104. ca.track('/o', 2, { tier: 'plain' }, 1)
  105. cb.track('/o', 2, { tier: 'plain' }, 1)
  106. await tick()
  107. expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
  108. expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
  109. disposeB()
  110. expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
  111. expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
  112. // The name is free again, and a stale double-dispose stays a no-op.
  113. disposeB()
  114. slash.registerSource(deferredSource('/', 'beta').source)
  115. })
  116. it('HMR shape: dispose of the registering fiber removes the source', async () => {
  117. const { root, slash, mint } = await serviceBench()
  118. const controller = slash.sessionOf(mint('a').actx)
  119. const fiber = root.plugin({
  120. apply(pluginCtx: Context) {
  121. pluginCtx.effect(
  122. () => slash.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source),
  123. 'test: slash source',
  124. )
  125. },
  126. })
  127. await fiber.await()
  128. controller.track('/g', 2, { tier: 'plain' }, 1)
  129. await tick()
  130. expect(controller.menu.getSnapshot().open).toBe(true)
  131. await fiber.dispose()
  132. // Group dropped with the fiber; a fresh track finds no sources → closed.
  133. expect(controller.menu.getSnapshot().open).toBe(false)
  134. controller.track('/g', 2, { tier: 'plain' }, 1)
  135. expect(controller.menu.getSnapshot().open).toBe(false)
  136. })
  137. })
  138. describe('sessionOf', () => {
  139. it('resolves lazily: same scope → same resident controller; another session → its own', async () => {
  140. const { slash, mint } = await serviceBench()
  141. const a = mint('a')
  142. const first = slash.sessionOf(a.actx)
  143. expect(slash.sessionOf(a.actx)).toBe(first)
  144. expect(slash.sessionOf(mint('b').actx)).not.toBe(first)
  145. })
  146. it('throws off an unscoped context', async () => {
  147. const { root, slash } = await serviceBench()
  148. expect(() => slash.sessionOf(root)).toThrow(/requires a session scope/)
  149. })
  150. it('warms the roster once at controller birth with the session projection', async () => {
  151. const { slash, mint } = await serviceBench()
  152. const cmd = deferredSource('/', 'command')
  153. const sub = deferredSource('@', 'subagent')
  154. slash.registerSource(cmd.source)
  155. slash.registerSource(sub.source)
  156. const a = mint('a')
  157. slash.sessionOf(a.actx)
  158. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  159. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  160. // Re-resolution of the resident controller never re-warms.
  161. slash.sessionOf(a.actx)
  162. expect(cmd.warm).toHaveBeenCalledTimes(1)
  163. })
  164. it('the scope disposer removes and disposes the controller; a re-mint resolves fresh', async () => {
  165. const { slash, mint } = await serviceBench()
  166. slash.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source)
  167. const a = mint('a')
  168. const controller = slash.sessionOf(a.actx)
  169. controller.track('/g', 2, { tier: 'plain' }, 1)
  170. await tick()
  171. expect(controller.menu.getSnapshot().open).toBe(true)
  172. await a.fiber.dispose()
  173. expect(controller.menu.getSnapshot().open).toBe(false)
  174. controller.track('/g', 2, { tier: 'plain' }, 1)
  175. expect(controller.menu.getSnapshot().open).toBe(false)
  176. const again = mint('a')
  177. expect(slash.sessionOf(again.actx)).not.toBe(controller)
  178. })
  179. it('two sessions are isolated: one menu opening never touches the other', async () => {
  180. const { slash, mint } = await serviceBench()
  181. const src = deferredSource('/', 'command')
  182. slash.registerSource(src.source)
  183. const ca = slash.sessionOf(mint('a').actx)
  184. const cb = slash.sessionOf(mint('b').actx)
  185. ca.track('/g', 2, { tier: 'plain' }, 1)
  186. expect(ca.menu.getSnapshot().open).toBe(true)
  187. expect(cb.menu.getSnapshot().open).toBe(false)
  188. src.pending[0]!.resolve([{ name: 'goal' }])
  189. await tick()
  190. expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  191. expect(cb.menu.getSnapshot().open).toBe(false)
  192. })
  193. })
  194. describe('track', () => {
  195. it('drives seed → pending → ready through the store', async () => {
  196. const cmd = deferredSource('/', 'command')
  197. const skill = deferredSource('/', 'skill')
  198. const { controller } = controllerBench([cmd.source, skill.source])
  199. controller.track('/g', 2, { tier: 'plain' }, 1)
  200. let state = controller.menu.getSnapshot()
  201. expect(state.open).toBe(true)
  202. expect(state.groups).toEqual([
  203. { source: 'command', status: 'pending', items: [] },
  204. { source: 'skill', status: 'pending', items: [] },
  205. ])
  206. cmd.pending[0]!.resolve([{ name: 'goal' }])
  207. await tick()
  208. state = controller.menu.getSnapshot()
  209. expect(state.groups[0]).toEqual({ source: 'command', status: 'ready', items: [{ name: 'goal' }] })
  210. expect(state.groups[1]!.status).toBe('pending')
  211. expect(state.highlight).toEqual({ source: 'command', index: 0 })
  212. })
  213. it('stamps the caller draftRev into the hit span', () => {
  214. const cmd = deferredSource('/', 'command')
  215. const { controller } = controllerBench([cmd.source])
  216. controller.track('/g', 2, { tier: 'plain' }, 7)
  217. expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
  218. })
  219. it('candidates receive the session projection, identity only', () => {
  220. const cmd = deferredSource('/', 'command')
  221. const { controller } = controllerBench([cmd.source])
  222. controller.track('/g', 2, { tier: 'plain' }, 1)
  223. expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
  224. })
  225. it('query refinement supersedes the old generation and aborts its fetch', async () => {
  226. const cmd = deferredSource('/', 'command')
  227. const { controller } = controllerBench([cmd.source])
  228. controller.track('/g', 2, { tier: 'plain' }, 1)
  229. const gen1 = controller.menu.getSnapshot().generation
  230. controller.track('/go', 3, { tier: 'plain' }, 1)
  231. expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
  232. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  233. // A late settle of the aborted fetch is dropped even before the
  234. // generation gate: the group stays pending until the live fetch lands.
  235. cmd.pending[0]!.resolve([{ name: 'stale' }])
  236. await tick()
  237. expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
  238. cmd.pending[1]!.resolve([{ name: 'goal' }])
  239. await tick()
  240. expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  241. })
  242. it('same hit re-track refreshes the span stamp without refetching', () => {
  243. const cmd = deferredSource('/', 'command')
  244. const { controller } = controllerBench([cmd.source])
  245. controller.track('/g', 2, { tier: 'plain' }, 1)
  246. // Same token under the caret, later revision (an edit past the caret).
  247. controller.track('/g x', 2, { tier: 'plain' }, 2)
  248. expect(cmd.pending).toHaveLength(1)
  249. expect(controller.menu.getSnapshot().generation).toBe(1)
  250. })
  251. it('no live trigger closes the menu and aborts the fetch', () => {
  252. const cmd = deferredSource('/', 'command')
  253. const { controller } = controllerBench([cmd.source])
  254. controller.track('/g', 2, { tier: 'plain' }, 1)
  255. controller.track('hello', 5, { tier: 'plain' }, 1)
  256. expect(controller.menu.getSnapshot().open).toBe(false)
  257. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  258. })
  259. it('a trigger with no registered sources never opens', () => {
  260. const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
  261. controller.track('@w', 2, { tier: 'plain' }, 1)
  262. expect(controller.menu.getSnapshot().open).toBe(false)
  263. })
  264. it('trigger switch reseeds the roster', () => {
  265. const { controller } = controllerBench([
  266. deferredSource('/', 'command').source,
  267. deferredSource('@', 'subagent').source,
  268. ])
  269. controller.track('/g', 2, { tier: 'plain' }, 1)
  270. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
  271. controller.track('@w', 2, { tier: 'plain' }, 1)
  272. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
  273. })
  274. it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
  275. const cmd = deferredSource('/', 'command')
  276. const skill = deferredSource('/', 'skill')
  277. const { controller } = controllerBench([cmd.source, skill.source])
  278. controller.track('/zzz', 4, { tier: 'plain' }, 1)
  279. cmd.pending[0]!.resolve([])
  280. await tick()
  281. expect(controller.menu.getSnapshot().open).toBe(true)
  282. skill.pending[0]!.resolve([])
  283. await tick()
  284. expect(controller.menu.getSnapshot().open).toBe(false)
  285. })
  286. it('a rejecting source logs and silently drops its group', async () => {
  287. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
  288. try {
  289. const cmd = deferredSource('/', 'command')
  290. const skill = deferredSource('/', 'skill')
  291. const { controller } = controllerBench([cmd.source, skill.source])
  292. controller.track('/g', 2, { tier: 'plain' }, 1)
  293. skill.pending[0]!.reject(new Error('boom'))
  294. cmd.pending[0]!.resolve([{ name: 'goal' }])
  295. await tick()
  296. const state = controller.menu.getSnapshot()
  297. expect(state.groups.map(g => g.source)).toEqual(['command'])
  298. expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
  299. } finally {
  300. errorSpy.mockRestore()
  301. }
  302. })
  303. })
  304. describe('scope-birth warm', () => {
  305. it('construction warms every source once with the session projection', () => {
  306. const cmd = deferredSource('/', 'command')
  307. const sub = deferredSource('@', 'subagent')
  308. controllerBench([cmd.source, sub.source])
  309. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  310. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  311. })
  312. it('hook-less sources are skipped', () => {
  313. const bare: SlashSource = {
  314. trigger: '/',
  315. name: 'bare',
  316. candidates: () => Promise.resolve([]),
  317. onPick: () => undefined,
  318. }
  319. const cmd = deferredSource('/', 'command')
  320. // No throw on the hook-less source; the implementing one still warms.
  321. controllerBench([bare, cmd.source])
  322. expect(cmd.warm).toHaveBeenCalledTimes(1)
  323. })
  324. it('dispose inerts every verb', () => {
  325. const cmd = deferredSource('/', 'command')
  326. const { controller } = controllerBench([cmd.source])
  327. controller.track('/g', 2, { tier: 'plain' }, 1)
  328. controller.dispose()
  329. expect(controller.menu.getSnapshot().open).toBe(false)
  330. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  331. controller.track('/g', 2, { tier: 'plain' }, 1)
  332. expect(controller.menu.getSnapshot().open).toBe(false)
  333. expect(controller.arbitrate('down', false)).toBe('pass')
  334. expect(controller.onSpace()).toBe(false)
  335. controller.pick('command', 0)
  336. })
  337. })
  338. describe('pick / scoped input events', () => {
  339. function pickBench(outcomeOf: (pick: SlashPick) => PickOutcome) {
  340. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
  341. const bench = controllerBench([cmd.source])
  342. const begins: BeginCommandRequest[] = []
  343. const inserts: InsertReferenceRequest[] = []
  344. bench.actx.on('slash/input-begin-command', (req) => {
  345. begins.push(req)
  346. return true
  347. })
  348. bench.actx.on('slash/input-insert-reference', (req) => {
  349. inserts.push(req)
  350. return true
  351. })
  352. bench.controller.track('/g', 2, { tier: 'plain' }, 3)
  353. return { ...bench, cmd, begins, inserts }
  354. }
  355. it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
  356. const claim = claimOf('/goal ')
  357. const { controller, cmd, begins } = pickBench(() => ({ claim }))
  358. await tick()
  359. controller.pick('command', 0)
  360. expect(cmd.picks).toHaveLength(1)
  361. expect(cmd.picks[0]).toMatchObject({
  362. candidate: { name: 'goal' },
  363. session: { sessionId: sid('a') },
  364. position: 'leading',
  365. via: 'menu',
  366. span: { start: 0, end: 2, draftRev: 3 },
  367. })
  368. expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
  369. expect(controller.menu.getSnapshot().open).toBe(false)
  370. })
  371. it('routes an insert outcome through the scoped insert-reference event', async () => {
  372. const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
  373. const { controller, inserts } = pickBench(() => ({ insert }))
  374. await tick()
  375. controller.pick('command', 1)
  376. expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
  377. })
  378. it('routes a text outcome through the scoped insert-text event (decision 21) and closes the menu', async () => {
  379. const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
  380. const texts: Array<{ text: string; span: unknown }> = []
  381. actx.on('slash/input-insert-text', (req) => {
  382. texts.push(req)
  383. return true
  384. })
  385. await tick()
  386. controller.pick('command', 0)
  387. expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
  388. expect(controller.menu.getSnapshot().open).toBe(false)
  389. })
  390. it('a text outcome the input declines answers false on the space path', async () => {
  391. const src: SlashSource = {
  392. trigger: '/',
  393. name: 'command',
  394. candidates: () => Promise.resolve([]),
  395. onPick: () => undefined,
  396. matchSpace: () => ({ text: '/goal ' }),
  397. }
  398. const { controller, actx } = controllerBench([src])
  399. actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
  400. controller.track('/goal', 5, { tier: 'plain' }, 1)
  401. expect(controller.onSpace()).toBe(false)
  402. })
  403. it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
  404. const claim = claimOf('/goal ')
  405. const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
  406. const { root, controller } = controllerBench([cmd.source])
  407. const foreign: BeginCommandRequest[] = []
  408. const rootSeen: BeginCommandRequest[] = []
  409. createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
  410. foreign.push(req)
  411. return true
  412. })
  413. // Untagged root listeners are admitted globally (the carrier contract).
  414. root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
  415. controller.track('/g', 2, { tier: 'plain' }, 3)
  416. await tick()
  417. controller.pick('command', 0)
  418. expect(foreign).toHaveLength(0)
  419. expect(rootSeen).toHaveLength(1)
  420. })
  421. it("'handled' and undefined outcomes only close the menu", async () => {
  422. const { controller, begins, inserts } = pickBench(() => 'handled')
  423. await tick()
  424. controller.pick('command', 0)
  425. expect(begins).toHaveLength(0)
  426. expect(inserts).toHaveLength(0)
  427. expect(controller.menu.getSnapshot().open).toBe(false)
  428. })
  429. it('closed menu / vanished candidate picks are no-ops', async () => {
  430. const { controller, cmd } = pickBench(() => undefined)
  431. await tick()
  432. controller.pick('command', 9)
  433. controller.pick('ghost', 0)
  434. expect(cmd.picks).toHaveLength(0)
  435. expect(controller.menu.getSnapshot().open).toBe(true)
  436. })
  437. })
  438. describe('lexicon', () => {
  439. function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): SlashSource {
  440. return {
  441. trigger,
  442. name,
  443. candidates: () => Promise.resolve([]),
  444. onPick: () => undefined,
  445. ...(hasHook ? { lexicon: () => roll } : {}),
  446. }
  447. }
  448. it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
  449. const seen: unknown[] = []
  450. const skill: SlashSource = {
  451. trigger: '/',
  452. name: 'skill',
  453. candidates: () => Promise.resolve([]),
  454. onPick: () => undefined,
  455. lexicon: (projection) => {
  456. seen.push(projection)
  457. return ['commit-helper', 'review']
  458. },
  459. }
  460. const { controller } = controllerBench([
  461. lexSource('/', 'command', undefined, false), // no hook: not polled
  462. skill,
  463. lexSource('@', 'subagent', ['worker-1']),
  464. ])
  465. const rolls = controller.lexicon()
  466. expect([...rolls.keys()]).toEqual(['/', '@'])
  467. expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
  468. expect(rolls.get('@')).toEqual(['worker-1'])
  469. expect(seen).toEqual([{ sessionId: sid('a') }])
  470. })
  471. it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
  472. const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
  473. expect(controller.lexicon().size).toBe(0)
  474. })
  475. it('two sources on one trigger concatenate in registration order', () => {
  476. const { controller } = controllerBench([
  477. lexSource('/', 'skill', ['b', 'a']),
  478. lexSource('/', 'prompt', ['c']),
  479. lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
  480. ])
  481. const rolls = controller.lexicon()
  482. expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
  483. expect(rolls.has('@')).toBe(false)
  484. })
  485. })
  486. describe('arbitrate', () => {
  487. async function menuBench() {
  488. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
  489. const { controller } = controllerBench([cmd.source])
  490. controller.track('/g', 2, { tier: 'plain' }, 1)
  491. await tick()
  492. return { controller, cmd }
  493. }
  494. it('up/down move the highlight and are consumed', async () => {
  495. const { controller } = await menuBench()
  496. expect(controller.arbitrate('down', false)).toBe('consumed')
  497. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  498. expect(controller.arbitrate('up', false)).toBe('consumed')
  499. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  500. })
  501. it('enter picks the highlight through the pipeline', async () => {
  502. const { controller, cmd } = await menuBench()
  503. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  504. expect(cmd.picks[0]!.candidate.name).toBe('goal')
  505. expect(controller.menu.getSnapshot().open).toBe(false)
  506. })
  507. it('escape closes and consumes', async () => {
  508. const { controller } = await menuBench()
  509. expect(controller.arbitrate('escape', false)).toBe('consumed')
  510. expect(controller.menu.getSnapshot().open).toBe(false)
  511. })
  512. it('IME composition passes every key untouched', async () => {
  513. const { controller } = await menuBench()
  514. for (const key of ['up', 'down', 'enter', 'escape'] as const) {
  515. expect(controller.arbitrate(key, true)).toBe('pass')
  516. }
  517. expect(controller.menu.getSnapshot().open).toBe(true)
  518. })
  519. it('closed menu passes; an open menu without a highlight passes enter', () => {
  520. const cmd = deferredSource('/', 'command')
  521. const { controller } = controllerBench([cmd.source])
  522. expect(controller.arbitrate('enter', false)).toBe('pass')
  523. // Open with the only group still pending: nothing to pick yet.
  524. controller.track('/g', 2, { tier: 'plain' }, 1)
  525. expect(controller.arbitrate('enter', false)).toBe('pass')
  526. })
  527. })
  528. describe('onSpace', () => {
  529. function spaceSource(name: string, answer: PickOutcome, calls: string[]): SlashSource {
  530. return {
  531. trigger: '/',
  532. name,
  533. candidates: () => Promise.resolve([]),
  534. onPick: () => undefined,
  535. matchSpace: (_session, token) => {
  536. calls.push(`${name}:${token}`)
  537. return answer
  538. },
  539. }
  540. }
  541. it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
  542. const calls: string[] = []
  543. const claim = claimOf('/goal ')
  544. const { controller, actx } = controllerBench([
  545. // Hook-less source: never polled, so it must not shadow the order below.
  546. { trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
  547. spaceSource('first', undefined, calls),
  548. spaceSource('second', { claim }, calls),
  549. spaceSource('third', { claim: claimOf('/x ') }, calls),
  550. ])
  551. const begins: BeginCommandRequest[] = []
  552. actx.on('slash/input-begin-command', (req) => {
  553. begins.push(req)
  554. return true
  555. })
  556. controller.track('/goal', 5, { tier: 'plain' }, 1)
  557. expect(controller.onSpace()).toBe(true)
  558. expect(calls).toEqual(['first:/goal', 'second:/goal'])
  559. expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
  560. })
  561. it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
  562. const calls: string[] = []
  563. const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  564. declined.actx.on('slash/input-begin-command', () => undefined)
  565. declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
  566. expect(declined.controller.onSpace()).toBe(false)
  567. const handled = controllerBench([spaceSource('command', 'handled', calls)])
  568. const begins: BeginCommandRequest[] = []
  569. handled.actx.on('slash/input-begin-command', (req) => {
  570. begins.push(req)
  571. return true
  572. })
  573. handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
  574. expect(handled.controller.onSpace()).toBe(true)
  575. expect(begins).toHaveLength(0)
  576. })
  577. it('answers false off a non-leading hit or with no tracked hit', () => {
  578. const calls: string[] = []
  579. const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  580. expect(controller.onSpace()).toBe(false)
  581. controller.track('say /goal', 9, { tier: 'plain' }, 1)
  582. expect(controller.onSpace()).toBe(false)
  583. expect(calls).toEqual([])
  584. })
  585. })
  586. describe('adjudicate', () => {
  587. const enterSource = (
  588. trigger: TriggerChar, name: string,
  589. matchEnter?: SlashSource['matchEnter'],
  590. ): SlashSource => ({
  591. trigger,
  592. name,
  593. candidates: () => Promise.resolve([]),
  594. onPick: () => undefined,
  595. ...(matchEnter !== undefined ? { matchEnter } : {}),
  596. })
  597. it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
  598. const calls: string[] = []
  599. const claim = claimOf('/goal ')
  600. const { controller } = controllerBench([
  601. enterSource('/', 'silent'),
  602. enterSource('/', 'first', (session, line) => {
  603. expect(session).toEqual({ sessionId: sid('a') })
  604. calls.push(`first:${line}`)
  605. return Promise.resolve(undefined)
  606. }),
  607. enterSource('/', 'second', (_session, line) => {
  608. calls.push(`second:${line}`)
  609. return Promise.resolve({ claim })
  610. }),
  611. enterSource('/', 'third', () => {
  612. calls.push('third')
  613. return Promise.resolve('handled')
  614. }),
  615. ])
  616. const result = await controller.adjudicate('/goal make it fast', new AbortController().signal)
  617. expect(result).toEqual({ claim })
  618. expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
  619. })
  620. it('skips sources of another trigger; all-undefined answers undefined', async () => {
  621. const atHook = vi.fn(() => Promise.resolve('handled' as const))
  622. const { controller } = controllerBench([
  623. enterSource('@', 'subagent', atHook),
  624. enterSource('/', 'command', () => Promise.resolve(undefined)),
  625. ])
  626. await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined()
  627. expect(atHook).not.toHaveBeenCalled()
  628. })
  629. it('a rejecting source rejects the whole adjudication', async () => {
  630. const { controller } = controllerBench([
  631. enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
  632. enterSource('/', 'late', () => Promise.resolve('handled')),
  633. ])
  634. await expect(controller.adjudicate('/goal x', new AbortController().signal))
  635. .rejects.toThrow('warmup failed')
  636. })
  637. it('an aborted attempt signal stops the poll', async () => {
  638. const hook = vi.fn(() => Promise.resolve(undefined))
  639. const { controller } = controllerBench([enterSource('/', 'command', hook)])
  640. const abort = new AbortController()
  641. abort.abort(new Error('attempt released'))
  642. await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released')
  643. expect(hook).not.toHaveBeenCalled()
  644. })
  645. })