service.client.spec.ts 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  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-api-session-controller/client'
  13. import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
  14. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  15. import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  16. import type {
  17. BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome,
  18. ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SourceRoster, TriggerChar,
  19. } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  20. const sid = (k: string): SessionId => k as SessionId
  21. interface PendingFetch {
  22. resolve: (items: readonly InputTriggerCandidate[]) => void
  23. reject: (err: unknown) => void
  24. query: string
  25. signal: AbortSignal
  26. session: ClientSessionContext
  27. }
  28. /** Deferred-candidates source: settle each fetch by hand; warm is a spy. */
  29. function deferredSource(trigger: TriggerChar, name: string, over: Partial<InputTriggerSource> = {}) {
  30. const pending: PendingFetch[] = []
  31. const warm = vi.fn()
  32. const source: InputTriggerSource = {
  33. trigger,
  34. name,
  35. candidates: (session, req) => new Promise<readonly InputTriggerCandidate[]>((resolve, reject) => {
  36. pending.push({ resolve, reject, query: req.query, signal: req.signal, session })
  37. }),
  38. onPick: () => undefined,
  39. warm,
  40. ...over,
  41. }
  42. return { source, pending, warm }
  43. }
  44. /** Source whose candidates resolve immediately; picks are recorded. */
  45. function readySource(
  46. trigger: TriggerChar, name: string, items: readonly InputTriggerCandidate[], onPick?: (pick: InputTriggerPick) => PickOutcome,
  47. ) {
  48. const picks: InputTriggerPick[] = []
  49. const source: InputTriggerSource = {
  50. trigger,
  51. name,
  52. candidates: () => Promise.resolve(items),
  53. onPick: (pick) => {
  54. picks.push(pick)
  55. return onPick?.(pick)
  56. },
  57. }
  58. return { source, picks }
  59. }
  60. const claimOf = (token: string): CommandClaim =>
  61. ({ token, submit: () => Promise.resolve({ kind: 'success' }) })
  62. /** One microtask hop: lets settled candidate promises flow into the store. */
  63. const tick = () => Promise.resolve()
  64. /** Direct controller bench: real scope tag + live roster array. */
  65. function controllerBench(sources: InputTriggerSource[] = [], key = 'a') {
  66. const root = new Context()
  67. const scope = createScope(root, sid(key))
  68. const roster: SourceRoster = {
  69. sources: trigger => sources.filter(s => s.trigger === trigger),
  70. all: () => sources,
  71. }
  72. const controller = new InputTriggerController({ actx: scope.ctx, sessionId: sid(key), roster })
  73. return { root, actx: scope.ctx, controller, sources }
  74. }
  75. /** Real-service bench: a sessions face resolving scope tags to session ids. */
  76. async function serviceBench() {
  77. const root = new Context()
  78. root.provide('sessions', {
  79. scopeOf: (c: Context) => scopeOf(c),
  80. })
  81. await root.plugin(InputTriggerService).await()
  82. const inputTriggers = root.get('inputTriggers') as InputTriggerService
  83. const mint = (key: string) => {
  84. const scope = createScope(root, sid(key))
  85. return { actx: scope.ctx, fiber: scope.fiber }
  86. }
  87. return { root, inputTriggers, mint }
  88. }
  89. describe('registerSource', () => {
  90. it('throws on a duplicate (trigger, name); same name across triggers is fine', async () => {
  91. const { inputTriggers } = await serviceBench()
  92. inputTriggers.registerSource(readySource('/', 'command', []).source)
  93. expect(() => inputTriggers.registerSource(readySource('/', 'command', []).source))
  94. .toThrow(/already registered/)
  95. inputTriggers.registerSource(readySource('@', 'command', []).source)
  96. })
  97. it('disposal frees the name and drops the live menu group in every session controller', async () => {
  98. const { inputTriggers, mint } = await serviceBench()
  99. const a = readySource('/', 'alpha', [{ name: 'one' }])
  100. const b = deferredSource('/', 'beta')
  101. inputTriggers.registerSource(a.source)
  102. const disposeB = inputTriggers.registerSource(b.source)
  103. const ca = inputTriggers.sessionOf(mint('a').actx)
  104. const cb = inputTriggers.sessionOf(mint('b').actx)
  105. ca.track('/o', 2, { tier: 'plain' }, 1)
  106. cb.track('/o', 2, { tier: 'plain' }, 1)
  107. await tick()
  108. expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
  109. expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha', 'beta'])
  110. disposeB()
  111. expect(ca.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
  112. expect(cb.menu.getSnapshot().groups.map(g => g.source)).toEqual(['alpha'])
  113. // The name is free again, and a stale double-dispose stays a no-op.
  114. disposeB()
  115. inputTriggers.registerSource(deferredSource('/', 'beta').source)
  116. })
  117. it('a source registered after controller birth warms in every live controller', async () => {
  118. const { inputTriggers, mint } = await serviceBench()
  119. const ca = inputTriggers.sessionOf(mint('a').actx)
  120. const cb = inputTriggers.sessionOf(mint('b').actx)
  121. const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
  122. inputTriggers.registerSource(late.source)
  123. expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
  124. expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
  125. expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  126. expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  127. })
  128. it('HMR shape: dispose of the registering fiber removes the source', async () => {
  129. const { root, inputTriggers, mint } = await serviceBench()
  130. const controller = inputTriggers.sessionOf(mint('a').actx)
  131. const fiber = root.plugin({
  132. apply(pluginCtx: Context) {
  133. pluginCtx.effect(
  134. () => inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source),
  135. 'test: slash source',
  136. )
  137. },
  138. })
  139. await fiber.await()
  140. controller.track('/g', 2, { tier: 'plain' }, 1)
  141. await tick()
  142. expect(controller.menu.getSnapshot().open).toBe(true)
  143. await fiber.dispose()
  144. // Group dropped with the fiber; a fresh track finds no sources → closed.
  145. expect(controller.menu.getSnapshot().open).toBe(false)
  146. controller.track('/g', 2, { tier: 'plain' }, 1)
  147. expect(controller.menu.getSnapshot().open).toBe(false)
  148. })
  149. })
  150. describe('sessionOf', () => {
  151. it('resolves lazily: same scope → same resident controller; another session → its own', async () => {
  152. const { inputTriggers, mint } = await serviceBench()
  153. const a = mint('a')
  154. const first = inputTriggers.sessionOf(a.actx)
  155. expect(inputTriggers.sessionOf(a.actx)).toBe(first)
  156. expect(inputTriggers.sessionOf(mint('b').actx)).not.toBe(first)
  157. })
  158. it('throws off an unscoped context', async () => {
  159. const { root, inputTriggers } = await serviceBench()
  160. expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a session scope/)
  161. })
  162. it('warms the roster once at controller birth with the session projection', async () => {
  163. const { inputTriggers, mint } = await serviceBench()
  164. const cmd = deferredSource('/', 'command')
  165. const sub = deferredSource('@', 'subagent')
  166. inputTriggers.registerSource(cmd.source)
  167. inputTriggers.registerSource(sub.source)
  168. const a = mint('a')
  169. inputTriggers.sessionOf(a.actx)
  170. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  171. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  172. // Re-resolution of the resident controller never re-warms.
  173. inputTriggers.sessionOf(a.actx)
  174. expect(cmd.warm).toHaveBeenCalledTimes(1)
  175. })
  176. it('the scope disposer removes and disposes the controller; a re-mint resolves fresh', async () => {
  177. const { inputTriggers, mint } = await serviceBench()
  178. inputTriggers.registerSource(readySource('/', 'command', [{ name: 'goal' }]).source)
  179. const a = mint('a')
  180. const controller = inputTriggers.sessionOf(a.actx)
  181. controller.track('/g', 2, { tier: 'plain' }, 1)
  182. await tick()
  183. expect(controller.menu.getSnapshot().open).toBe(true)
  184. await a.fiber.dispose()
  185. expect(controller.menu.getSnapshot().open).toBe(false)
  186. controller.track('/g', 2, { tier: 'plain' }, 1)
  187. expect(controller.menu.getSnapshot().open).toBe(false)
  188. const again = mint('a')
  189. expect(inputTriggers.sessionOf(again.actx)).not.toBe(controller)
  190. })
  191. it('two sessions are isolated: one menu opening never touches the other', async () => {
  192. const { inputTriggers, mint } = await serviceBench()
  193. const src = deferredSource('/', 'command')
  194. inputTriggers.registerSource(src.source)
  195. const ca = inputTriggers.sessionOf(mint('a').actx)
  196. const cb = inputTriggers.sessionOf(mint('b').actx)
  197. ca.track('/g', 2, { tier: 'plain' }, 1)
  198. expect(ca.menu.getSnapshot().open).toBe(true)
  199. expect(cb.menu.getSnapshot().open).toBe(false)
  200. src.pending[0]!.resolve([{ name: 'goal' }])
  201. await tick()
  202. expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  203. expect(cb.menu.getSnapshot().open).toBe(false)
  204. })
  205. it('re-fetches every open menu when the active locale changes', async () => {
  206. const { root, inputTriggers, mint } = await serviceBench()
  207. let locale = 'en'
  208. const candidates = vi.fn(() => Promise.resolve([{ name: 'compact', description: locale }]))
  209. inputTriggers.registerSource({
  210. trigger: '/',
  211. name: 'command',
  212. candidates,
  213. onPick: () => undefined,
  214. })
  215. const first = inputTriggers.sessionOf(mint('a').actx)
  216. const second = inputTriggers.sessionOf(mint('b').actx)
  217. const closed = inputTriggers.sessionOf(mint('c').actx)
  218. first.track('/c', 2, { tier: 'plain' }, 1)
  219. second.track('/c', 2, { tier: 'plain' }, 1)
  220. await tick()
  221. expect(first.menu.getSnapshot()).toMatchObject({
  222. open: true,
  223. hit: { query: 'c' },
  224. groups: [{ source: 'command', status: 'ready', items: [{ name: 'compact', description: 'en' }] }],
  225. })
  226. locale = 'zh'
  227. root.emit('locale/change', { active: 'zh', locales: [], revision: 1 } as LocaleSnapshot)
  228. expect(first.menu.getSnapshot().open).toBe(true)
  229. expect(second.menu.getSnapshot().open).toBe(true)
  230. await tick()
  231. expect(candidates).toHaveBeenCalledTimes(4)
  232. expect(first.menu.getSnapshot()).toMatchObject({
  233. open: true,
  234. hit: { query: 'c' },
  235. groups: [{ source: 'command', status: 'ready', items: [{ name: 'compact', description: 'zh' }] }],
  236. })
  237. expect(second.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'compact', description: 'zh' }])
  238. expect(closed.menu.getSnapshot().open).toBe(false)
  239. })
  240. })
  241. describe('track', () => {
  242. it('drives seed → pending → ready through the store', async () => {
  243. const cmd = deferredSource('/', 'command')
  244. const skill = deferredSource('/', 'skill')
  245. const { controller } = controllerBench([cmd.source, skill.source])
  246. controller.track('/g', 2, { tier: 'plain' }, 1)
  247. let state = controller.menu.getSnapshot()
  248. expect(state.open).toBe(true)
  249. expect(state.groups).toEqual([
  250. { source: 'command', status: 'pending', items: [] },
  251. { source: 'skill', status: 'pending', items: [] },
  252. ])
  253. cmd.pending[0]!.resolve([{ name: 'goal' }])
  254. await tick()
  255. state = controller.menu.getSnapshot()
  256. expect(state.groups[0]).toEqual({ source: 'command', status: 'ready', items: [{ name: 'goal' }] })
  257. expect(state.groups[1]!.status).toBe('pending')
  258. expect(state.highlight).toEqual({ source: 'command', index: 0 })
  259. })
  260. it('carries source-title visibility from the roster through candidate settlement', async () => {
  261. const reference = deferredSource('@', 'reference', { showGroupTitle: false })
  262. const { controller } = controllerBench([reference.source])
  263. controller.track('@r', 2, { tier: 'plain' }, 1)
  264. expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'pending' })
  265. reference.pending[0]!.resolve([{ name: 'README.md', section: '文件与文件夹' }])
  266. await tick()
  267. expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'ready' })
  268. })
  269. it('stamps the caller draftRev into the hit span', () => {
  270. const cmd = deferredSource('/', 'command')
  271. const { controller } = controllerBench([cmd.source])
  272. controller.track('/g', 2, { tier: 'plain' }, 7)
  273. expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
  274. })
  275. it('candidates receive the session projection, identity only', () => {
  276. const cmd = deferredSource('/', 'command')
  277. const { controller } = controllerBench([cmd.source])
  278. controller.track('/g', 2, { tier: 'plain' }, 1)
  279. expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
  280. })
  281. it('query refinement supersedes the old generation and aborts its fetch', async () => {
  282. const cmd = deferredSource('/', 'command')
  283. const { controller } = controllerBench([cmd.source])
  284. controller.track('/g', 2, { tier: 'plain' }, 1)
  285. const gen1 = controller.menu.getSnapshot().generation
  286. controller.track('/go', 3, { tier: 'plain' }, 1)
  287. expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
  288. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  289. // A late settle of the aborted fetch is dropped even before the
  290. // generation gate: the group stays pending until the live fetch lands.
  291. cmd.pending[0]!.resolve([{ name: 'stale' }])
  292. await tick()
  293. expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
  294. cmd.pending[1]!.resolve([{ name: 'goal' }])
  295. await tick()
  296. expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  297. })
  298. it('refinement keeps the settled items on screen until the new fetch lands', async () => {
  299. const cmd = deferredSource('/', 'command')
  300. const { controller } = controllerBench([cmd.source])
  301. controller.track('/g', 2, { tier: 'plain' }, 1)
  302. cmd.pending[0]!.resolve([{ name: 'goal' }])
  303. await tick()
  304. // Stale-while-revalidate: the pending group still carries the items.
  305. controller.track('/go', 3, { tier: 'plain' }, 1)
  306. expect(controller.menu.getSnapshot().groups[0]).toEqual(
  307. { source: 'command', status: 'pending', items: [{ name: 'goal' }] },
  308. )
  309. cmd.pending[1]!.resolve([{ name: 'goat' }])
  310. await tick()
  311. expect(controller.menu.getSnapshot().groups[0]).toEqual(
  312. { source: 'command', status: 'ready', items: [{ name: 'goat' }] },
  313. )
  314. })
  315. it('same hit re-track refreshes the span stamp without refetching', () => {
  316. const cmd = deferredSource('/', 'command')
  317. const { controller } = controllerBench([cmd.source])
  318. controller.track('/g', 2, { tier: 'plain' }, 1)
  319. // Same token under the caret, later revision (an edit past the caret).
  320. controller.track('/g x', 2, { tier: 'plain' }, 2)
  321. expect(cmd.pending).toHaveLength(1)
  322. expect(controller.menu.getSnapshot().generation).toBe(1)
  323. })
  324. it('no live trigger closes the menu and aborts the fetch', () => {
  325. const cmd = deferredSource('/', 'command')
  326. const { controller } = controllerBench([cmd.source])
  327. controller.track('/g', 2, { tier: 'plain' }, 1)
  328. controller.track('hello', 5, { tier: 'plain' }, 1)
  329. expect(controller.menu.getSnapshot().open).toBe(false)
  330. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  331. })
  332. it('a trigger with no registered sources never opens', () => {
  333. const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
  334. controller.track('@w', 2, { tier: 'plain' }, 1)
  335. expect(controller.menu.getSnapshot().open).toBe(false)
  336. })
  337. it('trigger switch reseeds the roster', () => {
  338. const { controller } = controllerBench([
  339. deferredSource('/', 'command').source,
  340. deferredSource('@', 'subagent').source,
  341. ])
  342. controller.track('/g', 2, { tier: 'plain' }, 1)
  343. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
  344. controller.track('@w', 2, { tier: 'plain' }, 1)
  345. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
  346. })
  347. it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
  348. const cmd = deferredSource('/', 'command')
  349. const skill = deferredSource('/', 'skill')
  350. const { controller } = controllerBench([cmd.source, skill.source])
  351. controller.track('/zzz', 4, { tier: 'plain' }, 1)
  352. cmd.pending[0]!.resolve([])
  353. await tick()
  354. expect(controller.menu.getSnapshot().open).toBe(true)
  355. skill.pending[0]!.resolve([])
  356. await tick()
  357. expect(controller.menu.getSnapshot().open).toBe(false)
  358. })
  359. it('a rejecting source logs and silently drops its group', async () => {
  360. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
  361. try {
  362. const cmd = deferredSource('/', 'command')
  363. const skill = deferredSource('/', 'skill')
  364. const { controller } = controllerBench([cmd.source, skill.source])
  365. controller.track('/g', 2, { tier: 'plain' }, 1)
  366. skill.pending[0]!.reject(new Error('boom'))
  367. cmd.pending[0]!.resolve([{ name: 'goal' }])
  368. await tick()
  369. const state = controller.menu.getSnapshot()
  370. expect(state.groups.map(g => g.source)).toEqual(['command'])
  371. expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
  372. } finally {
  373. errorSpy.mockRestore()
  374. }
  375. })
  376. })
  377. describe('programmatic source launcher', () => {
  378. it('opens only the requested source and reuses its ordinary pick span', async () => {
  379. const command = readySource('/', 'command', [{ name: 'goal' }])
  380. const skill = readySource('/', 'skill', [{ name: 'review' }])
  381. const { controller } = controllerBench([command.source, skill.source])
  382. const hit = {
  383. trigger: '/' as const,
  384. query: '',
  385. quoted: false,
  386. position: 'leading' as const,
  387. span: { start: 2, end: 5, draftRev: 7 },
  388. }
  389. controller.toggleSource('command', hit)
  390. await tick()
  391. expect(controller.launcher.getSnapshot()).toBe('command')
  392. expect(controller.menu.getSnapshot()).toMatchObject({
  393. open: true,
  394. hit,
  395. groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
  396. })
  397. controller.pick('command', 0)
  398. expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
  399. expect(skill.picks).toHaveLength(0)
  400. expect(controller.launcher.getSnapshot()).toBeNull()
  401. })
  402. it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
  403. const command = readySource('/', 'command', [{ name: 'goal' }])
  404. const skill = readySource('/', 'skill', [{ name: 'review' }])
  405. const { controller } = controllerBench([command.source, skill.source])
  406. const hit = {
  407. trigger: '/' as const,
  408. query: '',
  409. quoted: false,
  410. position: 'leading' as const,
  411. span: { start: 0, end: 0, draftRev: 1 },
  412. }
  413. controller.toggleSource('command', hit)
  414. controller.toggleSource('command', hit)
  415. expect(controller.menu.getSnapshot().open).toBe(false)
  416. expect(controller.launcher.getSnapshot()).toBeNull()
  417. controller.toggleSource('command', hit)
  418. controller.track('/g', 2, { tier: 'plain' }, 2)
  419. await tick()
  420. expect(controller.launcher.getSnapshot()).toBeNull()
  421. expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
  422. })
  423. })
  424. describe('scope-birth warm', () => {
  425. it('construction warms every source once with the session projection', () => {
  426. const cmd = deferredSource('/', 'command')
  427. const sub = deferredSource('@', 'subagent')
  428. controllerBench([cmd.source, sub.source])
  429. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  430. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  431. })
  432. it('hook-less sources are skipped', () => {
  433. const bare: InputTriggerSource = {
  434. trigger: '/',
  435. name: 'bare',
  436. candidates: () => Promise.resolve([]),
  437. onPick: () => undefined,
  438. }
  439. const cmd = deferredSource('/', 'command')
  440. // No throw on the hook-less source; the implementing one still warms.
  441. controllerBench([bare, cmd.source])
  442. expect(cmd.warm).toHaveBeenCalledTimes(1)
  443. })
  444. it('dispose inerts every verb', () => {
  445. const cmd = deferredSource('/', 'command')
  446. const { controller } = controllerBench([cmd.source])
  447. controller.track('/g', 2, { tier: 'plain' }, 1)
  448. controller.dispose()
  449. expect(controller.menu.getSnapshot().open).toBe(false)
  450. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  451. controller.track('/g', 2, { tier: 'plain' }, 1)
  452. expect(controller.menu.getSnapshot().open).toBe(false)
  453. expect(controller.arbitrate('down', false)).toBe('pass')
  454. expect(controller.onSpace()).toBe(false)
  455. controller.pick('command', 0)
  456. })
  457. })
  458. describe('pick / scoped input events', () => {
  459. function pickBench(outcomeOf: (pick: InputTriggerPick) => PickOutcome) {
  460. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
  461. const bench = controllerBench([cmd.source])
  462. const begins: BeginCommandRequest[] = []
  463. const inserts: InsertReferenceRequest[] = []
  464. bench.actx.on('slash/input-begin-command', (req) => {
  465. begins.push(req)
  466. return true
  467. })
  468. bench.actx.on('slash/input-insert-reference', (req) => {
  469. inserts.push(req)
  470. return true
  471. })
  472. bench.controller.track('/g', 2, { tier: 'plain' }, 3)
  473. return { ...bench, cmd, begins, inserts }
  474. }
  475. it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
  476. const claim = claimOf('/goal ')
  477. const { controller, cmd, begins } = pickBench(() => ({ claim }))
  478. await tick()
  479. controller.pick('command', 0)
  480. expect(cmd.picks).toHaveLength(1)
  481. expect(cmd.picks[0]).toMatchObject({
  482. candidate: { name: 'goal' },
  483. session: { sessionId: sid('a') },
  484. position: 'leading',
  485. via: 'menu',
  486. span: { start: 0, end: 2, draftRev: 3 },
  487. })
  488. expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
  489. expect(controller.menu.getSnapshot().open).toBe(false)
  490. })
  491. it('routes an insert outcome through the scoped insert-reference event', async () => {
  492. const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
  493. const { controller, inserts } = pickBench(() => ({ insert }))
  494. await tick()
  495. controller.pick('command', 1)
  496. expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
  497. })
  498. it('routes a text outcome through the scoped insert-text event and closes the menu', async () => {
  499. const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
  500. const texts: Array<{ text: string; span: unknown }> = []
  501. actx.on('slash/input-insert-text', (req) => {
  502. texts.push(req)
  503. return true
  504. })
  505. await tick()
  506. controller.pick('command', 0)
  507. expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
  508. expect(controller.menu.getSnapshot().open).toBe(false)
  509. })
  510. it('forwards a continuing text outcome so a directory pick keeps completion open', async () => {
  511. const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true }))
  512. const texts: Array<{ text: string; continue?: boolean }> = []
  513. actx.on('slash/input-insert-text', (req) => {
  514. texts.push(req)
  515. return true
  516. })
  517. await tick()
  518. controller.pick('command', 0)
  519. expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }])
  520. })
  521. it('a text outcome the input declines answers false on the space path', async () => {
  522. const src: InputTriggerSource = {
  523. trigger: '/',
  524. name: 'command',
  525. candidates: () => Promise.resolve([]),
  526. onPick: () => undefined,
  527. matchSpace: () => ({ text: '/goal ' }),
  528. }
  529. const { controller, actx } = controllerBench([src])
  530. actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
  531. controller.track('/goal', 5, { tier: 'plain' }, 1)
  532. expect(controller.onSpace()).toBe(false)
  533. })
  534. it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
  535. const claim = claimOf('/goal ')
  536. const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
  537. const { root, controller } = controllerBench([cmd.source])
  538. const foreign: BeginCommandRequest[] = []
  539. const rootSeen: BeginCommandRequest[] = []
  540. createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
  541. foreign.push(req)
  542. return true
  543. })
  544. // Untagged root listeners are admitted globally (the carrier contract).
  545. root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
  546. controller.track('/g', 2, { tier: 'plain' }, 3)
  547. await tick()
  548. controller.pick('command', 0)
  549. expect(foreign).toHaveLength(0)
  550. expect(rootSeen).toHaveLength(1)
  551. })
  552. it("'handled' and undefined outcomes only close the menu", async () => {
  553. const { controller, begins, inserts } = pickBench(() => 'handled')
  554. await tick()
  555. controller.pick('command', 0)
  556. expect(begins).toHaveLength(0)
  557. expect(inserts).toHaveLength(0)
  558. expect(controller.menu.getSnapshot().open).toBe(false)
  559. })
  560. it('closed menu / vanished candidate picks are no-ops', async () => {
  561. const { controller, cmd } = pickBench(() => undefined)
  562. await tick()
  563. controller.pick('command', 9)
  564. controller.pick('ghost', 0)
  565. expect(cmd.picks).toHaveLength(0)
  566. expect(controller.menu.getSnapshot().open).toBe(true)
  567. })
  568. })
  569. describe('header / drilled descent', () => {
  570. /** A source that publishes one crumb per path segment of a drilled query. */
  571. function crumbSource() {
  572. const requests: Array<{ query: string; quoted?: boolean; drilled: boolean }> = []
  573. const fetches: boolean[] = []
  574. const picks: InputTriggerPick[] = []
  575. const source: InputTriggerSource = {
  576. trigger: '@',
  577. name: 'reference',
  578. candidates: (_session, req) => {
  579. fetches.push(req.drilled)
  580. return Promise.resolve([{ name: 'src', drill: true, value: 'src' }])
  581. },
  582. header: (_session, req) => {
  583. requests.push({ ...req })
  584. if (!req.drilled || !req.query.includes('/')) return undefined
  585. return req.query.split('/').filter(Boolean).map(label => ({ label, value: label }))
  586. },
  587. onPick: (pick) => {
  588. picks.push(pick)
  589. return pick.action === 'drill' ? { text: `@${String(pick.candidate.value)}/`, continue: true } : undefined
  590. },
  591. }
  592. return { source, requests, fetches, picks }
  593. }
  594. it('publishes no crumbs for a typed path and asks every source how the menu was reached', async () => {
  595. const { source, requests } = crumbSource()
  596. const { controller } = controllerBench([source])
  597. controller.track('@src/', 5, { tier: 'plain' }, 1)
  598. await tick()
  599. expect(requests).toEqual([{ query: 'src/', drilled: false, quoted: false }])
  600. expect(controller.headers.getSnapshot().size).toBe(0)
  601. })
  602. it('publishes crumbs once a drill produced the query, and drops them when the menu closes', async () => {
  603. const { source, picks } = crumbSource()
  604. const { controller, actx } = controllerBench([source])
  605. const texts: string[] = []
  606. actx.on('slash/input-insert-text', (req) => {
  607. texts.push(req.text)
  608. return true
  609. })
  610. controller.track('@sr', 3, { tier: 'plain' }, 1)
  611. await tick()
  612. controller.pick('reference', 0, 'drill')
  613. expect(texts).toEqual(['@src/'])
  614. expect(picks[0]?.action).toBe('drill')
  615. // The drilled text lands as the next tracked draft.
  616. controller.track('@src/', 5, { tier: 'plain' }, 2)
  617. await tick()
  618. expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }])
  619. controller.dismiss()
  620. expect(controller.headers.getSnapshot().size).toBe(0)
  621. })
  622. it('routes a crumb through the source drill path and refuses the current step', async () => {
  623. const { source, picks } = crumbSource()
  624. const { controller, actx } = controllerBench([source])
  625. actx.on('slash/input-insert-text', () => true)
  626. controller.track('@sr', 3, { tier: 'plain' }, 1)
  627. await tick()
  628. controller.pick('reference', 0, 'drill')
  629. controller.track('@src/lib/', 9, { tier: 'plain' }, 2)
  630. await tick()
  631. const trail = controller.headers.getSnapshot().get('reference')
  632. expect(trail?.map(crumb => crumb.label)).toEqual(['src', 'lib'])
  633. picks.length = 0
  634. controller.pickCrumb('reference', 0)
  635. expect(picks).toHaveLength(1)
  636. expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' })
  637. })
  638. it('publishes crumbs when the input re-tracks inside the drill edit', async () => {
  639. const { source, fetches } = crumbSource()
  640. const { controller, actx } = controllerBench([source])
  641. // A pointer drill reaches the input outside any editor update, so the
  642. // descent commits synchronously and re-tracks before the pick that asked
  643. // for it has returned — the keyboard drill, dispatched inside an update,
  644. // re-tracks only once that update commits. Both orders must reach the
  645. // header and candidate requests as a drill.
  646. actx.on('slash/input-insert-text', (req) => {
  647. controller.track(req.text, req.text.length, { tier: 'plain' }, 2)
  648. return true
  649. })
  650. controller.track('@sr', 3, { tier: 'plain' }, 1)
  651. await tick()
  652. controller.pick('reference', 0, 'drill')
  653. await tick()
  654. expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }])
  655. expect(fetches).toEqual([false, true])
  656. })
  657. it('publishes no crumbs when the input refused the drill edit', async () => {
  658. const { source } = crumbSource()
  659. const { controller } = controllerBench([source])
  660. // No listener accepts the insert, so the descent text never landed.
  661. controller.track('@sr', 3, { tier: 'plain' }, 1)
  662. await tick()
  663. controller.pick('reference', 0, 'drill')
  664. controller.track('@src/', 5, { tier: 'plain' }, 2)
  665. await tick()
  666. expect(controller.headers.getSnapshot().size).toBe(0)
  667. })
  668. it('drops a source whose header throws and keeps the rest of the menu', async () => {
  669. const failing: InputTriggerSource = {
  670. trigger: '@',
  671. name: 'broken',
  672. candidates: () => Promise.resolve([{ name: 'x' }]),
  673. header: () => { throw new Error('header boom') },
  674. onPick: () => undefined,
  675. }
  676. const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  677. const { controller } = controllerBench([failing])
  678. controller.track('@x', 2, { tier: 'plain' }, 1)
  679. await tick()
  680. expect(controller.headers.getSnapshot().size).toBe(0)
  681. expect(controller.menu.getSnapshot().open).toBe(true)
  682. expect(spy).toHaveBeenCalled()
  683. spy.mockRestore()
  684. })
  685. it('tells candidate fetches how the menu was reached', async () => {
  686. const seen: boolean[] = []
  687. const source: InputTriggerSource = {
  688. trigger: '@',
  689. name: 'reference',
  690. candidates: (_session, req) => {
  691. seen.push(req.drilled)
  692. return Promise.resolve([{ name: 'src', drill: true, value: 'src' }])
  693. },
  694. onPick: pick => (pick.action === 'drill' ? { text: '@src/', continue: true } : undefined),
  695. }
  696. const { controller, actx } = controllerBench([source])
  697. actx.on('slash/input-insert-text', () => true)
  698. controller.track('@sr', 3, { tier: 'plain' }, 1)
  699. await tick()
  700. controller.pick('reference', 0, 'drill')
  701. controller.track('@src/', 5, { tier: 'plain' }, 2)
  702. await tick()
  703. expect(seen).toEqual([false, true])
  704. })
  705. })
  706. describe('lexicon', () => {
  707. function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
  708. return {
  709. trigger,
  710. name,
  711. candidates: () => Promise.resolve([]),
  712. onPick: () => undefined,
  713. ...(hasHook ? { lexicon: () => roll } : {}),
  714. }
  715. }
  716. it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
  717. const seen: unknown[] = []
  718. const skill: InputTriggerSource = {
  719. trigger: '/',
  720. name: 'skill',
  721. candidates: () => Promise.resolve([]),
  722. onPick: () => undefined,
  723. lexicon: (projection) => {
  724. seen.push(projection)
  725. return ['commit-helper', 'review']
  726. },
  727. }
  728. const { controller } = controllerBench([
  729. lexSource('/', 'command', undefined, false), // no hook: not polled
  730. skill,
  731. lexSource('@', 'subagent', ['worker-1']),
  732. ])
  733. const rolls = controller.lexicon.getSnapshot()
  734. expect([...rolls.keys()]).toEqual(['/', '@'])
  735. expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
  736. expect(rolls.get('@')).toEqual(['worker-1'])
  737. expect(seen).toEqual([{ sessionId: sid('a') }])
  738. })
  739. it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
  740. const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
  741. expect(controller.lexicon.getSnapshot().size).toBe(0)
  742. })
  743. it('two sources on one trigger concatenate in registration order', () => {
  744. const { controller } = controllerBench([
  745. lexSource('/', 'skill', ['b', 'a']),
  746. lexSource('/', 'prompt', ['c']),
  747. lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
  748. ])
  749. const rolls = controller.lexicon.getSnapshot()
  750. expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
  751. expect(rolls.has('@')).toBe(false)
  752. })
  753. it('a source lexicon notification republishes the roll and refreshes an open menu', async () => {
  754. let roll: readonly string[] | undefined = ['old']
  755. let notify: (() => void) | undefined
  756. const source: InputTriggerSource = {
  757. trigger: '/',
  758. name: 'skill',
  759. candidates: () => Promise.resolve((roll ?? []).map(name => ({ name }))),
  760. onPick: () => undefined,
  761. lexicon: () => roll,
  762. subscribeLexicon: (_session, listener) => {
  763. notify = listener
  764. return () => { notify = undefined }
  765. },
  766. }
  767. const { controller } = controllerBench([source])
  768. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['old'])
  769. controller.track('/', 1, { tier: 'plain' }, 1)
  770. await tick()
  771. expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'old' }])
  772. const seen: number[] = []
  773. controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
  774. roll = ['commit-helper']
  775. notify?.()
  776. await tick()
  777. await tick()
  778. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
  779. expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'commit-helper' }])
  780. expect(seen).toEqual([1])
  781. controller.dispose()
  782. expect(notify).toBeUndefined()
  783. })
  784. it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
  785. const { controller, sources } = controllerBench([])
  786. expect(controller.lexicon.getSnapshot().size).toBe(0)
  787. const warm = vi.fn()
  788. const late: InputTriggerSource = {
  789. trigger: '/',
  790. name: 'late',
  791. candidates: () => Promise.resolve([]),
  792. onPick: () => undefined,
  793. warm,
  794. lexicon: () => ['fresh'],
  795. }
  796. sources.push(late)
  797. controller.sourceAdded(late)
  798. expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
  799. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  800. })
  801. it('a removed source leaves the aggregated lexicon', () => {
  802. const src = lexSource('/', 'skill', ['gone'])
  803. const { controller, sources } = controllerBench([src])
  804. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
  805. sources.splice(sources.indexOf(src), 1)
  806. controller.sourceRemoved(src)
  807. expect(controller.lexicon.getSnapshot().size).toBe(0)
  808. })
  809. })
  810. describe('arbitrate', () => {
  811. async function menuBench() {
  812. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
  813. const { controller } = controllerBench([cmd.source])
  814. controller.track('/g', 2, { tier: 'plain' }, 1)
  815. await tick()
  816. return { controller, cmd }
  817. }
  818. it('up/down move the highlight and are consumed', async () => {
  819. const { controller } = await menuBench()
  820. expect(controller.arbitrate('down', false)).toBe('consumed')
  821. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  822. expect(controller.arbitrate('up', false)).toBe('consumed')
  823. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  824. })
  825. it('hover parks the shared highlight; disposed controllers ignore it', async () => {
  826. const { controller } = await menuBench()
  827. controller.hover('command', 1)
  828. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  829. // Keyboard keeps moving from the parked spot: last input wins.
  830. expect(controller.arbitrate('up', false)).toBe('consumed')
  831. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  832. controller.dispose()
  833. controller.hover('command', 1)
  834. expect(controller.menu.getSnapshot().highlight).toBeNull()
  835. })
  836. it('enter picks the highlight through the pipeline', async () => {
  837. const { controller, cmd } = await menuBench()
  838. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  839. expect(cmd.picks[0]!.candidate.name).toBe('goal')
  840. expect(controller.menu.getSnapshot().open).toBe(false)
  841. })
  842. it('escape closes and consumes', async () => {
  843. const { controller } = await menuBench()
  844. expect(controller.arbitrate('escape', false)).toBe('consumed')
  845. expect(controller.menu.getSnapshot().open).toBe(false)
  846. })
  847. it('tab drills into a drillable highlight and picks a plain completion', async () => {
  848. const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined)
  849. const { controller } = controllerBench([drillable.source])
  850. controller.track('/s', 2, { tier: 'plain' }, 1)
  851. await tick()
  852. expect(controller.arbitrate('tab', false)).toBe('consumed')
  853. expect(drillable.picks[0]!.action).toBe('drill')
  854. expect(drillable.picks[0]!.candidate.name).toBe('src')
  855. // Plain row (no drill flag): Tab settles the highlighted completion.
  856. controller.track('/s', 2, { tier: 'plain' }, 2)
  857. await tick()
  858. controller.arbitrate('down', false)
  859. expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
  860. expect(drillable.picks[1]!.action).toBe('pick')
  861. expect(drillable.picks[1]!.candidate.name).toBe('plan')
  862. expect(controller.menu.getSnapshot().open).toBe(false)
  863. })
  864. it('tab during a pending refinement is consumed: no pick, no focus traversal', async () => {
  865. const picks: string[] = []
  866. const cmd = deferredSource('/', 'command', {
  867. onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
  868. })
  869. const { controller } = controllerBench([cmd.source])
  870. controller.track('/g', 2, { tier: 'plain' }, 1)
  871. cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
  872. await tick()
  873. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  874. // Refinement: previous rows and highlight stay visible while the fetch pends.
  875. controller.track('/go', 3, { tier: 'plain' }, 2)
  876. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  877. expect(controller.arbitrate('tab', false)).toBe('consumed')
  878. expect(picks).toHaveLength(0)
  879. expect(controller.menu.getSnapshot().open).toBe(true)
  880. // Settled: the same gesture settles the highlighted completion.
  881. cmd.pending[1]!.resolve([{ name: 'goal' }])
  882. await tick()
  883. expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
  884. expect(picks).toEqual(['goal'])
  885. })
  886. it('a settling pick reports the pick action', async () => {
  887. const { controller, cmd } = await menuBench()
  888. controller.arbitrate('enter', false)
  889. expect(cmd.picks[0]!.action).toBe('pick')
  890. })
  891. it('IME composition passes every key untouched', async () => {
  892. const { controller } = await menuBench()
  893. for (const key of ['up', 'down', 'enter', 'escape', 'tab'] as const) {
  894. expect(controller.arbitrate(key, true)).toBe('pass')
  895. }
  896. expect(controller.menu.getSnapshot().open).toBe(true)
  897. })
  898. it('closed menu passes; an open menu without a highlight passes picking keys', () => {
  899. const cmd = deferredSource('/', 'command')
  900. const { controller } = controllerBench([cmd.source])
  901. expect(controller.arbitrate('enter', false)).toBe('pass')
  902. expect(controller.arbitrate('tab', false)).toBe('pass')
  903. // Open with the only group still pending: nothing to pick yet.
  904. controller.track('/g', 2, { tier: 'plain' }, 1)
  905. expect(controller.arbitrate('enter', false)).toBe('pass')
  906. expect(controller.arbitrate('tab', false)).toBe('pass')
  907. })
  908. it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => {
  909. const picks: string[] = []
  910. const cmd = deferredSource('/', 'command', {
  911. onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
  912. })
  913. const { controller } = controllerBench([cmd.source])
  914. controller.track('/g', 2, { tier: 'plain' }, 1)
  915. cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
  916. await tick()
  917. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  918. // Refinement: previous rows and highlight stay visible while the fetch pends.
  919. controller.track('/go', 3, { tier: 'plain' }, 2)
  920. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  921. expect(controller.arbitrate('enter', false)).toBe('consumed')
  922. expect(picks).toHaveLength(0)
  923. expect(controller.menu.getSnapshot().open).toBe(true)
  924. // Settled: the same gesture picks again.
  925. cmd.pending[1]!.resolve([{ name: 'goal' }])
  926. await tick()
  927. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  928. expect(picks).toEqual(['goal'])
  929. })
  930. })
  931. describe('onSpace', () => {
  932. function spaceSource(name: string, answer: PickOutcome, calls: string[]): InputTriggerSource {
  933. return {
  934. trigger: '/',
  935. name,
  936. candidates: () => Promise.resolve([]),
  937. onPick: () => undefined,
  938. matchSpace: (_session, token) => {
  939. calls.push(`${name}:${token}`)
  940. return answer
  941. },
  942. }
  943. }
  944. it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
  945. const calls: string[] = []
  946. const claim = claimOf('/goal ')
  947. const { controller, actx } = controllerBench([
  948. // Hook-less source: never polled, so it must not shadow the order below.
  949. { trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
  950. spaceSource('first', undefined, calls),
  951. spaceSource('second', { claim }, calls),
  952. spaceSource('third', { claim: claimOf('/x ') }, calls),
  953. ])
  954. const begins: BeginCommandRequest[] = []
  955. actx.on('slash/input-begin-command', (req) => {
  956. begins.push(req)
  957. return true
  958. })
  959. controller.track('/goal', 5, { tier: 'plain' }, 1)
  960. expect(controller.onSpace()).toBe(true)
  961. expect(calls).toEqual(['first:/goal', 'second:/goal'])
  962. expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
  963. })
  964. it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
  965. const calls: string[] = []
  966. const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  967. declined.actx.on('slash/input-begin-command', () => undefined)
  968. declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
  969. expect(declined.controller.onSpace()).toBe(false)
  970. const handled = controllerBench([spaceSource('command', 'handled', calls)])
  971. const begins: BeginCommandRequest[] = []
  972. handled.actx.on('slash/input-begin-command', (req) => {
  973. begins.push(req)
  974. return true
  975. })
  976. handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
  977. expect(handled.controller.onSpace()).toBe(true)
  978. expect(begins).toHaveLength(0)
  979. })
  980. it('answers false off a non-leading hit or with no tracked hit', () => {
  981. const calls: string[] = []
  982. const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  983. expect(controller.onSpace()).toBe(false)
  984. controller.track('say /goal', 9, { tier: 'plain' }, 1)
  985. expect(controller.onSpace()).toBe(false)
  986. expect(calls).toEqual([])
  987. })
  988. })
  989. describe('adjudicate', () => {
  990. const enterSource = (
  991. trigger: TriggerChar, name: string,
  992. matchEnter?: InputTriggerSource['matchEnter'],
  993. ): InputTriggerSource => ({
  994. trigger,
  995. name,
  996. candidates: () => Promise.resolve([]),
  997. onPick: () => undefined,
  998. ...(matchEnter !== undefined ? { matchEnter } : {}),
  999. })
  1000. it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
  1001. const calls: string[] = []
  1002. const claim = claimOf('/goal ')
  1003. const { controller } = controllerBench([
  1004. enterSource('/', 'silent'),
  1005. enterSource('/', 'first', (session, line) => {
  1006. expect(session).toEqual({ sessionId: sid('a') })
  1007. calls.push(`first:${line}`)
  1008. return Promise.resolve(undefined)
  1009. }),
  1010. enterSource('/', 'second', (_session, line) => {
  1011. calls.push(`second:${line}`)
  1012. return Promise.resolve({ claim })
  1013. }),
  1014. enterSource('/', 'third', () => {
  1015. calls.push('third')
  1016. return Promise.resolve('handled')
  1017. }),
  1018. ])
  1019. const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { attachments: 0 })
  1020. expect(result).toEqual({ claim })
  1021. expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
  1022. })
  1023. it('skips sources of another trigger; all-undefined answers undefined', async () => {
  1024. const atHook = vi.fn(() => Promise.resolve('handled' as const))
  1025. const { controller } = controllerBench([
  1026. enterSource('@', 'subagent', atHook),
  1027. enterSource('/', 'command', () => Promise.resolve(undefined)),
  1028. ])
  1029. await expect(controller.adjudicate('/xyz', new AbortController().signal, { attachments: 0 })).resolves.toBeUndefined()
  1030. expect(atHook).not.toHaveBeenCalled()
  1031. })
  1032. it('forwards the caller envelope to every polled matchEnter unchanged', async () => {
  1033. const envelopes: unknown[] = []
  1034. const { controller } = controllerBench([
  1035. enterSource('/', 'first', (_session, _line, _signal, envelope) => {
  1036. envelopes.push(envelope)
  1037. return Promise.resolve(undefined)
  1038. }),
  1039. enterSource('/', 'second', (_session, _line, _signal, envelope) => {
  1040. envelopes.push(envelope)
  1041. return Promise.resolve('handled')
  1042. }),
  1043. ])
  1044. const envelope = { attachments: 2 }
  1045. await controller.adjudicate('/goal', new AbortController().signal, envelope)
  1046. expect(envelopes).toEqual([envelope, envelope])
  1047. expect(envelopes[0]).toBe(envelope)
  1048. })
  1049. it('a rejecting source rejects the whole adjudication', async () => {
  1050. const { controller } = controllerBench([
  1051. enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
  1052. enterSource('/', 'late', () => Promise.resolve('handled')),
  1053. ])
  1054. await expect(controller.adjudicate('/goal x', new AbortController().signal, { attachments: 0 }))
  1055. .rejects.toThrow('warmup failed')
  1056. })
  1057. it('an aborted attempt signal stops the poll', async () => {
  1058. const hook = vi.fn(() => Promise.resolve(undefined))
  1059. const { controller } = controllerBench([enterSource('/', 'command', hook)])
  1060. const abort = new AbortController()
  1061. abort.abort(new Error('attempt released'))
  1062. await expect(controller.adjudicate('/goal', abort.signal, { attachments: 0 })).rejects.toThrow('attempt released')
  1063. expect(hook).not.toHaveBeenCalled()
  1064. })
  1065. })