service.client.spec.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879
  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('same hit re-track refreshes the span stamp without refetching', () => {
  263. const cmd = deferredSource('/', 'command')
  264. const { controller } = controllerBench([cmd.source])
  265. controller.track('/g', 2, { tier: 'plain' }, 1)
  266. // Same token under the caret, later revision (an edit past the caret).
  267. controller.track('/g x', 2, { tier: 'plain' }, 2)
  268. expect(cmd.pending).toHaveLength(1)
  269. expect(controller.menu.getSnapshot().generation).toBe(1)
  270. })
  271. it('no live trigger closes the menu and aborts the fetch', () => {
  272. const cmd = deferredSource('/', 'command')
  273. const { controller } = controllerBench([cmd.source])
  274. controller.track('/g', 2, { tier: 'plain' }, 1)
  275. controller.track('hello', 5, { tier: 'plain' }, 1)
  276. expect(controller.menu.getSnapshot().open).toBe(false)
  277. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  278. })
  279. it('a trigger with no registered sources never opens', () => {
  280. const { controller } = controllerBench([readySource('/', 'command', [{ name: 'goal' }]).source])
  281. controller.track('@w', 2, { tier: 'plain' }, 1)
  282. expect(controller.menu.getSnapshot().open).toBe(false)
  283. })
  284. it('trigger switch reseeds the roster', () => {
  285. const { controller } = controllerBench([
  286. deferredSource('/', 'command').source,
  287. deferredSource('@', 'subagent').source,
  288. ])
  289. controller.track('/g', 2, { tier: 'plain' }, 1)
  290. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['command'])
  291. controller.track('@w', 2, { tier: 'plain' }, 1)
  292. expect(controller.menu.getSnapshot().groups.map(g => g.source)).toEqual(['subagent'])
  293. })
  294. it('all sources settling empty auto-closes; a later settle of a gone generation is silent', async () => {
  295. const cmd = deferredSource('/', 'command')
  296. const skill = deferredSource('/', 'skill')
  297. const { controller } = controllerBench([cmd.source, skill.source])
  298. controller.track('/zzz', 4, { tier: 'plain' }, 1)
  299. cmd.pending[0]!.resolve([])
  300. await tick()
  301. expect(controller.menu.getSnapshot().open).toBe(true)
  302. skill.pending[0]!.resolve([])
  303. await tick()
  304. expect(controller.menu.getSnapshot().open).toBe(false)
  305. })
  306. it('a rejecting source logs and silently drops its group', async () => {
  307. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
  308. try {
  309. const cmd = deferredSource('/', 'command')
  310. const skill = deferredSource('/', 'skill')
  311. const { controller } = controllerBench([cmd.source, skill.source])
  312. controller.track('/g', 2, { tier: 'plain' }, 1)
  313. skill.pending[0]!.reject(new Error('boom'))
  314. cmd.pending[0]!.resolve([{ name: 'goal' }])
  315. await tick()
  316. const state = controller.menu.getSnapshot()
  317. expect(state.groups.map(g => g.source)).toEqual(['command'])
  318. expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('skill'), expect.any(Error))
  319. } finally {
  320. errorSpy.mockRestore()
  321. }
  322. })
  323. })
  324. describe('programmatic source launcher', () => {
  325. it('opens only the requested source and reuses its ordinary pick span', async () => {
  326. const command = readySource('/', 'command', [{ name: 'goal' }])
  327. const skill = readySource('/', 'skill', [{ name: 'review' }])
  328. const { controller } = controllerBench([command.source, skill.source])
  329. const hit = {
  330. trigger: '/' as const,
  331. query: '',
  332. quoted: false,
  333. position: 'leading' as const,
  334. span: { start: 2, end: 5, draftRev: 7 },
  335. }
  336. controller.toggleSource('command', hit)
  337. await tick()
  338. expect(controller.launcher.getSnapshot()).toBe('command')
  339. expect(controller.menu.getSnapshot()).toMatchObject({
  340. open: true,
  341. hit,
  342. groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }],
  343. })
  344. controller.pick('command', 0)
  345. expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span })
  346. expect(skill.picks).toHaveLength(0)
  347. expect(controller.launcher.getSnapshot()).toBeNull()
  348. })
  349. it('toggles closed, and typed tracking returns to the full trigger roster', async () => {
  350. const command = readySource('/', 'command', [{ name: 'goal' }])
  351. const skill = readySource('/', 'skill', [{ name: 'review' }])
  352. const { controller } = controllerBench([command.source, skill.source])
  353. const hit = {
  354. trigger: '/' as const,
  355. query: '',
  356. quoted: false,
  357. position: 'leading' as const,
  358. span: { start: 0, end: 0, draftRev: 1 },
  359. }
  360. controller.toggleSource('command', hit)
  361. controller.toggleSource('command', hit)
  362. expect(controller.menu.getSnapshot().open).toBe(false)
  363. expect(controller.launcher.getSnapshot()).toBeNull()
  364. controller.toggleSource('command', hit)
  365. controller.track('/g', 2, { tier: 'plain' }, 2)
  366. await tick()
  367. expect(controller.launcher.getSnapshot()).toBeNull()
  368. expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill'])
  369. })
  370. })
  371. describe('scope-birth warm', () => {
  372. it('construction warms every source once with the session projection', () => {
  373. const cmd = deferredSource('/', 'command')
  374. const sub = deferredSource('@', 'subagent')
  375. controllerBench([cmd.source, sub.source])
  376. expect(cmd.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  377. expect(sub.warm).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('a') })
  378. })
  379. it('hook-less sources are skipped', () => {
  380. const bare: InputTriggerSource = {
  381. trigger: '/',
  382. name: 'bare',
  383. candidates: () => Promise.resolve([]),
  384. onPick: () => undefined,
  385. }
  386. const cmd = deferredSource('/', 'command')
  387. // No throw on the hook-less source; the implementing one still warms.
  388. controllerBench([bare, cmd.source])
  389. expect(cmd.warm).toHaveBeenCalledTimes(1)
  390. })
  391. it('dispose inerts every verb', () => {
  392. const cmd = deferredSource('/', 'command')
  393. const { controller } = controllerBench([cmd.source])
  394. controller.track('/g', 2, { tier: 'plain' }, 1)
  395. controller.dispose()
  396. expect(controller.menu.getSnapshot().open).toBe(false)
  397. expect(cmd.pending[0]!.signal.aborted).toBe(true)
  398. controller.track('/g', 2, { tier: 'plain' }, 1)
  399. expect(controller.menu.getSnapshot().open).toBe(false)
  400. expect(controller.arbitrate('down', false)).toBe('pass')
  401. expect(controller.onSpace()).toBe(false)
  402. controller.pick('command', 0)
  403. })
  404. })
  405. describe('pick / scoped input events', () => {
  406. function pickBench(outcomeOf: (pick: InputTriggerPick) => PickOutcome) {
  407. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], outcomeOf)
  408. const bench = controllerBench([cmd.source])
  409. const begins: BeginCommandRequest[] = []
  410. const inserts: InsertReferenceRequest[] = []
  411. bench.actx.on('slash/input-begin-command', (req) => {
  412. begins.push(req)
  413. return true
  414. })
  415. bench.actx.on('slash/input-insert-reference', (req) => {
  416. inserts.push(req)
  417. return true
  418. })
  419. bench.controller.track('/g', 2, { tier: 'plain' }, 3)
  420. return { ...bench, cmd, begins, inserts }
  421. }
  422. it('routes a claim outcome through the scoped begin-command event and closes the menu', async () => {
  423. const claim = claimOf('/goal ')
  424. const { controller, cmd, begins } = pickBench(() => ({ claim }))
  425. await tick()
  426. controller.pick('command', 0)
  427. expect(cmd.picks).toHaveLength(1)
  428. expect(cmd.picks[0]).toMatchObject({
  429. candidate: { name: 'goal' },
  430. session: { sessionId: sid('a') },
  431. position: 'leading',
  432. via: 'menu',
  433. span: { start: 0, end: 2, draftRev: 3 },
  434. })
  435. expect(begins).toEqual([{ claim, span: { start: 0, end: 2, draftRev: 3 } }])
  436. expect(controller.menu.getSnapshot().open).toBe(false)
  437. })
  438. it('routes an insert outcome through the scoped insert-reference event', async () => {
  439. const insert: ReferenceInsert = { source: 'skill', ref: 'x', label: 'x', clipboardText: '/x' }
  440. const { controller, inserts } = pickBench(() => ({ insert }))
  441. await tick()
  442. controller.pick('command', 1)
  443. expect(inserts).toEqual([{ reference: insert, span: { start: 0, end: 2, draftRev: 3 } }])
  444. })
  445. it('routes a text outcome through the scoped insert-text event and closes the menu', async () => {
  446. const { controller, actx } = pickBench(() => ({ text: '/goal ' }))
  447. const texts: Array<{ text: string; span: unknown }> = []
  448. actx.on('slash/input-insert-text', (req) => {
  449. texts.push(req)
  450. return true
  451. })
  452. await tick()
  453. controller.pick('command', 0)
  454. expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 3 } }])
  455. expect(controller.menu.getSnapshot().open).toBe(false)
  456. })
  457. it('forwards a continuing text outcome so a directory pick keeps completion open', async () => {
  458. const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true }))
  459. const texts: Array<{ text: string; continue?: boolean }> = []
  460. actx.on('slash/input-insert-text', (req) => {
  461. texts.push(req)
  462. return true
  463. })
  464. await tick()
  465. controller.pick('command', 0)
  466. expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }])
  467. })
  468. it('a text outcome the input declines answers false on the space path', async () => {
  469. const src: InputTriggerSource = {
  470. trigger: '/',
  471. name: 'command',
  472. candidates: () => Promise.resolve([]),
  473. onPick: () => undefined,
  474. matchSpace: () => ({ text: '/goal ' }),
  475. }
  476. const { controller, actx } = controllerBench([src])
  477. actx.on('slash/input-insert-text', () => undefined) // input declines (CAS miss)
  478. controller.track('/goal', 5, { tier: 'plain' }, 1)
  479. expect(controller.onSpace()).toBe(false)
  480. })
  481. it('scope carrier routing: a foreign session\'s listener never hears the dispatch, untagged root does', async () => {
  482. const claim = claimOf('/goal ')
  483. const cmd = readySource('/', 'command', [{ name: 'goal' }], () => ({ claim }))
  484. const { root, controller } = controllerBench([cmd.source])
  485. const foreign: BeginCommandRequest[] = []
  486. const rootSeen: BeginCommandRequest[] = []
  487. createScope(root, sid('b')).ctx.on('slash/input-begin-command', (req) => {
  488. foreign.push(req)
  489. return true
  490. })
  491. // Untagged root listeners are admitted globally (the carrier contract).
  492. root.on('slash/input-begin-command', (req) => { rootSeen.push(req) })
  493. controller.track('/g', 2, { tier: 'plain' }, 3)
  494. await tick()
  495. controller.pick('command', 0)
  496. expect(foreign).toHaveLength(0)
  497. expect(rootSeen).toHaveLength(1)
  498. })
  499. it("'handled' and undefined outcomes only close the menu", async () => {
  500. const { controller, begins, inserts } = pickBench(() => 'handled')
  501. await tick()
  502. controller.pick('command', 0)
  503. expect(begins).toHaveLength(0)
  504. expect(inserts).toHaveLength(0)
  505. expect(controller.menu.getSnapshot().open).toBe(false)
  506. })
  507. it('closed menu / vanished candidate picks are no-ops', async () => {
  508. const { controller, cmd } = pickBench(() => undefined)
  509. await tick()
  510. controller.pick('command', 9)
  511. controller.pick('ghost', 0)
  512. expect(cmd.picks).toHaveLength(0)
  513. expect(controller.menu.getSnapshot().open).toBe(true)
  514. })
  515. })
  516. describe('lexicon', () => {
  517. function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource {
  518. return {
  519. trigger,
  520. name,
  521. candidates: () => Promise.resolve([]),
  522. onPick: () => undefined,
  523. ...(hasHook ? { lexicon: () => roll } : {}),
  524. }
  525. }
  526. it('aggregates hook-implementing sources by trigger with the session projection; hookless ones are skipped', () => {
  527. const seen: unknown[] = []
  528. const skill: InputTriggerSource = {
  529. trigger: '/',
  530. name: 'skill',
  531. candidates: () => Promise.resolve([]),
  532. onPick: () => undefined,
  533. lexicon: (projection) => {
  534. seen.push(projection)
  535. return ['commit-helper', 'review']
  536. },
  537. }
  538. const { controller } = controllerBench([
  539. lexSource('/', 'command', undefined, false), // no hook: not polled
  540. skill,
  541. lexSource('@', 'subagent', ['worker-1']),
  542. ])
  543. const rolls = controller.lexicon.getSnapshot()
  544. expect([...rolls.keys()]).toEqual(['/', '@'])
  545. expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
  546. expect(rolls.get('@')).toEqual(['worker-1'])
  547. expect(seen).toEqual([{ sessionId: sid('a') }])
  548. })
  549. it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
  550. const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
  551. expect(controller.lexicon.getSnapshot().size).toBe(0)
  552. })
  553. it('two sources on one trigger concatenate in registration order', () => {
  554. const { controller } = controllerBench([
  555. lexSource('/', 'skill', ['b', 'a']),
  556. lexSource('/', 'prompt', ['c']),
  557. lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
  558. ])
  559. const rolls = controller.lexicon.getSnapshot()
  560. expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
  561. expect(rolls.has('@')).toBe(false)
  562. })
  563. it('a source lexicon notification republishes the roll and refreshes an open menu', async () => {
  564. let roll: readonly string[] | undefined = ['old']
  565. let notify: (() => void) | undefined
  566. const source: InputTriggerSource = {
  567. trigger: '/',
  568. name: 'skill',
  569. candidates: () => Promise.resolve((roll ?? []).map(name => ({ name }))),
  570. onPick: () => undefined,
  571. lexicon: () => roll,
  572. subscribeLexicon: (_session, listener) => {
  573. notify = listener
  574. return () => { notify = undefined }
  575. },
  576. }
  577. const { controller } = controllerBench([source])
  578. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['old'])
  579. controller.track('/', 1, { tier: 'plain' }, 1)
  580. await tick()
  581. expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'old' }])
  582. const seen: number[] = []
  583. controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
  584. roll = ['commit-helper']
  585. notify?.()
  586. await tick()
  587. await tick()
  588. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
  589. expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'commit-helper' }])
  590. expect(seen).toEqual([1])
  591. controller.dispose()
  592. expect(notify).toBeUndefined()
  593. })
  594. it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
  595. const { controller, sources } = controllerBench([])
  596. expect(controller.lexicon.getSnapshot().size).toBe(0)
  597. const warm = vi.fn()
  598. const late: InputTriggerSource = {
  599. trigger: '/',
  600. name: 'late',
  601. candidates: () => Promise.resolve([]),
  602. onPick: () => undefined,
  603. warm,
  604. lexicon: () => ['fresh'],
  605. }
  606. sources.push(late)
  607. controller.sourceAdded(late)
  608. expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
  609. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
  610. })
  611. it('a removed source leaves the aggregated lexicon', () => {
  612. const src = lexSource('/', 'skill', ['gone'])
  613. const { controller, sources } = controllerBench([src])
  614. expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
  615. sources.splice(sources.indexOf(src), 1)
  616. controller.sourceRemoved(src)
  617. expect(controller.lexicon.getSnapshot().size).toBe(0)
  618. })
  619. })
  620. describe('arbitrate', () => {
  621. async function menuBench() {
  622. const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined)
  623. const { controller } = controllerBench([cmd.source])
  624. controller.track('/g', 2, { tier: 'plain' }, 1)
  625. await tick()
  626. return { controller, cmd }
  627. }
  628. it('up/down move the highlight and are consumed', async () => {
  629. const { controller } = await menuBench()
  630. expect(controller.arbitrate('down', false)).toBe('consumed')
  631. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 })
  632. expect(controller.arbitrate('up', false)).toBe('consumed')
  633. expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
  634. })
  635. it('enter picks the highlight through the pipeline', async () => {
  636. const { controller, cmd } = await menuBench()
  637. expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
  638. expect(cmd.picks[0]!.candidate.name).toBe('goal')
  639. expect(controller.menu.getSnapshot().open).toBe(false)
  640. })
  641. it('escape closes and consumes', async () => {
  642. const { controller } = await menuBench()
  643. expect(controller.arbitrate('escape', false)).toBe('consumed')
  644. expect(controller.menu.getSnapshot().open).toBe(false)
  645. })
  646. it('IME composition passes every key untouched', async () => {
  647. const { controller } = await menuBench()
  648. for (const key of ['up', 'down', 'enter', 'escape'] as const) {
  649. expect(controller.arbitrate(key, true)).toBe('pass')
  650. }
  651. expect(controller.menu.getSnapshot().open).toBe(true)
  652. })
  653. it('closed menu passes; an open menu without a highlight passes enter', () => {
  654. const cmd = deferredSource('/', 'command')
  655. const { controller } = controllerBench([cmd.source])
  656. expect(controller.arbitrate('enter', false)).toBe('pass')
  657. // Open with the only group still pending: nothing to pick yet.
  658. controller.track('/g', 2, { tier: 'plain' }, 1)
  659. expect(controller.arbitrate('enter', false)).toBe('pass')
  660. })
  661. })
  662. describe('onSpace', () => {
  663. function spaceSource(name: string, answer: PickOutcome, calls: string[]): InputTriggerSource {
  664. return {
  665. trigger: '/',
  666. name,
  667. candidates: () => Promise.resolve([]),
  668. onPick: () => undefined,
  669. matchSpace: (_session, token) => {
  670. calls.push(`${name}:${token}`)
  671. return answer
  672. },
  673. }
  674. }
  675. it('polls matchSpace in registration order; the first non-undefined wins and true = applied', () => {
  676. const calls: string[] = []
  677. const claim = claimOf('/goal ')
  678. const { controller, actx } = controllerBench([
  679. // Hook-less source: never polled, so it must not shadow the order below.
  680. { trigger: '/', name: 'nohook', candidates: () => Promise.resolve([]), onPick: () => undefined },
  681. spaceSource('first', undefined, calls),
  682. spaceSource('second', { claim }, calls),
  683. spaceSource('third', { claim: claimOf('/x ') }, calls),
  684. ])
  685. const begins: BeginCommandRequest[] = []
  686. actx.on('slash/input-begin-command', (req) => {
  687. begins.push(req)
  688. return true
  689. })
  690. controller.track('/goal', 5, { tier: 'plain' }, 1)
  691. expect(controller.onSpace()).toBe(true)
  692. expect(calls).toEqual(['first:/goal', 'second:/goal'])
  693. expect(begins).toEqual([{ claim, span: { start: 0, end: 5, draftRev: 1 } }])
  694. })
  695. it('answers false when the input declines the claim; handled outcomes are true without a dispatch', () => {
  696. const calls: string[] = []
  697. const declined = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  698. declined.actx.on('slash/input-begin-command', () => undefined)
  699. declined.controller.track('/goal', 5, { tier: 'plain' }, 1)
  700. expect(declined.controller.onSpace()).toBe(false)
  701. const handled = controllerBench([spaceSource('command', 'handled', calls)])
  702. const begins: BeginCommandRequest[] = []
  703. handled.actx.on('slash/input-begin-command', (req) => {
  704. begins.push(req)
  705. return true
  706. })
  707. handled.controller.track('/goal', 5, { tier: 'plain' }, 1)
  708. expect(handled.controller.onSpace()).toBe(true)
  709. expect(begins).toHaveLength(0)
  710. })
  711. it('answers false off a non-leading hit or with no tracked hit', () => {
  712. const calls: string[] = []
  713. const { controller } = controllerBench([spaceSource('command', { claim: claimOf('/goal ') }, calls)])
  714. expect(controller.onSpace()).toBe(false)
  715. controller.track('say /goal', 9, { tier: 'plain' }, 1)
  716. expect(controller.onSpace()).toBe(false)
  717. expect(calls).toEqual([])
  718. })
  719. })
  720. describe('adjudicate', () => {
  721. const enterSource = (
  722. trigger: TriggerChar, name: string,
  723. matchEnter?: InputTriggerSource['matchEnter'],
  724. ): InputTriggerSource => ({
  725. trigger,
  726. name,
  727. candidates: () => Promise.resolve([]),
  728. onPick: () => undefined,
  729. ...(matchEnter !== undefined ? { matchEnter } : {}),
  730. })
  731. it('polls matchEnter in registration order with the projection and full line; first non-undefined wins', async () => {
  732. const calls: string[] = []
  733. const claim = claimOf('/goal ')
  734. const { controller } = controllerBench([
  735. enterSource('/', 'silent'),
  736. enterSource('/', 'first', (session, line) => {
  737. expect(session).toEqual({ sessionId: sid('a') })
  738. calls.push(`first:${line}`)
  739. return Promise.resolve(undefined)
  740. }),
  741. enterSource('/', 'second', (_session, line) => {
  742. calls.push(`second:${line}`)
  743. return Promise.resolve({ claim })
  744. }),
  745. enterSource('/', 'third', () => {
  746. calls.push('third')
  747. return Promise.resolve('handled')
  748. }),
  749. ])
  750. const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 })
  751. expect(result).toEqual({ claim })
  752. expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
  753. })
  754. it('skips sources of another trigger; all-undefined answers undefined', async () => {
  755. const atHook = vi.fn(() => Promise.resolve('handled' as const))
  756. const { controller } = controllerBench([
  757. enterSource('@', 'subagent', atHook),
  758. enterSource('/', 'command', () => Promise.resolve(undefined)),
  759. ])
  760. await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined()
  761. expect(atHook).not.toHaveBeenCalled()
  762. })
  763. it('forwards the caller envelope to every polled matchEnter unchanged', async () => {
  764. const envelopes: unknown[] = []
  765. const { controller } = controllerBench([
  766. enterSource('/', 'first', (_session, _line, _signal, envelope) => {
  767. envelopes.push(envelope)
  768. return Promise.resolve(undefined)
  769. }),
  770. enterSource('/', 'second', (_session, _line, _signal, envelope) => {
  771. envelopes.push(envelope)
  772. return Promise.resolve('handled')
  773. }),
  774. ])
  775. const envelope = { images: 2 }
  776. await controller.adjudicate('/goal', new AbortController().signal, envelope)
  777. expect(envelopes).toEqual([envelope, envelope])
  778. expect(envelopes[0]).toBe(envelope)
  779. })
  780. it('a rejecting source rejects the whole adjudication', async () => {
  781. const { controller } = controllerBench([
  782. enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
  783. enterSource('/', 'late', () => Promise.resolve('handled')),
  784. ])
  785. await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 }))
  786. .rejects.toThrow('warmup failed')
  787. })
  788. it('an aborted attempt signal stops the poll', async () => {
  789. const hook = vi.fn(() => Promise.resolve(undefined))
  790. const { controller } = controllerBench([enterSource('/', 'command', hook)])
  791. const abort = new AbortController()
  792. abort.abort(new Error('attempt released'))
  793. await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released')
  794. expect(hook).not.toHaveBeenCalled()
  795. })
  796. })