service.client.spec.ts 53 KB

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