service.client.spec.ts 37 KB

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