service.client.spec.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. /**
  2. * CommandService tests on a real cordis Context with fake slash/connection
  3. * faces and real session scopes (createScope): session-keyed candidate
  4. * synthesis (host catalog by sessionId + contributions by availability,
  5. * collision fail-loud), the dispatch decision table cell by cell, matchSpace
  6. * hot-key policy, matchEnter strong-wait / reject, the sessionId execute
  7. * payload, the scoped consume-token dispatch, per-session popupFor
  8. * lifecycle, and the directory invalidation event subscriptions.
  9. */
  10. import { Context } from '@deepseek-ai/cordis'
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
  13. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  14. import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
  15. import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
  16. import type { CommandDescriptor } from '../src/client/directory.ts'
  17. import { CommandService } from '../src/client/service.ts'
  18. const sid = (k: string): SessionId => k as SessionId
  19. /** The agent-backed session projection (single state; identity only). */
  20. const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
  21. const S1_CMDS: CommandDescriptor[] = [
  22. { name: 'plan', description: 'bare kind' },
  23. { name: 'goal', description: 'leadingInput kind', input: { hint: 'goal text' } },
  24. ]
  25. const S2_CMDS: CommandDescriptor[] = [
  26. ...S1_CMDS,
  27. { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
  28. ]
  29. type ExecuteValue = { matched: boolean; commandId?: string }
  30. interface BenchOptions {
  31. /** Scripted catalog per list payload; default serves the fixed catalogs by session. */
  32. commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
  33. execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
  34. addressed?: SessionId
  35. }
  36. /**
  37. * Fold one programmed answer into the generated Remote face's outcome: a
  38. * resolved value is the ok branch, a rejection is the transport failure the
  39. * carrier reports in the error branch instead of throwing at the caller.
  40. * @param produce - the scripted answer for one Remote method.
  41. * @returns the carried result the service reads.
  42. */
  43. async function carried<T>(produce: () => Promise<T>) {
  44. try {
  45. return { ok: true as const, value: await produce() }
  46. } catch (error) {
  47. return {
  48. ok: false as const,
  49. error: {
  50. code: 'internal',
  51. message: error instanceof Error ? error.message : String(error),
  52. details: {},
  53. },
  54. }
  55. }
  56. }
  57. async function bench(opts: BenchOptions = {}) {
  58. const ctx = new Context()
  59. const registered = new Map<string, SlashSource>()
  60. const listCalls: Array<{ sessionId: SessionId }> = []
  61. const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
  62. // The service reads the generated commands Remote, which delivers the
  63. // carrier's outcome, so a programmed failure answers the error branch.
  64. const commandsRemote = {
  65. list: async (sessionId: SessionId) => {
  66. listCalls.push({ sessionId })
  67. return await carried(async () => {
  68. const value = await (opts.commands ?? (p => Promise.resolve({
  69. commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
  70. })))({ sessionId })
  71. return value.commands
  72. })
  73. },
  74. execute: async (sessionId: SessionId, line: string) => {
  75. executeCalls.push({ sessionId, line })
  76. return await carried(async () => {
  77. const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
  78. const value = await (opts.execute ?? fallback)({ sessionId, line })
  79. return value.matched
  80. ? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } }
  81. : undefined
  82. })
  83. },
  84. }
  85. ctx.provide('slash', {
  86. registerSource(src: SlashSource) {
  87. const key = `${src.trigger} ${src.name}`
  88. registered.set(key, src)
  89. return () => { registered.delete(key) }
  90. },
  91. })
  92. // Real scope tags behind a fake sessions face.
  93. const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
  94. ctx.provide('sessions', {
  95. scope: (id: SessionId) => scopes.get(id)?.ctx,
  96. scopeOf: (c: Context) => scopeOf(c),
  97. subagentAddress: (id: SessionId) => id === opts.addressed
  98. ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
  99. : undefined,
  100. })
  101. const forwarded = new Map<string, Array<(...args: never[]) => void>>()
  102. ctx.provide('remote', {
  103. commands: commandsRemote,
  104. $on: (event: string, listener: (...args: never[]) => void) => {
  105. const listeners = forwarded.get(event) ?? []
  106. listeners.push(listener)
  107. forwarded.set(event, listeners)
  108. return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) }
  109. },
  110. $dispatch: (event: string, args: readonly unknown[]) => {
  111. for (const listener of forwarded.get(event) ?? []) listener(...args as never[])
  112. },
  113. })
  114. ctx.provide('remote.commands', commandsRemote)
  115. /** Notices the fake conversation face collected (runDetached routing). */
  116. const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
  117. ctx.provide('conversation', {
  118. input: {
  119. for: (actx: Context) => ({
  120. notify: (level: 'info' | 'error', text: string) => {
  121. notices.push({ scope: scopeOf(actx), level, text })
  122. },
  123. }),
  124. },
  125. })
  126. const fiber = ctx.plugin(CommandService)
  127. await fiber.await()
  128. const command = ctx.get('command') as CommandService
  129. const source = registered.get('/ command')
  130. if (source === undefined) throw new Error('command source not registered')
  131. const mint = (key: string) => {
  132. const handle = createScope(ctx, sid(key))
  133. scopes.set(sid(key), handle)
  134. return handle
  135. }
  136. /** Warm one session's catalog through the source's own candidate pull. */
  137. const warm = async (session: ClientSessionContext) => {
  138. await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
  139. }
  140. return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices }
  141. }
  142. function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) {
  143. const pick: SlashPick = {
  144. candidate: { name },
  145. session,
  146. position: 'leading',
  147. via: 'menu',
  148. span: { start: 0, end: end ?? name.length + 1, draftRev: 3 },
  149. }
  150. return source.onPick(pick)
  151. }
  152. const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
  153. kind: 'popupSelect',
  154. options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
  155. onSelect: () => undefined,
  156. ...over,
  157. })
  158. const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
  159. name: 'theme',
  160. description: 'client popup kind',
  161. available: () => true,
  162. ui: themeUi(),
  163. ...over,
  164. })
  165. const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
  166. ({ query, position, signal: new AbortController().signal })
  167. describe('registration', () => {
  168. it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
  169. const { registered, source, fiber } = await bench()
  170. expect(typeof source.matchSpace).toBe('function')
  171. expect(typeof source.matchEnter).toBe('function')
  172. expect(typeof source.warm).toBe('function')
  173. expect([...registered.keys()]).toEqual(['/ command'])
  174. await fiber.dispose()
  175. expect(registered.size).toBe(0)
  176. })
  177. it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
  178. const { source, listCalls } = await bench()
  179. source.warm!(proj('s1'))
  180. expect(listCalls).toEqual([{ sessionId: sid('s1') }])
  181. source.warm!(proj('s2'))
  182. expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
  183. source.warm!(proj('s1')) // s1 already pending → no duplicate pull
  184. expect(listCalls).toHaveLength(2)
  185. })
  186. })
  187. describe('candidates', () => {
  188. it('does not fetch Agent-bound commands for an addressed child', async () => {
  189. const b = await bench({ addressed: sid('child') })
  190. await expect(b.warm(proj('child'))).resolves.toBeUndefined()
  191. expect(b.listCalls).toEqual([])
  192. })
  193. it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => {
  194. const { source, listCalls } = await bench()
  195. const list = await source.candidates(proj('s1'), req('g'))
  196. expect(listCalls).toEqual([{ sessionId: sid('s1') }])
  197. expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
  198. })
  199. it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => {
  200. const commands: CommandDescriptor[] = [
  201. { name: 'q-xylophone', description: '' },
  202. { name: 'qx-long', description: '' },
  203. { name: 'fabulous', description: '' },
  204. { name: 'foo-bar', description: '' },
  205. { name: 'zuv', description: '' },
  206. { name: 'zu1v', description: '' },
  207. { name: 'yu1v', description: '' },
  208. { name: 'zu12v', description: '' },
  209. ]
  210. const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
  211. const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
  212. await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone'])
  213. await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous'])
  214. await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v'])
  215. await expect(names('zzz')).resolves.toEqual([])
  216. await expect(names('query-longer-than-every-name')).resolves.toEqual([])
  217. })
  218. it('catalogs are per session: another session pulls its own key', async () => {
  219. const { source, listCalls } = await bench()
  220. const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
  221. expect(listCalls).toEqual([{ sessionId: sid('s2') }])
  222. expect(names).toEqual(['plan', 'goal', 'attach'])
  223. })
  224. it('hides leadingInput commands at inline position', async () => {
  225. const { source } = await bench()
  226. const names = (await source.candidates(proj('s1'), req('', 'inline'))).map(c => c.name)
  227. expect(names).toEqual(['plan'])
  228. })
  229. it('merges available contributions and filters unavailable ones with the per-call projection', async () => {
  230. const { command, source } = await bench()
  231. const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
  232. command.register(themeContribution({ available }))
  233. const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
  234. expect(s1Names).toEqual(['plan', 'goal', 'theme'])
  235. expect(available).toHaveBeenLastCalledWith(proj('s1'))
  236. const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
  237. expect(s2Names).not.toContain('theme')
  238. })
  239. it('contribution rows ride the same fuzzy query filter', async () => {
  240. const { command, source } = await bench()
  241. command.register(themeContribution())
  242. const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name)
  243. expect(names).toEqual(['theme'])
  244. })
  245. it('a contribution/host name collision fails loud', async () => {
  246. const { command, source } = await bench()
  247. command.register(themeContribution({ name: 'plan' }))
  248. await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
  249. })
  250. })
  251. describe('decorations (bare-invocation UI on host commands)', () => {
  252. const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
  253. name: 'goal',
  254. available: () => true,
  255. ui: themeUi(),
  256. ...over,
  257. })
  258. it('adds no catalog row: the host row stands alone', async () => {
  259. const { command, source } = await bench()
  260. command.decorate(goalDecoration())
  261. const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
  262. expect(names).toEqual(['plan', 'goal'])
  263. })
  264. it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
  265. const { command, source, mint, warm } = await bench()
  266. command.decorate(goalDecoration())
  267. const scope = mint('s1')
  268. await warm(proj('s1'))
  269. expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
  270. expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
  271. const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
  272. if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
  273. expect(argued.claim.token).toBe('/goal ')
  274. })
  275. it('space never consults the decoration (host claim)', async () => {
  276. const { command, source, warm } = await bench()
  277. command.decorate(goalDecoration())
  278. await warm(proj('s1'))
  279. const outcome = source.matchSpace!(proj('s1'), '/goal')
  280. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
  281. expect(outcome.claim.token).toBe('/goal ')
  282. })
  283. it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
  284. const { command, source, mint, warm } = await bench()
  285. command.decorate(goalDecoration({ name: 'phantom' }))
  286. const scope = mint('s1')
  287. await warm(proj('s1'))
  288. expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
  289. expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
  290. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  291. })
  292. it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
  293. const { command, source, warm, executeCalls } = await bench()
  294. command.decorate(goalDecoration({ name: 'plan', available: () => false }))
  295. await warm(proj('s1'))
  296. expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
  297. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
  298. })
  299. it('duplicate decoration names fail loud', async () => {
  300. const { command } = await bench()
  301. command.decorate(goalDecoration())
  302. expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
  303. })
  304. })
  305. describe('dispatch (menu column)', () => {
  306. it('contribution → opens the session popup with the open-time projection, no execute', async () => {
  307. const { command, source, mint, warm, executeCalls } = await bench()
  308. const options = vi.fn((_s: ClientSessionContext) => Promise.resolve([{ id: 'dark', label: 'Dark' }]))
  309. command.register(themeContribution({ ui: themeUi({ options }) }))
  310. const scope = mint('s1')
  311. await warm(proj('s1'))
  312. expect(menuPick(source, 'theme', proj('s1'))).toBe('handled')
  313. const popup = command.popupFor(scope.ctx)
  314. expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme' })
  315. expect(options).toHaveBeenCalledExactlyOnceWith(proj('s1'), expect.any(AbortSignal))
  316. expect(executeCalls).toEqual([])
  317. })
  318. it('an unavailable contribution falls through to the host catalog', async () => {
  319. const { command, source, mint, warm } = await bench()
  320. command.register(themeContribution({ available: () => false }))
  321. const scope = mint('s1')
  322. await warm(proj('s1'))
  323. expect(menuPick(source, 'theme', proj('s1'))).toBeUndefined() // no host 'theme' either
  324. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  325. })
  326. it('host leadingInput → {claim} with token "/name " and hint; claiming never executes', async () => {
  327. const { source, warm, executeCalls } = await bench()
  328. await warm(proj('s1'))
  329. const outcome = menuPick(source, 'goal', proj('s1'))
  330. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  331. expect(outcome.claim.token).toBe('/goal ')
  332. expect(outcome.claim.hint).toBe('goal text')
  333. expect(executeCalls).toEqual([])
  334. })
  335. it('host bare → consume-token span guard on the session scope + detached execute', async () => {
  336. const { source, mint, warm, executeCalls } = await bench()
  337. const scope = mint('s1')
  338. const consumes: ConsumeTokenRequest[] = []
  339. scope.ctx.on('slash/input-consume-token', (r) => {
  340. consumes.push(r)
  341. return true
  342. })
  343. await warm(proj('s1'))
  344. expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
  345. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
  346. await Promise.resolve()
  347. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
  348. })
  349. it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
  350. const { source, warm } = await bench()
  351. await warm(proj('s1'))
  352. expect(menuPick(source, 'gone', proj('s1'))).toBeUndefined()
  353. })
  354. })
  355. describe('matchSpace (space column)', () => {
  356. it('answers undefined from a not-ready key (no waiting, no RPC)', async () => {
  357. const { source, listCalls } = await bench()
  358. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  359. expect(listCalls).toEqual([])
  360. })
  361. it('hot leadingInput exact token → {claim}; the key axis is the session', async () => {
  362. const { source, warm } = await bench()
  363. await warm(proj('s2'))
  364. const outcome = source.matchSpace!(proj('s2'), '/attach')
  365. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  366. expect(outcome.claim.token).toBe('/attach ')
  367. // s1's key is still cold: the same token answers undefined there.
  368. expect(source.matchSpace!(proj('s1'), '/attach')).toBeUndefined()
  369. })
  370. it('bare kind and contribution names stay plain text', async () => {
  371. const { command, source, warm } = await bench()
  372. command.register(themeContribution())
  373. await warm(proj('s1'))
  374. expect(source.matchSpace!(proj('s1'), '/plan')).toBeUndefined()
  375. expect(source.matchSpace!(proj('s1'), '/theme')).toBeUndefined()
  376. })
  377. it('unknown token / non-slash token → undefined', async () => {
  378. const { source, warm } = await bench()
  379. await warm(proj('s1'))
  380. expect(source.matchSpace!(proj('s1'), '/nope')).toBeUndefined()
  381. expect(source.matchSpace!(proj('s1'), 'plan')).toBeUndefined()
  382. })
  383. })
  384. describe('matchEnter (enter column)', () => {
  385. const signal = () => new AbortController().signal
  386. it('strong-waits a cold key before adjudicating', async () => {
  387. let release!: (value: { commands: CommandDescriptor[] }) => void
  388. const { source } = await bench({
  389. commands: () => new Promise((resolve) => { release = resolve }),
  390. })
  391. const wait = source.matchEnter!(proj('s1'), '/goal args', signal())
  392. release({ commands: S1_CMDS })
  393. const outcome = await wait
  394. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  395. expect(outcome.claim.token).toBe('/goal ')
  396. })
  397. it('rejects when warmup fails (never a silent downgrade)', async () => {
  398. const { source } = await bench({
  399. commands: () => Promise.reject(new Error('warmup boom')),
  400. })
  401. await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
  402. })
  403. it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
  404. const { source, warm } = await bench()
  405. await warm(proj('s1'))
  406. for (const line of ['/goal', '/goal refactor the loop']) {
  407. const outcome = await source.matchEnter!(proj('s1'), line, signal())
  408. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  409. expect(outcome.claim.token).toBe('/goal ')
  410. }
  411. })
  412. it('bare host command executes detached with the bare-token consume guard', async () => {
  413. const { source, mint, warm, executeCalls } = await bench()
  414. const scope = mint('s1')
  415. const consumes: ConsumeTokenRequest[] = []
  416. scope.ctx.on('slash/input-consume-token', (r) => {
  417. consumes.push(r)
  418. return true
  419. })
  420. await warm(proj('s1'))
  421. await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled')
  422. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
  423. await Promise.resolve()
  424. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
  425. })
  426. it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
  427. const { source, warm, executeCalls } = await bench()
  428. await warm(proj('s1'))
  429. await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined()
  430. expect(executeCalls).toEqual([])
  431. })
  432. it('contribution: bare token opens the popup without touching the directory; args → undefined', async () => {
  433. const { command, source, mint, listCalls } = await bench()
  434. command.register(themeContribution())
  435. const scope = mint('s1')
  436. await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled')
  437. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
  438. expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
  439. await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined()
  440. })
  441. it('unknown name, bare "/", and non-slash lines → undefined', async () => {
  442. const { source, warm } = await bench()
  443. await warm(proj('s1'))
  444. await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined()
  445. await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined()
  446. await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined()
  447. })
  448. })
  449. describe('execute payload', () => {
  450. it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
  451. const { source, warm, executeCalls } = await bench({
  452. execute: () => Promise.resolve({ matched: true }),
  453. })
  454. await warm(proj('s1'))
  455. const outcome = source.matchSpace!(proj('s1'), '/goal')
  456. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  457. const settled = await outcome.claim.submit('ship it', new Context())
  458. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
  459. // Pure admission: no outcome text ever rides the submit result — the
  460. // durable command lifecycle events render the outcome in the flow.
  461. expect(settled).toEqual({ kind: 'success' })
  462. })
  463. it('maps matched:false to an error outcome and a matched bare result to success', async () => {
  464. const claimOf = async (opts: BenchOptions) => {
  465. const b = await bench(opts)
  466. await b.warm(proj('s1'))
  467. const outcome = b.source.matchSpace!(proj('s1'), '/goal')
  468. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  469. return outcome.claim
  470. }
  471. const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
  472. const bad = await first.submit('x', new Context())
  473. expect(bad.kind).toBe('error')
  474. const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
  475. await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' })
  476. })
  477. })
  478. describe('detached admission notices', () => {
  479. const flush = () => new Promise(resolve => setTimeout(resolve, 0))
  480. it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
  481. let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
  482. const { source, mint, warm, notices } = await bench({
  483. execute: () => {
  484. if (mode === 'reject') return Promise.reject(new Error('network down'))
  485. return Promise.resolve({ matched: mode === 'admitted' })
  486. },
  487. })
  488. mint('s1')
  489. await warm(proj('s1'))
  490. // Admitted: the durable lifecycle events own the outcome — no notice.
  491. menuPick(source, 'plan', proj('s1'))
  492. await flush()
  493. expect(notices).toEqual([])
  494. // Admission miss (matched:false): immediate composer feedback stays.
  495. mode = 'miss'
  496. await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
  497. await flush()
  498. expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
  499. notices.length = 0
  500. mode = 'reject'
  501. menuPick(source, 'plan', proj('s1'))
  502. await flush()
  503. // A dead Remote call and a rejected one now read alike: both arrive as a
  504. // failed result, so the notice names the endpoint either way.
  505. expect(notices).toEqual([{
  506. scope: sid('s1'),
  507. level: 'error',
  508. text: 'command.execute failed: internal: network down',
  509. }])
  510. })
  511. it('a torn-down scope drops the failure notice', async () => {
  512. const { source, warm, notices } = await bench({
  513. execute: () => Promise.reject(new Error('orphan failure')),
  514. })
  515. await warm(proj('ghost')) // never minted: scopeFor misses
  516. menuPick(source, 'plan', proj('ghost'))
  517. await flush()
  518. expect(notices).toEqual([])
  519. })
  520. })
  521. describe('register (contribution face)', () => {
  522. it('duplicate registration throws; the disposer frees the name', async () => {
  523. const { command } = await bench()
  524. const dispose = command.register(themeContribution())
  525. expect(() => command.register(themeContribution())).toThrow('duplicate contribution')
  526. dispose()
  527. command.register(themeContribution())()
  528. })
  529. })
  530. describe('popupFor', () => {
  531. it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
  532. const { ctx, command, mint } = await bench()
  533. const a = mint('s1')
  534. const first = command.popupFor(a.ctx)
  535. expect(command.popupFor(a.ctx)).toBe(first)
  536. expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
  537. expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
  538. })
  539. it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => {
  540. const { command, source, mint } = await bench()
  541. const onSelect = vi.fn()
  542. command.register(themeContribution({ ui: themeUi({ onSelect }) }))
  543. const scope = mint('s1')
  544. const consumes: ConsumeTokenRequest[] = []
  545. scope.ctx.on('slash/input-consume-token', (r) => {
  546. consumes.push(r)
  547. return true
  548. })
  549. const focus = vi.fn()
  550. command.bindComposerFocus(sid('s1'), focus)
  551. expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
  552. const popup = command.popupFor(scope.ctx)
  553. await Promise.resolve() // options land
  554. await popup.select(0)
  555. expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
  556. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
  557. expect(focus).toHaveBeenCalledTimes(1)
  558. })
  559. it('the enter path opens with the bare-token guard', async () => {
  560. const { command, source, mint } = await bench()
  561. command.register(themeContribution())
  562. const scope = mint('s1')
  563. const consumes: ConsumeTokenRequest[] = []
  564. scope.ctx.on('slash/input-consume-token', (r) => {
  565. consumes.push(r)
  566. return true
  567. })
  568. await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
  569. const popup = command.popupFor(scope.ctx)
  570. await Promise.resolve()
  571. await popup.select(0)
  572. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/theme' } }])
  573. })
  574. it('the scope disposer disposes the controller and a re-mint resolves fresh', async () => {
  575. const { command, source, mint } = await bench()
  576. command.register(themeContribution())
  577. const scope = mint('s1')
  578. await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
  579. const popup = command.popupFor(scope.ctx)
  580. expect(popup.state.getSnapshot().open).toBe(true)
  581. await scope.fiber.dispose()
  582. expect(popup.state.getSnapshot().open).toBe(false)
  583. expect(command.popupFor(mint('s1').ctx)).not.toBe(popup)
  584. })
  585. })
  586. describe('directory invalidation events', () => {
  587. it('commands/change repulls in the background while the old snapshot serves', async () => {
  588. let round = 0
  589. const { ctx, source, warm } = await bench({
  590. commands: () => {
  591. round += 1
  592. return Promise.resolve({
  593. commands: round === 1
  594. ? S1_CMDS
  595. : [{ name: 'fresh', description: '', input: { hint: 'h' } }],
  596. })
  597. },
  598. })
  599. await warm(proj('s1'))
  600. ctx.remote.$dispatch('commands/change', [])
  601. await new Promise(resolve => setTimeout(resolve, 0))
  602. expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
  603. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  604. })
  605. it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
  606. const rounds = new Map<SessionId, number>()
  607. const { ctx, source, warm } = await bench({
  608. commands: (payload) => {
  609. const round = (rounds.get(payload.sessionId) ?? 0) + 1
  610. rounds.set(payload.sessionId, round)
  611. return Promise.resolve({
  612. commands: round === 1
  613. ? S1_CMDS
  614. : [{ name: 'fresh', description: '', input: { hint: 'h' } }],
  615. })
  616. },
  617. })
  618. await warm(proj('s1'))
  619. await warm(proj('s2'))
  620. // A preset switch changes which commands one session's agent resolves;
  621. // every other session keeps the catalog its own composition serves.
  622. ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
  623. await new Promise(resolve => setTimeout(resolve, 0))
  624. expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
  625. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  626. expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
  627. })
  628. it('connection/reset hard-drops every session key until its rewarm lands', async () => {
  629. let block = false
  630. let release!: (value: { commands: CommandDescriptor[] }) => void
  631. const { ctx, source, warm } = await bench({
  632. commands: () => (block
  633. ? new Promise((resolve) => { release = resolve })
  634. : Promise.resolve({ commands: S2_CMDS })),
  635. })
  636. await warm(proj('s2'))
  637. expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
  638. block = true
  639. ctx.emit('connection/reset')
  640. // Hard reset: silent until the rewarm lands.
  641. expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
  642. release({ commands: S2_CMDS })
  643. await new Promise(resolve => setTimeout(resolve, 0))
  644. expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
  645. })
  646. })