service.client.spec.ts 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127
  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 { SessionId } from '@deepseek-ai/dsh-session/types'
  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('carries source-title visibility from the roster through candidate settlement', async () => {
  225. const reference = deferredSource('@', 'reference', { showGroupTitle: false })
  226. const { controller } = controllerBench([reference.source])
  227. controller.track('@r', 2, { tier: 'plain' }, 1)
  228. expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'pending' })
  229. reference.pending[0]!.resolve([{ name: 'README.md', section: '文件与文件夹' }])
  230. await tick()
  231. expect(controller.menu.getSnapshot().groups[0]).toMatchObject({ showGroupTitle: false, status: 'ready' })
  232. })
  233. it('stamps the caller draftRev into the hit span', () => {
  234. const cmd = deferredSource('/', 'command')
  235. const { controller } = controllerBench([cmd.source])
  236. controller.track('/g', 2, { tier: 'plain' }, 7)
  237. expect(controller.menu.getSnapshot().hit!.span).toEqual({ start: 0, end: 2, draftRev: 7 })
  238. })
  239. it('candidates receive the session projection, identity only', () => {
  240. const cmd = deferredSource('/', 'command')
  241. const { controller } = controllerBench([cmd.source])
  242. controller.track('/g', 2, { tier: 'plain' }, 1)
  243. expect(cmd.pending[0]!.session).toEqual({ sessionId: sid('a') })
  244. })
  245. it('query refinement supersedes the old generation and aborts its fetch', async () => {
  246. const cmd = deferredSource('/', 'command')
  247. const { controller } = controllerBench([cmd.source])
  248. controller.track('/g', 2, { tier: 'plain' }, 1)
  249. const gen1 = controller.menu.getSnapshot().generation
  250. controller.track('/go', 3, { tier: 'plain' }, 1)
  251. expect(controller.menu.getSnapshot().generation).toBe(gen1 + 1)
  252. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  253. // A late settle of the aborted fetch is dropped even before the
  254. // generation gate: the group stays pending until the live fetch lands.
  255. cmd.pending[0]!.resolve([{ name: 'stale' }])
  256. await tick()
  257. expect(controller.menu.getSnapshot().groups[0]!.status).toBe('pending')
  258. cmd.pending[1]!.resolve([{ name: 'goal' }])
  259. await tick()
  260. expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
  261. })
  262. it('refinement keeps the settled items on screen until the new fetch lands', async () => {
  263. const cmd = deferredSource('/', 'command')
  264. const { controller } = controllerBench([cmd.source])
  265. controller.track('/g', 2, { tier: 'plain' }, 1)
  266. cmd.pending[0]!.resolve([{ name: 'goal' }])
  267. await tick()
  268. // Stale-while-revalidate: the pending group still carries the items.
  269. controller.track('/go', 3, { tier: 'plain' }, 1)
  270. expect(controller.menu.getSnapshot().groups[0]).toEqual(
  271. { source: 'command', status: 'pending', items: [{ name: 'goal' }] },
  272. )
  273. cmd.pending[1]!.resolve([{ name: 'goat' }])
  274. await tick()
  275. expect(controller.menu.getSnapshot().groups[0]).toEqual(
  276. { source: 'command', status: 'ready', items: [{ name: 'goat' }] },
  277. )
  278. })
  279. it('same hit re-track refreshes the span stamp without refetching', () => {
  280. const cmd = deferredSource('/', 'command')
  281. const { controller } = controllerBench([cmd.source])
  282. controller.track('/g', 2, { tier: 'plain' }, 1)
  283. // Same token under the caret, later revision (an edit past the caret).
  284. controller.track('/g x', 2, { tier: 'plain' }, 2)
  285. expect(cmd.pending).toHaveLength(1)
  286. expect(controller.menu.getSnapshot().generation).toBe(1)
  287. })
  288. it('no live trigger closes the menu and aborts the fetch', () => {
  289. const cmd = deferredSource('/', 'command')
  290. const { controller } = controllerBench([cmd.source])
  291. controller.track('/g', 2, { tier: 'plain' }, 1)
  292. controller.track('hello', 5, { tier: 'plain' }, 1)
  293. expect(controller.menu.getSnapshot().open).toBe(false)
  294. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  295. })
  296. it('a trigger with no registered sources never opens', () => {
  297. const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
  298. controller.track('@w', 2, { tier: 'plain' }, 1)
  299. expect(controller.menu.getSnapshot().open).toBe(false)
  300. })
  301. it('trigger switch reseeds the roster', () => {
  302. const { controller } = controllerBench([
  303. deferredSource('/', 'command').source,
  304. deferredSource('@', 'subagent').source,
  305. ])
  306. controller.track('/g', 2, { tier: 'plain' }, 1)
  307. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
  308. controller.track('@w', 2, { tier: 'plain' }, 1)
  309. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
  310. })
  311. it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
  312. const cmd = deferredSource('/', 'command')
  313. const skill = deferredSource('/', 'skill')
  314. const { controller } = controllerBench([cmd.source, skill.source])
  315. controller.track('/zzz', 4, { tier: 'plain' }, 1)
  316. cmd.pending[0]!.resolve([])
  317. await tick()
  318. expect(controller.menu.getSnapshot().open).toBe(true)
  319. skill.pending[0]!.resolve([])
  320. await tick()
  321. expect(controller.menu.getSnapshot().open).toBe(false)
  322. })
  323. it('a rejecting source logs and silently drops its group', async () => {
  324. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
  325. try {
  326. const cmd = deferredSource('/', 'command')
  327. const skill = deferredSource('/', 'skill')
  328. const { controller } = controllerBench([cmd.source, skill.source])
  329. controller.track('/g', 2, { tier: 'plain' }, 1)
  330. skill.pending[0]!.reject(new Error('boom'))
  331. cmd.pending[0]!.resolve([{ name: 'goal' }])
  332. await tick()
  333. const state = controller.menu.getSnapshot()
  334. expect(state.groups.map(g => g.source)).toEqual(['command'])
  335. expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
  336. } finally {
  337. errorSpy.mockRestore()
  338. }
  339. })
  340. })
  341. describe('programmatic source launcher', () => {
  342. it('opens only the requested source and reuses its ordinary pick span', async () => {
  343. const command = readySource('/', 'command', [{ name: 'goal' }])
  344. const skill = readySource('/', 'skill', [{ name: 'review' }])
  345. const { controller } = controllerBench([command.source, skill.source])
  346. const hit = {
  347. trigger: '/' as const,
  348. query: '',
  349. quoted: false,
  350. position: 'leading' as const,
  351. span: { start: 2, end: 5, draftRev: 7 },
  352. }
  353. controller.toggleSource('command', hit)
  354. await tick()
  355. expect(controller.launcher.getSnapshot()).toBe('command')
  356. expect(controller.menu.getSnapshot()).toMatchObject({
  357. open: true,
  358. hit,
  359. groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
  360. })
  361. controller.pick('command', 0)
  362. expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
  363. expect(skill.picks).toHaveLength(0)
  364. expect(controller.launcher.getSnapshot()).toBeNull()
  365. })
  366. it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
  367. const command = readySource('/', 'command', [{ name: 'goal' }])
  368. const skill = readySource('/', 'skill', [{ name: 'review' }])
  369. const { controller } = controllerBench([command.source, skill.source])
  370. const hit = {
  371. trigger: '/' as const,
  372. query: '',
  373. quoted: false,
  374. position: 'leading' as const,
  375. span: { start: 0, end: 0, draftRev: 1 },
  376. }
  377. controller.toggleSource('command', hit)
  378. controller.toggleSource('command', hit)
  379. expect(controller.menu.getSnapshot().open).toBe(false)
  380. expect(controller.launcher.getSnapshot()).toBeNull()
  381. controller.toggleSource('command', hit)
  382. controller.track('/g', 2, { tier: 'plain' }, 2)
  383. await tick()
  384. expect(controller.launcher.getSnapshot()).toBeNull()
  385. expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
  386. })
  387. })
  388. describe('scope-birth warm', () => {
  389. it('construction warms every source once with the session projection', () => {
  390. const cmd = deferredSource('/', 'command')
  391. const sub = deferredSource('@', 'subagent')
  392. controllerBench([cmd.source, sub.source])
  393. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  394. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  395. })
  396. it('hook-less sources are skipped', () => {
  397. const bare: InputTriggerSource = {
  398. trigger: '/',
  399. name: 'bare',
  400. candidates: () => Promise.resolve([]),
  401. onPick: () => undefined,
  402. }
  403. const cmd = deferredSource('/', 'command')
  404. // No throw on the hook-less source; the implementing one still warms.
  405. controllerBench([bare, cmd.source])
  406. expect(cmd.warm).toHaveBeenCalledTimes(1)
  407. })
  408. it('dispose inerts every verb', () => {
  409. const cmd = deferredSource('/', 'command')
  410. const { controller } = controllerBench([cmd.source])
  411. controller.track('/g', 2, { tier: 'plain' }, 1)
  412. controller.dispose()
  413. expect(controller.menu.getSnapshot().open).toBe(false)
  414. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  415. controller.track('/g', 2, { tier: 'plain' }, 1)
  416. expect(controller.menu.getSnapshot().open).toBe(false)
  417. expect(controller.arbitrate('down', false)).toBe('pass')
  418. expect(controller.onSpace()).toBe(false)
  419. controller.pick('command', 0)
  420. })
  421. })
  422. describe('pick / scoped input events', () => {
  423. function pickBench(outcomeOf: (pick: InputTriggerPick) => PickOutcome) {
  424. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
  425. const bench = controllerBench([cmd.source])
  426. const begins: BeginCommandRequest[] = []
  427. const inserts: InsertReferenceRequest[] = []
  428. bench.actx.on('slash/input-begin-command', (req) => {
  429. begins.push(req)
  430. return true
  431. })
  432. bench.actx.on('slash/input-insert-reference', (req) => {
  433. inserts.push(req)
  434. return true
  435. })
  436. bench.controller.track('/g', 2, { tier: 'plain' }, 3)
  437. return { ...bench, cmd, begins, inserts }
  438. }
  439. it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
  440. const claim = claimOf('/goal ')
  441. const { controller, cmd, begins } = pickBench(() => ({ claim }))
  442. await tick()
  443. controller.pick('command', 0)
  444. expect(cmd.picks).toHaveLength(1)
  445. expect(cmd.picks[0]).toMatchObject({
  446. candidate: { name: 'goal' },
  447. session: { sessionId: sid('a') },
  448. position: 'leading',
  449. via: 'menu',
  450. span: { start: 0, end: 2, draftRev: 3 },
  451. })
  452. expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
  453. expect(controller.menu.getSnapshot().open).toBe(false)
  454. })
  455. it('routes an insert outcome through the scoped insert-reference event', async () => {
  456. const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
  457. const { controller, inserts } = pickBench(() => ({ insert }))
  458. await tick()
  459. controller.pick('command', 1)
  460. expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
  461. })
  462. it('routes a text outcome through the scoped insert-text event and closes the menu', async () => {
  463. const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
  464. const texts: Array<{ text: string; span: unknown }> = []
  465. actx.on('slash/input-insert-text', (req) => {
  466. texts.push(req)
  467. return true
  468. })
  469. await tick()
  470. controller.pick('command', 0)
  471. expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
  472. expect(controller.menu.getSnapshot().open).toBe(false)
  473. })
  474. it('forwards a continuing text outcome so a directory pick keeps completion open', async () => {
  475. const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true }))
  476. const texts: Array<{ text: string; continue?: boolean }> = []
  477. actx.on('slash/input-insert-text', (req) => {
  478. texts.push(req)
  479. return true
  480. })
  481. await tick()
  482. controller.pick('command', 0)
  483. expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }])
  484. })
  485. it('a text outcome the input declines answers false on the space path', async () => {
  486. const src: InputTriggerSource = {
  487. trigger: '/',
  488. name: 'command',
  489. candidates: () => Promise.resolve([]),
  490. onPick: () => undefined,
  491. matchSpace: () => ({ text: '/goal ' }),
  492. }
  493. const { controller, actx } = controllerBench([src])
  494. actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
  495. controller.track('/goal', 5, { tier: 'plain' }, 1)
  496. expect(controller.onSpace()).toBe(false)
  497. })
  498. it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
  499. const claim = claimOf('/goal ')
  500. const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
  501. const { root, controller } = controllerBench([cmd.source])
  502. const foreign: BeginCommandRequest[] = []
  503. const rootSeen: BeginCommandRequest[] = []
  504. createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
  505. foreign.push(req)
  506. return true
  507. })
  508. // Untagged root listeners are admitted globally (the carrier contract).
  509. root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
  510. controller.track('/g', 2, { tier: 'plain' }, 3)
  511. await tick()
  512. controller.pick('command', 0)
  513. expect(foreign).toHaveLength(0)
  514. expect(rootSeen).toHaveLength(1)
  515. })
  516. it("'handled' and undefined outcomes only close the menu", async () => {
  517. const { controller, begins, inserts } = pickBench(() => 'handled')
  518. await tick()
  519. controller.pick('command', 0)
  520. expect(begins).toHaveLength(0)
  521. expect(inserts).toHaveLength(0)
  522. expect(controller.menu.getSnapshot().open).toBe(false)
  523. })
  524. it('closed menu / vanished candidate picks are no-ops', async () => {
  525. const { controller, cmd } = pickBench(() => undefined)
  526. await tick()
  527. controller.pick('command', 9)
  528. controller.pick('ghost', 0)
  529. expect(cmd.picks).toHaveLength(0)
  530. expect(controller.menu.getSnapshot().open).toBe(true)
  531. })
  532. })
  533. describe('header / drilled descent', () => {
  534. /** A source that publishes one crumb per path segment of a drilled query. */
  535. function crumbSource() {
  536. const requests: Array<{ query: string; quoted?: boolean; drilled: boolean }> = []
  537. const fetches: boolean[] = []
  538. const picks: InputTriggerPick[] = []
  539. const source: InputTriggerSource = {
  540. trigger: '@',
  541. name: 'reference',
  542. candidates: (_session, req) => {
  543. fetches.push(req.drilled)
  544. return Promise.resolve([{ name: 'src', drill: true, value: 'src' }])
  545. },
  546. header: (_session, req) => {
  547. requests.push({ ...req })
  548. if (!req.drilled || !req.query.includes('/')) return undefined
  549. return req.query.split('/').filter(Boolean).map(label => ({ label, value: label }))
  550. },
  551. onPick: (pick) => {
  552. picks.push(pick)
  553. return pick.action === 'drill' ? { text: `@${String(pick.candidate.value)}/`, continue: true } : undefined
  554. },
  555. }
  556. return { source, requests, fetches, picks }
  557. }
  558. it('publishes no crumbs for a typed path and asks every source how the menu was reached', async () => {
  559. const { source, requests } = crumbSource()
  560. const { controller } = controllerBench([source])
  561. controller.track('@src/', 5, { tier: 'plain' }, 1)
  562. await tick()
  563. expect(requests).toEqual([{ query: 'src/', drilled: false, quoted: false }])
  564. expect(controller.headers.getSnapshot().size).toBe(0)
  565. })
  566. it('publishes crumbs once a drill produced the query, and drops them when the menu closes', async () => {
  567. const { source, picks } = crumbSource()
  568. const { controller, actx } = controllerBench([source])
  569. const texts: string[] = []
  570. actx.on('slash/input-insert-text', (req) => {
  571. texts.push(req.text)
  572. return true
  573. })
  574. controller.track('@sr', 3, { tier: 'plain' }, 1)
  575. await tick()
  576. controller.pick('reference', 0, 'drill')
  577. expect(texts).toEqual(['@src/'])
  578. expect(picks[0]?.action).toBe('drill')
  579. // The drilled text lands as the next tracked draft.
  580. controller.track('@src/', 5, { tier: 'plain' }, 2)
  581. await tick()
  582. expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }])
  583. controller.dismiss()
  584. expect(controller.headers.getSnapshot().size).toBe(0)
  585. })
  586. it('routes a crumb through the source drill path and refuses the current step', async () => {
  587. const { source, picks } = crumbSource()
  588. const { controller, actx } = controllerBench([source])
  589. actx.on('slash/input-insert-text', () => true)
  590. controller.track('@sr', 3, { tier: 'plain' }, 1)
  591. await tick()
  592. controller.pick('reference', 0, 'drill')
  593. controller.track('@src/lib/', 9, { tier: 'plain' }, 2)
  594. await tick()
  595. const trail = controller.headers.getSnapshot().get('reference')
  596. expect(trail?.map(crumb => crumb.label)).toEqual(['src', 'lib'])
  597. picks.length = 0
  598. controller.pickCrumb('reference', 0)
  599. expect(picks).toHaveLength(1)
  600. expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' })
  601. })
  602. it('publishes crumbs when the input re-tracks inside the drill edit', async () => {
  603. const { source, fetches } = crumbSource()
  604. const { controller, actx } = controllerBench([source])
  605. // A pointer drill reaches the input outside any editor update, so the
  606. // descent commits synchronously and re-tracks before the pick that asked
  607. // for it has returned — the keyboard drill, dispatched inside an update,
  608. // re-tracks only once that update commits. Both orders must reach the
  609. // header and candidate requests as a drill.
  610. actx.on('slash/input-insert-text', (req) => {
  611. controller.track(req.text, req.text.length, { tier: 'plain' }, 2)
  612. return true
  613. })
  614. controller.track('@sr', 3, { tier: 'plain' }, 1)
  615. await tick()
  616. controller.pick('reference', 0, 'drill')
  617. await tick()
  618. expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }])
  619. expect(fetches).toEqual([false, true])
  620. })
  621. it('publishes no crumbs when the input refused the drill edit', async () => {
  622. const { source } = crumbSource()
  623. const { controller } = controllerBench([source])
  624. // No listener accepts the insert, so the descent text never landed.
  625. controller.track('@sr', 3, { tier: 'plain' }, 1)
  626. await tick()
  627. controller.pick('reference', 0, 'drill')
  628. controller.track('@src/', 5, { tier: 'plain' }, 2)
  629. await tick()
  630. expect(controller.headers.getSnapshot().size).toBe(0)
  631. })
  632. it('drops a source whose header throws and keeps the rest of the menu', async () => {
  633. const failing: InputTriggerSource = {
  634. trigger: '@',
  635. name: 'broken',
  636. candidates: () => Promise.resolve([{ name: 'x' }]),
  637. header: () => { throw new Error('header boom') },
  638. onPick: () => undefined,
  639. }
  640. const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  641. const { controller } = controllerBench([failing])
  642. controller.track('@x', 2, { tier: 'plain' }, 1)
  643. await tick()
  644. expect(controller.headers.getSnapshot().size).toBe(0)
  645. expect(controller.menu.getSnapshot().open).toBe(true)
  646. expect(spy).toHaveBeenCalled()
  647. spy.mockRestore()
  648. })
  649. it('tells candidate fetches how the menu was reached', async () => {
  650. const seen: boolean[] = []
  651. const source: InputTriggerSource = {
  652. trigger: '@',
  653. name: 'reference',
  654. candidates: (_session, req) => {
  655. seen.push(req.drilled)
  656. return Promise.resolve([{ name: 'src', drill: true, value: 'src' }])
  657. },
  658. onPick: pick => (pick.action === 'drill' ? { text: '@src/', continue: true } : undefined),
  659. }
  660. const { controller, actx } = controllerBench([source])
  661. actx.on('slash/input-insert-text', () => true)
  662. controller.track('@sr', 3, { tier: 'plain' }, 1)
  663. await tick()
  664. controller.pick('reference', 0, 'drill')
  665. controller.track('@src/', 5, { tier: 'plain' }, 2)
  666. await tick()
  667. expect(seen).toEqual([false, true])
  668. })
  669. })
  670. describe('lexicon', () => {
  671. function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
  672. return {
  673. trigger,
  674. name,
  675. candidates: () => Promise.resolve([]),
  676. onPick: () => undefined,
  677. ...(hasHook ? { lexicon: () => roll } : {}),
  678. }
  679. }
  680. it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
  681. const seen: unknown[] = []
  682. const skill: InputTriggerSource = {
  683. trigger: '/',
  684. name: 'skill',
  685. candidates: () => Promise.resolve([]),
  686. onPick: () => undefined,
  687. lexicon: (projection) => {
  688. seen.push(projection)
  689. return ['commit-helper', 'review']
  690. },
  691. }
  692. const { controller } = controllerBench([
  693. lexSource('/', 'command', undefined, false), // no hook: not polled
  694. skill,
  695. lexSource('@', 'subagent', ['worker-1']),
  696. ])
  697. const rolls = controller.lexicon.getSnapshot()
  698. expect([...rolls.keys()]).toEqual(['/', '@'])
  699. expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
  700. expect(rolls.get('@')).toEqual(['worker-1'])
  701. expect(seen).toEqual([{ sessionId: sid('a') }])
  702. })
  703. it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
  704. const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
  705. expect(controller.lexicon.getSnapshot().size).toBe(0)
  706. })
  707. it('two sources on one trigger concatenate in registration order', () => {
  708. const { controller } = controllerBench([
  709. lexSource('/', 'skill', ['b', 'a']),
  710. lexSource('/', 'prompt', ['c']),
  711. lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
  712. ])
  713. const rolls = controller.lexicon.getSnapshot()
  714. expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
  715. expect(rolls.has('@')).toBe(false)
  716. })
  717. it('a source lexicon notification republishes the roll and refreshes an open menu', async () => {
  718. let roll: readonly string[] | undefined = ['old']
  719. let notify: (() => void) | undefined
  720. const source: InputTriggerSource = {
  721. trigger: '/',
  722. name: 'skill',
  723. candidates: () => Promise.resolve((roll ?? []).map(name => ({ name }))),
  724. onPick: () => undefined,
  725. lexicon: () => roll,
  726. subscribeLexicon: (_session, listener) => {
  727. notify = listener
  728. return () => { notify = undefined }
  729. },
  730. }
  731. const { controller } = controllerBench([source])
  732. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['old'])
  733. controller.track('/', 1, { tier: 'plain' }, 1)
  734. await tick()
  735. expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'old' }])
  736. const seen: number[] = []
  737. controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
  738. roll = ['commit-helper']
  739. notify?.()
  740. await tick()
  741. await tick()
  742. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
  743. expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'commit-helper' }])
  744. expect(seen).toEqual([1])
  745. controller.dispose()
  746. expect(notify).toBeUndefined()
  747. })
  748. it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
  749. const { controller, sources } = controllerBench([])
  750. expect(controller.lexicon.getSnapshot().size).toBe(0)
  751. const warm = vi.fn()
  752. const late: InputTriggerSource = {
  753. trigger: '/',
  754. name: 'late',
  755. candidates: () => Promise.resolve([]),
  756. onPick: () => undefined,
  757. warm,
  758. lexicon: () => ['fresh'],
  759. }
  760. sources.push(late)
  761. controller.sourceAdded(late)
  762. expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
  763. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  764. })
  765. it('a removed source leaves the aggregated lexicon', () => {
  766. const src = lexSource('/', 'skill', ['gone'])
  767. const { controller, sources } = controllerBench([src])
  768. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
  769. sources.splice(sources.indexOf(src), 1)
  770. controller.sourceRemoved(src)
  771. expect(controller.lexicon.getSnapshot().size).toBe(0)
  772. })
  773. })
  774. describe('arbitrate', () => {
  775. async function menuBench() {
  776. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
  777. const { controller } = controllerBench([cmd.source])
  778. controller.track('/g', 2, { tier: 'plain' }, 1)
  779. await tick()
  780. return { controller, cmd }
  781. }
  782. it('up/down move the highlight and are consumed', async () => {
  783. const { controller } = await menuBench()
  784. expect(controller.arbitrate('down', false)).toBe('consumed')
  785. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  786. expect(controller.arbitrate('up', false)).toBe('consumed')
  787. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  788. })
  789. it('hover parks the shared highlight; disposed controllers ignore it', async () => {
  790. const { controller } = await menuBench()
  791. controller.hover('command', 1)
  792. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  793. // Keyboard keeps moving from the parked spot: last input wins.
  794. expect(controller.arbitrate('up', false)).toBe('consumed')
  795. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  796. controller.dispose()
  797. controller.hover('command', 1)
  798. expect(controller.menu.getSnapshot().highlight).toBeNull()
  799. })
  800. it('enter picks the highlight through the pipeline', async () => {
  801. const { controller, cmd } = await menuBench()
  802. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  803. expect(cmd.picks[0]!.candidate.name).toBe('goal')
  804. expect(controller.menu.getSnapshot().open).toBe(false)
  805. })
  806. it('escape closes and consumes', async () => {
  807. const { controller } = await menuBench()
  808. expect(controller.arbitrate('escape', false)).toBe('consumed')
  809. expect(controller.menu.getSnapshot().open).toBe(false)
  810. })
  811. it('tab drills into a drillable highlight and picks a plain completion', async () => {
  812. const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined)
  813. const { controller } = controllerBench([drillable.source])
  814. controller.track('/s', 2, { tier: 'plain' }, 1)
  815. await tick()
  816. expect(controller.arbitrate('tab', false)).toBe('consumed')
  817. expect(drillable.picks[0]!.action).toBe('drill')
  818. expect(drillable.picks[0]!.candidate.name).toBe('src')
  819. // Plain row (no drill flag): Tab settles the highlighted completion.
  820. controller.track('/s', 2, { tier: 'plain' }, 2)
  821. await tick()
  822. controller.arbitrate('down', false)
  823. expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
  824. expect(drillable.picks[1]!.action).toBe('pick')
  825. expect(drillable.picks[1]!.candidate.name).toBe('plan')
  826. expect(controller.menu.getSnapshot().open).toBe(false)
  827. })
  828. it('tab during a pending refinement is consumed: no pick, no focus traversal', async () => {
  829. const picks: string[] = []
  830. const cmd = deferredSource('/', 'command', {
  831. onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
  832. })
  833. const { controller } = controllerBench([cmd.source])
  834. controller.track('/g', 2, { tier: 'plain' }, 1)
  835. cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
  836. await tick()
  837. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  838. // Refinement: previous rows and highlight stay visible while the fetch pends.
  839. controller.track('/go', 3, { tier: 'plain' }, 2)
  840. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  841. expect(controller.arbitrate('tab', false)).toBe('consumed')
  842. expect(picks).toHaveLength(0)
  843. expect(controller.menu.getSnapshot().open).toBe(true)
  844. // Settled: the same gesture settles the highlighted completion.
  845. cmd.pending[1]!.resolve([{ name: 'goal' }])
  846. await tick()
  847. expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
  848. expect(picks).toEqual(['goal'])
  849. })
  850. it('a settling pick reports the pick action', async () => {
  851. const { controller, cmd } = await menuBench()
  852. controller.arbitrate('enter', false)
  853. expect(cmd.picks[0]!.action).toBe('pick')
  854. })
  855. it('IME composition passes every key untouched', async () => {
  856. const { controller } = await menuBench()
  857. for (const key of ['up', 'down', 'enter', 'escape', 'tab'] as const) {
  858. expect(controller.arbitrate(key, true)).toBe('pass')
  859. }
  860. expect(controller.menu.getSnapshot().open).toBe(true)
  861. })
  862. it('closed menu passes; an open menu without a highlight passes picking keys', () => {
  863. const cmd = deferredSource('/', 'command')
  864. const { controller } = controllerBench([cmd.source])
  865. expect(controller.arbitrate('enter', false)).toBe('pass')
  866. expect(controller.arbitrate('tab', false)).toBe('pass')
  867. // Open with the only group still pending: nothing to pick yet.
  868. controller.track('/g', 2, { tier: 'plain' }, 1)
  869. expect(controller.arbitrate('enter', false)).toBe('pass')
  870. expect(controller.arbitrate('tab', false)).toBe('pass')
  871. })
  872. it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => {
  873. const picks: string[] = []
  874. const cmd = deferredSource('/', 'command', {
  875. onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
  876. })
  877. const { controller } = controllerBench([cmd.source])
  878. controller.track('/g', 2, { tier: 'plain' }, 1)
  879. cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
  880. await tick()
  881. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  882. // Refinement: previous rows and highlight stay visible while the fetch pends.
  883. controller.track('/go', 3, { tier: 'plain' }, 2)
  884. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  885. expect(controller.arbitrate('enter', false)).toBe('consumed')
  886. expect(picks).toHaveLength(0)
  887. expect(controller.menu.getSnapshot().open).toBe(true)
  888. // Settled: the same gesture picks again.
  889. cmd.pending[1]!.resolve([{ name: 'goal' }])
  890. await tick()
  891. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  892. expect(picks).toEqual(['goal'])
  893. })
  894. })
  895. describe('onSpace', () => {
  896. function spaceSource(name: string, answer: PickOutcome, calls: string[]): InputTriggerSource {
  897. return {
  898. trigger: '/',
  899. name,
  900. candidates: () => Promise.resolve([]),
  901. onPick: () => undefined,
  902. matchSpace: (_session, token) => {
  903. calls.push(`${name}:${token}`)
  904. return answer
  905. },
  906. }
  907. }
  908. it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
  909. const calls: string[] = []
  910. const claim = claimOf('/goal ')
  911. const { controller, actx } = controllerBench([
  912. // Hook-less source: never polled, so it must not shadow the order below.
  913. { trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
  914. spaceSource('first', undefined, calls),
  915. spaceSource('second', { claim }, calls),
  916. spaceSource('third', { claim: claimOf('/x ') }, calls),
  917. ])
  918. const begins: BeginCommandRequest[] = []
  919. actx.on('slash/input-begin-command', (req) => {
  920. begins.push(req)
  921. return true
  922. })
  923. controller.track('/goal', 5, { tier: 'plain' }, 1)
  924. expect(controller.onSpace()).toBe(true)
  925. expect(calls).toEqual(['first:/goal', 'second:/goal'])
  926. expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
  927. })
  928. it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
  929. const calls: string[] = []
  930. const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  931. declined.actx.on('slash/input-begin-command', () => undefined)
  932. declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
  933. expect(declined.controller.onSpace()).toBe(false)
  934. const handled = controllerBench([spaceSource('command', 'handled', calls)])
  935. const begins: BeginCommandRequest[] = []
  936. handled.actx.on('slash/input-begin-command', (req) => {
  937. begins.push(req)
  938. return true
  939. })
  940. handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
  941. expect(handled.controller.onSpace()).toBe(true)
  942. expect(begins).toHaveLength(0)
  943. })
  944. it('answers false off a non-leading hit or with no tracked hit', () => {
  945. const calls: string[] = []
  946. const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  947. expect(controller.onSpace()).toBe(false)
  948. controller.track('say /goal', 9, { tier: 'plain' }, 1)
  949. expect(controller.onSpace()).toBe(false)
  950. expect(calls).toEqual([])
  951. })
  952. })
  953. describe('adjudicate', () => {
  954. const enterSource = (
  955. trigger: TriggerChar, name: string,
  956. matchEnter?: InputTriggerSource['matchEnter'],
  957. ): InputTriggerSource => ({
  958. trigger,
  959. name,
  960. candidates: () => Promise.resolve([]),
  961. onPick: () => undefined,
  962. ...(matchEnter !== undefined ? { matchEnter } : {}),
  963. })
  964. it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
  965. const calls: string[] = []
  966. const claim = claimOf('/goal ')
  967. const { controller } = controllerBench([
  968. enterSource('/', 'silent'),
  969. enterSource('/', 'first', (session, line) => {
  970. expect(session).toEqual({ sessionId: sid('a') })
  971. calls.push(`first:${line}`)
  972. return Promise.resolve(undefined)
  973. }),
  974. enterSource('/', 'second', (_session, line) => {
  975. calls.push(`second:${line}`)
  976. return Promise.resolve({ claim })
  977. }),
  978. enterSource('/', 'third', () => {
  979. calls.push('third')
  980. return Promise.resolve('handled')
  981. }),
  982. ])
  983. const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { attachments: 0 })
  984. expect(result).toEqual({ claim })
  985. expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
  986. })
  987. it('skips sources of another trigger; all-undefined answers undefined', async () => {
  988. const atHook = vi.fn(() => Promise.resolve('handled' as const))
  989. const { controller } = controllerBench([
  990. enterSource('@', 'subagent', atHook),
  991. enterSource('/', 'command', () => Promise.resolve(undefined)),
  992. ])
  993. await expect(controller.adjudicate('/xyz', new AbortController().signal, { attachments: 0 })).resolves.toBeUndefined()
  994. expect(atHook).not.toHaveBeenCalled()
  995. })
  996. it('forwards the caller envelope to every polled matchEnter unchanged', async () => {
  997. const envelopes: unknown[] = []
  998. const { controller } = controllerBench([
  999. enterSource('/', 'first', (_session, _line, _signal, envelope) => {
  1000. envelopes.push(envelope)
  1001. return Promise.resolve(undefined)
  1002. }),
  1003. enterSource('/', 'second', (_session, _line, _signal, envelope) => {
  1004. envelopes.push(envelope)
  1005. return Promise.resolve('handled')
  1006. }),
  1007. ])
  1008. const envelope = { attachments: 2 }
  1009. await controller.adjudicate('/goal', new AbortController().signal, envelope)
  1010. expect(envelopes).toEqual([envelope, envelope])
  1011. expect(envelopes[0]).toBe(envelope)
  1012. })
  1013. it('a rejecting source rejects the whole adjudication', async () => {
  1014. const { controller } = controllerBench([
  1015. enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
  1016. enterSource('/', 'late', () => Promise.resolve('handled')),
  1017. ])
  1018. await expect(controller.adjudicate('/goal x', new AbortController().signal, { attachments: 0 }))
  1019. .rejects.toThrow('warmup failed')
  1020. })
  1021. it('an aborted attempt signal stops the poll', async () => {
  1022. const hook = vi.fn(() => Promise.resolve(undefined))
  1023. const { controller } = controllerBench([enterSource('/', 'command', hook)])
  1024. const abort = new AbortController()
  1025. abort.abort(new Error('attempt released'))
  1026. await expect(controller.adjudicate('/goal', abort.signal, { attachments: 0 })).rejects.toThrow('attempt released')
  1027. expect(hook).not.toHaveBeenCalled()
  1028. })
  1029. })