service.client.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. /**
  2. * Slash pipeline spec over the split architecture. InputTriggerService 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 InputTriggerController behavior, tested on a real
  8. * session scope (createScope).
  9. */
  10. import { Context } from '@deepseek-ai/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 { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  15. import type {
  16. BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,
  17. ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SourceRoster, TriggerChar,
  18. } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  19. const sid = (k: string): SessionId => k as SessionId
  20. interface PendingFetch {
  21. resolve: (items: readonly InputTriggerCandidate[]) => 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<InputTriggerSource> = {}) {
  29. const pending: PendingFetch[] = []
  30. const warm = vi.fn()
  31. const source: InputTriggerSource = {
  32. trigger,
  33. name,
  34. candidates: (session, req) => new Promise<readonly InputTriggerCandidate[]>((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 InputTriggerCandidate[], onPick?: (pick: InputTriggerPick) => PickOutcome,
  46. ) {
  47. const picks: InputTriggerPick[] = []
  48. const source: InputTriggerSource = {
  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: InputTriggerSource[] = [], 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 InputTriggerController({ 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(InputTriggerService).await()
  81. const inputTriggers = root.get('inputTriggers') as InputTriggerService
  82. const mint = (key: string) => {
  83. const scope = createScope(root, sid(key))
  84. return { actx: scope.ctx, fiber: scope.fiber }
  85. }
  86. return { root, inputTriggers, mint }
  87. }
  88. describe('registerSource', () => {
  89. it('throws on a duplicate (trigger, name); same name across triggers is fine', async () => {
  90. const { inputTriggers } = await serviceBench()
  91. inputTriggers.registerSource(readySource('/', 'command', []).source)
  92. expect(() => inputTriggers.registerSource(readySource('/', 'command', []).source))
  93. .toThrow(/already registered/)
  94. inputTriggers.registerSource(readySource('@', 'command', []).source)
  95. })
  96. it('disposal frees the name and drops the live menu group in every session controller', async () => {
  97. const { inputTriggers, mint } = await serviceBench()
  98. const a = readySource('/', 'alpha', [{ name: 'one' }])
  99. const b = deferredSource('/', 'beta')
  100. inputTriggers.registerSource(a.source)
  101. const disposeB = inputTriggers.registerSource(b.source)
  102. const ca = inputTriggers.sessionOf(mint('a').actx)
  103. const cb = inputTriggers.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. inputTriggers.registerSource(deferredSource('/', 'beta').source)
  115. })
  116. it('a source registered after controller birth warms in every live controller', async () => {
  117. const { inputTriggers, mint } = await serviceBench()
  118. const ca = inputTriggers.sessionOf(mint('a').actx)
  119. const cb = inputTriggers.sessionOf(mint('b').actx)
  120. const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
  121. inputTriggers.registerSource(late.source)
  122. expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
  123. expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
  124. expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  125. expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  126. })
  127. it('HMR shape: dispose of the registering fiber removes the source', async () => {
  128. const { root, inputTriggers, mint } = await serviceBench()
  129. const controller = inputTriggers.sessionOf(mint('a').actx)
  130. const fiber = root.plugin({
  131. apply(pluginCtx: Context) {
  132. pluginCtx.effect(
  133. () => inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source),
  134. 'test: slash source',
  135. )
  136. },
  137. })
  138. await fiber.await()
  139. controller.track('/g', 2, { tier: 'plain' }, 1)
  140. await tick()
  141. expect(controller.menu.getSnapshot().open).toBe(true)
  142. await fiber.dispose()
  143. // Group dropped with the fiber; a fresh track finds no sources → closed.
  144. expect(controller.menu.getSnapshot().open).toBe(false)
  145. controller.track('/g', 2, { tier: 'plain' }, 1)
  146. expect(controller.menu.getSnapshot().open).toBe(false)
  147. })
  148. })
  149. describe('sessionOf', () => {
  150. it('resolves lazily: same scope → same resident controller; another session → its own', async () => {
  151. const { inputTriggers, mint } = await serviceBench()
  152. const a = mint('a')
  153. const first = inputTriggers.sessionOf(a.actx)
  154. expect(inputTriggers.sessionOf(a.actx)).toBe(first)
  155. expect(inputTriggers.sessionOf(mint('b').actx)).not.toBe(first)
  156. })
  157. it('throws off an unscoped context', async () => {
  158. const { root, inputTriggers } = await serviceBench()
  159. expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a session scope/)
  160. })
  161. it('warms the roster once at controller birth with the session projection', async () => {
  162. const { inputTriggers, mint } = await serviceBench()
  163. const cmd = deferredSource('/', 'command')
  164. const sub = deferredSource('@', 'subagent')
  165. inputTriggers.registerSource(cmd.source)
  166. inputTriggers.registerSource(sub.source)
  167. const a = mint('a')
  168. inputTriggers.sessionOf(a.actx)
  169. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  170. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  171. // Re-resolution of the resident controller never re-warms.
  172. inputTriggers.sessionOf(a.actx)
  173. expect(cmd.warm).toHaveBeenCalledTimes(1)
  174. })
  175. it('the scope disposer removes and disposes the controller; a re-mint resolves fresh', async () => {
  176. const { inputTriggers, mint } = await serviceBench()
  177. inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source)
  178. const a = mint('a')
  179. const controller = inputTriggers.sessionOf(a.actx)
  180. controller.track('/g', 2, { tier: 'plain' }, 1)
  181. await tick()
  182. expect(controller.menu.getSnapshot().open).toBe(true)
  183. await a.fiber.dispose()
  184. expect(controller.menu.getSnapshot().open).toBe(false)
  185. controller.track('/g', 2, { tier: 'plain' }, 1)
  186. expect(controller.menu.getSnapshot().open).toBe(false)
  187. const again = mint('a')
  188. expect(inputTriggers.sessionOf(again.actx)).not.toBe(controller)
  189. })
  190. it('two sessions are isolated: one menu opening never touches the other', async () => {
  191. const { inputTriggers, mint } = await serviceBench()
  192. const src = deferredSource('/', 'command')
  193. inputTriggers.registerSource(src.source)
  194. const ca = inputTriggers.sessionOf(mint('a').actx)
  195. const cb = inputTriggers.sessionOf(mint('b').actx)
  196. ca.track('/g', 2, { tier: 'plain' }, 1)
  197. expect(ca.menu.getSnapshot().open).toBe(true)
  198. expect(cb.menu.getSnapshot().open).toBe(false)
  199. src.pending[0]!.resolve([{ name: 'goal' }])
  200. await tick()
  201. expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  202. expect(cb.menu.getSnapshot().open).toBe(false)
  203. })
  204. })
  205. describe('track', () => {
  206. it('drives seed → pending → ready through the store', async () => {
  207. const cmd = deferredSource('/', 'command')
  208. const skill = deferredSource('/', 'skill')
  209. const { controller } = controllerBench([cmd.source, skill.source])
  210. controller.track('/g', 2, { tier: 'plain' }, 1)
  211. let state = controller.menu.getSnapshot()
  212. expect(state.open).toBe(true)
  213. expect(state.groups).toEqual([
  214. { source: 'command', status: 'pending', items: [] },
  215. { source: 'skill', status: 'pending', items: [] },
  216. ])
  217. cmd.pending[0]!.resolve([{ name: 'goal' }])
  218. await tick()
  219. state = controller.menu.getSnapshot()
  220. expect(state.groups[0]).toEqual({ source: 'command', status: 'ready', items: [{ name: 'goal' }] })
  221. expect(state.groups[1]!.status).toBe('pending')
  222. expect(state.highlight).toEqual({ source: 'command', index: 0 })
  223. })
  224. it('stamps the caller draftRev into the hit span', () => {
  225. const cmd = deferredSource('/', 'command')
  226. const { controller } = controllerBench([cmd.source])
  227. controller.track('/g', 2, { tier: 'plain' }, 7)
  228. expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
  229. })
  230. it('candidates receive the session projection, identity only', () => {
  231. const cmd = deferredSource('/', 'command')
  232. const { controller } = controllerBench([cmd.source])
  233. controller.track('/g', 2, { tier: 'plain' }, 1)
  234. expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
  235. })
  236. it('query refinement supersedes the old generation and aborts its fetch', async () => {
  237. const cmd = deferredSource('/', 'command')
  238. const { controller } = controllerBench([cmd.source])
  239. controller.track('/g', 2, { tier: 'plain' }, 1)
  240. const gen1 = controller.menu.getSnapshot().generation
  241. controller.track('/go', 3, { tier: 'plain' }, 1)
  242. expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
  243. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  244. // A late settle of the aborted fetch is dropped even before the
  245. // generation gate: the group stays pending until the live fetch lands.
  246. cmd.pending[0]!.resolve([{ name: 'stale' }])
  247. await tick()
  248. expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
  249. cmd.pending[1]!.resolve([{ name: 'goal' }])
  250. await tick()
  251. expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  252. })
  253. it('same hit re-track refreshes the span stamp without refetching', () => {
  254. const cmd = deferredSource('/', 'command')
  255. const { controller } = controllerBench([cmd.source])
  256. controller.track('/g', 2, { tier: 'plain' }, 1)
  257. // Same token under the caret, later revision (an edit past the caret).
  258. controller.track('/g x', 2, { tier: 'plain' }, 2)
  259. expect(cmd.pending).toHaveLength(1)
  260. expect(controller.menu.getSnapshot().generation).toBe(1)
  261. })
  262. it('no live trigger closes the menu and aborts the fetch', () => {
  263. const cmd = deferredSource('/', 'command')
  264. const { controller } = controllerBench([cmd.source])
  265. controller.track('/g', 2, { tier: 'plain' }, 1)
  266. controller.track('hello', 5, { tier: 'plain' }, 1)
  267. expect(controller.menu.getSnapshot().open).toBe(false)
  268. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  269. })
  270. it('a trigger with no registered sources never opens', () => {
  271. const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
  272. controller.track('@w', 2, { tier: 'plain' }, 1)
  273. expect(controller.menu.getSnapshot().open).toBe(false)
  274. })
  275. it('trigger switch reseeds the roster', () => {
  276. const { controller } = controllerBench([
  277. deferredSource('/', 'command').source,
  278. deferredSource('@', 'subagent').source,
  279. ])
  280. controller.track('/g', 2, { tier: 'plain' }, 1)
  281. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
  282. controller.track('@w', 2, { tier: 'plain' }, 1)
  283. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
  284. })
  285. it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
  286. const cmd = deferredSource('/', 'command')
  287. const skill = deferredSource('/', 'skill')
  288. const { controller } = controllerBench([cmd.source, skill.source])
  289. controller.track('/zzz', 4, { tier: 'plain' }, 1)
  290. cmd.pending[0]!.resolve([])
  291. await tick()
  292. expect(controller.menu.getSnapshot().open).toBe(true)
  293. skill.pending[0]!.resolve([])
  294. await tick()
  295. expect(controller.menu.getSnapshot().open).toBe(false)
  296. })
  297. it('a rejecting source logs and silently drops its group', async () => {
  298. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
  299. try {
  300. const cmd = deferredSource('/', 'command')
  301. const skill = deferredSource('/', 'skill')
  302. const { controller } = controllerBench([cmd.source, skill.source])
  303. controller.track('/g', 2, { tier: 'plain' }, 1)
  304. skill.pending[0]!.reject(new Error('boom'))
  305. cmd.pending[0]!.resolve([{ name: 'goal' }])
  306. await tick()
  307. const state = controller.menu.getSnapshot()
  308. expect(state.groups.map(g => g.source)).toEqual(['command'])
  309. expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
  310. } finally {
  311. errorSpy.mockRestore()
  312. }
  313. })
  314. })
  315. describe('programmatic source launcher', () => {
  316. it('opens only the requested source and reuses its ordinary pick span', async () => {
  317. const command = readySource('/', 'command', [{ name: 'goal' }])
  318. const skill = readySource('/', 'skill', [{ name: 'review' }])
  319. const { controller } = controllerBench([command.source, skill.source])
  320. const hit = {
  321. trigger: '/' as const,
  322. query: '',
  323. position: 'leading' as const,
  324. span: { start: 2, end: 5, draftRev: 7 },
  325. }
  326. controller.toggleSource('command', hit)
  327. await tick()
  328. expect(controller.launcher.getSnapshot()).toBe('command')
  329. expect(controller.menu.getSnapshot()).toMatchObject({
  330. open: true,
  331. hit,
  332. groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
  333. })
  334. controller.pick('command', 0)
  335. expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
  336. expect(skill.picks).toHaveLength(0)
  337. expect(controller.launcher.getSnapshot()).toBeNull()
  338. })
  339. it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
  340. const command = readySource('/', 'command', [{ name: 'goal' }])
  341. const skill = readySource('/', 'skill', [{ name: 'review' }])
  342. const { controller } = controllerBench([command.source, skill.source])
  343. const hit = {
  344. trigger: '/' as const,
  345. query: '',
  346. position: 'leading' as const,
  347. span: { start: 0, end: 0, draftRev: 1 },
  348. }
  349. controller.toggleSource('command', hit)
  350. controller.toggleSource('command', hit)
  351. expect(controller.menu.getSnapshot().open).toBe(false)
  352. expect(controller.launcher.getSnapshot()).toBeNull()
  353. controller.toggleSource('command', hit)
  354. controller.track('/g', 2, { tier: 'plain' }, 2)
  355. await tick()
  356. expect(controller.launcher.getSnapshot()).toBeNull()
  357. expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
  358. })
  359. })
  360. describe('scope-birth warm', () => {
  361. it('construction warms every source once with the session projection', () => {
  362. const cmd = deferredSource('/', 'command')
  363. const sub = deferredSource('@', 'subagent')
  364. controllerBench([cmd.source, sub.source])
  365. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  366. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  367. })
  368. it('hook-less sources are skipped', () => {
  369. const bare: InputTriggerSource = {
  370. trigger: '/',
  371. name: 'bare',
  372. candidates: () => Promise.resolve([]),
  373. onPick: () => undefined,
  374. }
  375. const cmd = deferredSource('/', 'command')
  376. // No throw on the hook-less source; the implementing one still warms.
  377. controllerBench([bare, cmd.source])
  378. expect(cmd.warm).toHaveBeenCalledTimes(1)
  379. })
  380. it('dispose inerts every verb', () => {
  381. const cmd = deferredSource('/', 'command')
  382. const { controller } = controllerBench([cmd.source])
  383. controller.track('/g', 2, { tier: 'plain' }, 1)
  384. controller.dispose()
  385. expect(controller.menu.getSnapshot().open).toBe(false)
  386. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  387. controller.track('/g', 2, { tier: 'plain' }, 1)
  388. expect(controller.menu.getSnapshot().open).toBe(false)
  389. expect(controller.arbitrate('down', false)).toBe('pass')
  390. expect(controller.onSpace()).toBe(false)
  391. controller.pick('command', 0)
  392. })
  393. })
  394. describe('pick / scoped input events', () => {
  395. function pickBench(outcomeOf: (pick: InputTriggerPick) => PickOutcome) {
  396. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
  397. const bench = controllerBench([cmd.source])
  398. const begins: BeginCommandRequest[] = []
  399. const inserts: InsertReferenceRequest[] = []
  400. bench.actx.on('slash/input-begin-command', (req) => {
  401. begins.push(req)
  402. return true
  403. })
  404. bench.actx.on('slash/input-insert-reference', (req) => {
  405. inserts.push(req)
  406. return true
  407. })
  408. bench.controller.track('/g', 2, { tier: 'plain' }, 3)
  409. return { ...bench, cmd, begins, inserts }
  410. }
  411. it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
  412. const claim = claimOf('/goal ')
  413. const { controller, cmd, begins } = pickBench(() => ({ claim }))
  414. await tick()
  415. controller.pick('command', 0)
  416. expect(cmd.picks).toHaveLength(1)
  417. expect(cmd.picks[0]).toMatchObject({
  418. candidate: { name: 'goal' },
  419. session: { sessionId: sid('a') },
  420. position: 'leading',
  421. via: 'menu',
  422. span: { start: 0, end: 2, draftRev: 3 },
  423. })
  424. expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
  425. expect(controller.menu.getSnapshot().open).toBe(false)
  426. })
  427. it('routes an insert outcome through the scoped insert-reference event', async () => {
  428. const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
  429. const { controller, inserts } = pickBench(() => ({ insert }))
  430. await tick()
  431. controller.pick('command', 1)
  432. expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
  433. })
  434. it('routes a text outcome through the scoped insert-text event and closes the menu', async () => {
  435. const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
  436. const texts: Array<{ text: string; span: unknown }> = []
  437. actx.on('slash/input-insert-text', (req) => {
  438. texts.push(req)
  439. return true
  440. })
  441. await tick()
  442. controller.pick('command', 0)
  443. expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
  444. expect(controller.menu.getSnapshot().open).toBe(false)
  445. })
  446. it('a text outcome the input declines answers false on the space path', async () => {
  447. const src: InputTriggerSource = {
  448. trigger: '/',
  449. name: 'command',
  450. candidates: () => Promise.resolve([]),
  451. onPick: () => undefined,
  452. matchSpace: () => ({ text: '/goal ' }),
  453. }
  454. const { controller, actx } = controllerBench([src])
  455. actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
  456. controller.track('/goal', 5, { tier: 'plain' }, 1)
  457. expect(controller.onSpace()).toBe(false)
  458. })
  459. it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
  460. const claim = claimOf('/goal ')
  461. const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
  462. const { root, controller } = controllerBench([cmd.source])
  463. const foreign: BeginCommandRequest[] = []
  464. const rootSeen: BeginCommandRequest[] = []
  465. createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
  466. foreign.push(req)
  467. return true
  468. })
  469. // Untagged root listeners are admitted globally (the carrier contract).
  470. root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
  471. controller.track('/g', 2, { tier: 'plain' }, 3)
  472. await tick()
  473. controller.pick('command', 0)
  474. expect(foreign).toHaveLength(0)
  475. expect(rootSeen).toHaveLength(1)
  476. })
  477. it("'handled' and undefined outcomes only close the menu", async () => {
  478. const { controller, begins, inserts } = pickBench(() => 'handled')
  479. await tick()
  480. controller.pick('command', 0)
  481. expect(begins).toHaveLength(0)
  482. expect(inserts).toHaveLength(0)
  483. expect(controller.menu.getSnapshot().open).toBe(false)
  484. })
  485. it('closed menu / vanished candidate picks are no-ops', async () => {
  486. const { controller, cmd } = pickBench(() => undefined)
  487. await tick()
  488. controller.pick('command', 9)
  489. controller.pick('ghost', 0)
  490. expect(cmd.picks).toHaveLength(0)
  491. expect(controller.menu.getSnapshot().open).toBe(true)
  492. })
  493. })
  494. describe('lexicon', () => {
  495. function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
  496. return {
  497. trigger,
  498. name,
  499. candidates: () => Promise.resolve([]),
  500. onPick: () => undefined,
  501. ...(hasHook ? { lexicon: () => roll } : {}),
  502. }
  503. }
  504. it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
  505. const seen: unknown[] = []
  506. const skill: InputTriggerSource = {
  507. trigger: '/',
  508. name: 'skill',
  509. candidates: () => Promise.resolve([]),
  510. onPick: () => undefined,
  511. lexicon: (projection) => {
  512. seen.push(projection)
  513. return ['commit-helper', 'review']
  514. },
  515. }
  516. const { controller } = controllerBench([
  517. lexSource('/', 'command', undefined, false), // no hook: not polled
  518. skill,
  519. lexSource('@', 'subagent', ['worker-1']),
  520. ])
  521. const rolls = controller.lexicon.getSnapshot()
  522. expect([...rolls.keys()]).toEqual(['/', '@'])
  523. expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
  524. expect(rolls.get('@')).toEqual(['worker-1'])
  525. expect(seen).toEqual([{ sessionId: sid('a') }])
  526. })
  527. it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
  528. const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
  529. expect(controller.lexicon.getSnapshot().size).toBe(0)
  530. })
  531. it('two sources on one trigger concatenate in registration order', () => {
  532. const { controller } = controllerBench([
  533. lexSource('/', 'skill', ['b', 'a']),
  534. lexSource('/', 'prompt', ['c']),
  535. lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
  536. ])
  537. const rolls = controller.lexicon.getSnapshot()
  538. expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
  539. expect(rolls.has('@')).toBe(false)
  540. })
  541. it('a source lexicon notification republishes the aggregated store', () => {
  542. let roll: readonly string[] | undefined = undefined
  543. let notify: (() => void) | undefined
  544. const source: InputTriggerSource = {
  545. trigger: '/',
  546. name: 'skill',
  547. candidates: () => Promise.resolve([]),
  548. onPick: () => undefined,
  549. lexicon: () => roll,
  550. subscribeLexicon: (_session, listener) => {
  551. notify = listener
  552. return () => { notify = undefined }
  553. },
  554. }
  555. const { controller } = controllerBench([source])
  556. expect(controller.lexicon.getSnapshot().size).toBe(0)
  557. const seen: number[] = []
  558. controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
  559. roll = ['commit-helper']
  560. notify?.()
  561. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
  562. expect(seen).toEqual([1])
  563. controller.dispose()
  564. expect(notify).toBeUndefined()
  565. })
  566. it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
  567. const { controller, sources } = controllerBench([])
  568. expect(controller.lexicon.getSnapshot().size).toBe(0)
  569. const warm = vi.fn()
  570. const late: InputTriggerSource = {
  571. trigger: '/',
  572. name: 'late',
  573. candidates: () => Promise.resolve([]),
  574. onPick: () => undefined,
  575. warm,
  576. lexicon: () => ['fresh'],
  577. }
  578. sources.push(late)
  579. controller.sourceAdded(late)
  580. expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
  581. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  582. })
  583. it('a removed source leaves the aggregated lexicon', () => {
  584. const src = lexSource('/', 'skill', ['gone'])
  585. const { controller, sources } = controllerBench([src])
  586. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
  587. sources.splice(sources.indexOf(src), 1)
  588. controller.sourceRemoved(src)
  589. expect(controller.lexicon.getSnapshot().size).toBe(0)
  590. })
  591. })
  592. describe('arbitrate', () => {
  593. async function menuBench() {
  594. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
  595. const { controller } = controllerBench([cmd.source])
  596. controller.track('/g', 2, { tier: 'plain' }, 1)
  597. await tick()
  598. return { controller, cmd }
  599. }
  600. it('up/down move the highlight and are consumed', async () => {
  601. const { controller } = await menuBench()
  602. expect(controller.arbitrate('down', false)).toBe('consumed')
  603. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  604. expect(controller.arbitrate('up', false)).toBe('consumed')
  605. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  606. })
  607. it('enter picks the highlight through the pipeline', async () => {
  608. const { controller, cmd } = await menuBench()
  609. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  610. expect(cmd.picks[0]!.candidate.name).toBe('goal')
  611. expect(controller.menu.getSnapshot().open).toBe(false)
  612. })
  613. it('escape closes and consumes', async () => {
  614. const { controller } = await menuBench()
  615. expect(controller.arbitrate('escape', false)).toBe('consumed')
  616. expect(controller.menu.getSnapshot().open).toBe(false)
  617. })
  618. it('IME composition passes every key untouched', async () => {
  619. const { controller } = await menuBench()
  620. for (const key of ['up', 'down', 'enter', 'escape'] as const) {
  621. expect(controller.arbitrate(key, true)).toBe('pass')
  622. }
  623. expect(controller.menu.getSnapshot().open).toBe(true)
  624. })
  625. it('closed menu passes; an open menu without a highlight passes enter', () => {
  626. const cmd = deferredSource('/', 'command')
  627. const { controller } = controllerBench([cmd.source])
  628. expect(controller.arbitrate('enter', false)).toBe('pass')
  629. // Open with the only group still pending: nothing to pick yet.
  630. controller.track('/g', 2, { tier: 'plain' }, 1)
  631. expect(controller.arbitrate('enter', false)).toBe('pass')
  632. })
  633. })
  634. describe('onSpace', () => {
  635. function spaceSource(name: string, answer: PickOutcome, calls: string[]): InputTriggerSource {
  636. return {
  637. trigger: '/',
  638. name,
  639. candidates: () => Promise.resolve([]),
  640. onPick: () => undefined,
  641. matchSpace: (_session, token) => {
  642. calls.push(`${name}:${token}`)
  643. return answer
  644. },
  645. }
  646. }
  647. it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
  648. const calls: string[] = []
  649. const claim = claimOf('/goal ')
  650. const { controller, actx } = controllerBench([
  651. // Hook-less source: never polled, so it must not shadow the order below.
  652. { trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
  653. spaceSource('first', undefined, calls),
  654. spaceSource('second', { claim }, calls),
  655. spaceSource('third', { claim: claimOf('/x ') }, calls),
  656. ])
  657. const begins: BeginCommandRequest[] = []
  658. actx.on('slash/input-begin-command', (req) => {
  659. begins.push(req)
  660. return true
  661. })
  662. controller.track('/goal', 5, { tier: 'plain' }, 1)
  663. expect(controller.onSpace()).toBe(true)
  664. expect(calls).toEqual(['first:/goal', 'second:/goal'])
  665. expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
  666. })
  667. it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
  668. const calls: string[] = []
  669. const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  670. declined.actx.on('slash/input-begin-command', () => undefined)
  671. declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
  672. expect(declined.controller.onSpace()).toBe(false)
  673. const handled = controllerBench([spaceSource('command', 'handled', calls)])
  674. const begins: BeginCommandRequest[] = []
  675. handled.actx.on('slash/input-begin-command', (req) => {
  676. begins.push(req)
  677. return true
  678. })
  679. handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
  680. expect(handled.controller.onSpace()).toBe(true)
  681. expect(begins).toHaveLength(0)
  682. })
  683. it('answers false off a non-leading hit or with no tracked hit', () => {
  684. const calls: string[] = []
  685. const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  686. expect(controller.onSpace()).toBe(false)
  687. controller.track('say /goal', 9, { tier: 'plain' }, 1)
  688. expect(controller.onSpace()).toBe(false)
  689. expect(calls).toEqual([])
  690. })
  691. })
  692. describe('adjudicate', () => {
  693. const enterSource = (
  694. trigger: TriggerChar, name: string,
  695. matchEnter?: InputTriggerSource['matchEnter'],
  696. ): InputTriggerSource => ({
  697. trigger,
  698. name,
  699. candidates: () => Promise.resolve([]),
  700. onPick: () => undefined,
  701. ...(matchEnter !== undefined ? { matchEnter } : {}),
  702. })
  703. it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
  704. const calls: string[] = []
  705. const claim = claimOf('/goal ')
  706. const { controller } = controllerBench([
  707. enterSource('/', 'silent'),
  708. enterSource('/', 'first', (session, line) => {
  709. expect(session).toEqual({ sessionId: sid('a') })
  710. calls.push(`first:${line}`)
  711. return Promise.resolve(undefined)
  712. }),
  713. enterSource('/', 'second', (_session, line) => {
  714. calls.push(`second:${line}`)
  715. return Promise.resolve({ claim })
  716. }),
  717. enterSource('/', 'third', () => {
  718. calls.push('third')
  719. return Promise.resolve('handled')
  720. }),
  721. ])
  722. const result = await controller.adjudicate('/goal make it fast', new AbortController().signal)
  723. expect(result).toEqual({ claim })
  724. expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
  725. })
  726. it('skips sources of another trigger; all-undefined answers undefined', async () => {
  727. const atHook = vi.fn(() => Promise.resolve('handled' as const))
  728. const { controller } = controllerBench([
  729. enterSource('@', 'subagent', atHook),
  730. enterSource('/', 'command', () => Promise.resolve(undefined)),
  731. ])
  732. await expect(controller.adjudicate('/xyz', new AbortController().signal)).resolves.toBeUndefined()
  733. expect(atHook).not.toHaveBeenCalled()
  734. })
  735. it('a rejecting source rejects the whole adjudication', async () => {
  736. const { controller } = controllerBench([
  737. enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
  738. enterSource('/', 'late', () => Promise.resolve('handled')),
  739. ])
  740. await expect(controller.adjudicate('/goal x', new AbortController().signal))
  741. .rejects.toThrow('warmup failed')
  742. })
  743. it('an aborted attempt signal stops the poll', async () => {
  744. const hook = vi.fn(() => Promise.resolve(undefined))
  745. const { controller } = controllerBench([enterSource('/', 'command', hook)])
  746. const abort = new AbortController()
  747. abort.abort(new Error('attempt released'))
  748. await expect(controller.adjudicate('/goal', abort.signal)).rejects.toThrow('attempt released')
  749. expect(hook).not.toHaveBeenCalled()
  750. })
  751. })