service.client.spec.ts 54 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  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, onTestFinished, vi } from 'vitest'
  12. import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
  13. import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
  14. import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
  15. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  16. import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  17. import { IconGoalOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
  18. import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
  19. import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
  20. import type { CommandDescriptor } from '../src/client/directory.ts'
  21. import { CommandUiRuntime } from '../src/client/service.ts'
  22. import { en, zh, type CommandKey } from '../src/client/locales.ts'
  23. const sid = (k: string): SessionId => k as SessionId
  24. /** The agent-backed session projection (single state; identity only). */
  25. const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
  26. const S1_CMDS: CommandDescriptor[] = [
  27. { name: 'plan', description: 'bare kind' },
  28. { name: 'goal', description: 'leadingInput kind', input: { hint: 'goal text' } },
  29. ]
  30. const S2_CMDS: CommandDescriptor[] = [
  31. ...S1_CMDS,
  32. { name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
  33. ]
  34. type ExecuteValue = { matched: boolean; commandId?: string; result?: CommandResult }
  35. interface BenchOptions {
  36. /** Scripted catalog per list payload; default serves the fixed catalogs by session. */
  37. commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
  38. execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
  39. translate?: (namespace: string, key: string, params?: Record<string, unknown>) => string
  40. addressed?: SessionId
  41. }
  42. /**
  43. * Fold one programmed answer into the generated Remote face's outcome: a
  44. * resolved value is the ok branch, a rejection is the carrier failure the
  45. * Remote face reports in the error branch instead of throwing at the caller.
  46. * @param produce - the scripted answer for one Remote method.
  47. * @returns the carried result the service reads.
  48. */
  49. async function carried<T>(produce: () => Promise<T>) {
  50. try {
  51. return { ok: true as const, value: await produce() }
  52. } catch (error) {
  53. return {
  54. ok: false as const,
  55. error: new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {}),
  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 SubmitAttachment[] }> = []
  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 SubmitAttachment[] = []) => {
  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. opts.translate?.(ns, key, params)
  98. ?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`,
  99. })
  100. // Real scope tags behind a fake sessions face.
  101. const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
  102. const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
  103. const removeSessions = ctx.provide('sessions', {
  104. scope: (id: SessionId) => scopes.get(id)?.ctx,
  105. scopeOf: (c: Context) => scopeOf(c),
  106. sessionOf: (scopeCtx: Context) => [...bindings.values()].find(binding => binding.ctx === scopeCtx)?.session,
  107. binding: (id: SessionId) => bindings.get(id),
  108. subagentAddress: (id: SessionId) => id === opts.addressed
  109. ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
  110. : undefined,
  111. })
  112. const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote })
  113. ctx.provide('remote.commands', commandsRemote)
  114. const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
  115. ctx.on('command/executed', (sessionId, name, result) => {
  116. executions.push({ sessionId, name, result })
  117. })
  118. /** Notices the fake conversation face collected (runDetached routing). */
  119. const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
  120. /** Composer focuses the fake conversation face collected (popup dismissal). */
  121. const focuses: Array<SessionId | undefined> = []
  122. const removeConversation = ctx.provide('conversation', {
  123. input: {
  124. for: (actx: Context) => ({
  125. notify: (level: 'info' | 'error', text: string) => {
  126. notices.push({ scope: scopeOf(actx), level, text })
  127. },
  128. focus: () => { focuses.push(scopeOf(actx)) },
  129. }),
  130. },
  131. })
  132. const fiber = ctx.plugin(CommandUiRuntime)
  133. await fiber.await()
  134. const command = ctx.get('commandUi') as CommandUiRuntime
  135. const source = registered.get('/ command')
  136. if (source === undefined) throw new Error('command source not registered')
  137. const mint = (key: string) => {
  138. const id = sid(key)
  139. const handle = createScope(ctx, id)
  140. const binding = { sessionId: id, session: { sessionId: id }, ctx: handle.ctx }
  141. scopes.set(id, handle)
  142. bindings.set(id, binding)
  143. handle.ctx.effect(() => () => {
  144. if (bindings.get(id) === binding) bindings.delete(id)
  145. })
  146. return handle
  147. }
  148. /** Warm one session's catalog through the source's own candidate pull. */
  149. const warm = async (session: ClientSessionContext) => {
  150. await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
  151. }
  152. return {
  153. ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, focuses, remote,
  154. removeSessions, removeConversation,
  155. }
  156. }
  157. function menuPick(source: InputTriggerSource, name: string, session: ClientSessionContext, end?: number) {
  158. const pick: InputTriggerPick = {
  159. candidate: { name },
  160. session,
  161. position: 'leading',
  162. via: 'menu',
  163. action: 'pick',
  164. span: { start: 0, end: end ?? name.length + 1, draftRev: 3 },
  165. }
  166. return source.onPick(pick)
  167. }
  168. const themeUi = (over: Partial<PopupSelectSpec> = {}): PopupSelectSpec => ({
  169. kind: 'popupSelect',
  170. options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
  171. onSelect: () => undefined,
  172. ...over,
  173. })
  174. const themeContribution = (over: Partial<CommandContribution> = {}): CommandContribution => ({
  175. name: 'theme',
  176. description: () => 'client popup kind',
  177. available: () => true,
  178. ui: themeUi(),
  179. ...over,
  180. })
  181. const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
  182. ({ query, position, drilled: false, signal: new AbortController().signal })
  183. describe('registration', () => {
  184. it('reports missing initialization dependencies', () => {
  185. const missingLocale = new Context()
  186. expect(() => new CommandUiRuntime(missingLocale)).toThrow('locale service unavailable')
  187. const missingTriggers = new Context()
  188. missingTriggers.provide('locale', { bind: () => (key: string) => key })
  189. expect(() => new CommandUiRuntime(missingTriggers)).toThrow('slash service unavailable')
  190. })
  191. it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
  192. const { registered, source, fiber } = await bench()
  193. expect(typeof source.matchSpace).toBe('function')
  194. expect(typeof source.matchEnter).toBe('function')
  195. expect(typeof source.warm).toBe('function')
  196. expect([...registered.keys()]).toEqual(['/ command'])
  197. await fiber.dispose()
  198. expect(registered.size).toBe(0)
  199. })
  200. it('the warm hook prewarms the session key: one pull per session, no duplicate over pending', async () => {
  201. const { source, listCalls } = await bench()
  202. source.warm!(proj('s1'))
  203. expect(listCalls).toEqual([{ sessionId: sid('s1') }])
  204. source.warm!(proj('s2'))
  205. expect(listCalls).toEqual([{ sessionId: sid('s1') }, { sessionId: sid('s2') }])
  206. source.warm!(proj('s1')) // s1 already pending → no duplicate pull
  207. expect(listCalls).toHaveLength(2)
  208. })
  209. })
  210. describe('candidates', () => {
  211. it('does not fetch Agent-bound commands for an addressed child', async () => {
  212. const b = await bench({ addressed: sid('child') })
  213. await expect(b.warm(proj('child'))).resolves.toBeUndefined()
  214. expect(b.listCalls).toEqual([])
  215. })
  216. it('pulls the session catalog; fuzzy filter and hint mapping apply', async () => {
  217. const { source, listCalls } = await bench()
  218. const list = await source.candidates(proj('s1'), req('g'))
  219. expect(listCalls).toEqual([{ sessionId: sid('s1') }])
  220. expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }])
  221. })
  222. it('ranks rows through the shared name ranker: prefixes first, then alignment, then source order', async () => {
  223. const commands: CommandDescriptor[] = [
  224. { name: 'z_a_b', description: '' },
  225. { name: 'abc', description: '' },
  226. ]
  227. const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
  228. const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
  229. await expect(names('AB')).resolves.toEqual(['abc', 'z_a_b'])
  230. await expect(names('zzz')).resolves.toEqual([])
  231. })
  232. it('catalogs are per session: another session pulls its own key', async () => {
  233. const { source, listCalls } = await bench()
  234. const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
  235. expect(listCalls).toEqual([{ sessionId: sid('s2') }])
  236. expect(names).toEqual(['goal', 'plan', 'attach'])
  237. })
  238. it('hides leadingInput commands at inline position', async () => {
  239. const { source } = await bench()
  240. const names = (await source.candidates(proj('s1'), req('', 'inline'))).map(c => c.name)
  241. expect(names).toEqual(['plan'])
  242. })
  243. it('merges available contributions and filters unavailable ones with the per-call projection', async () => {
  244. const { command, source } = await bench()
  245. const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1'))
  246. command.register(themeContribution({ available }))
  247. const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
  248. expect(s1Names).toEqual(['goal', 'plan', 'theme'])
  249. expect(available).toHaveBeenLastCalledWith(proj('s1'))
  250. const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name)
  251. expect(s2Names).not.toContain('theme')
  252. })
  253. it('contribution rows ride the same fuzzy query filter', async () => {
  254. const { command, source } = await bench()
  255. command.register(themeContribution())
  256. const names = (await source.candidates(proj('s1'), req('tm'))).map(c => c.name)
  257. expect(names).toEqual(['theme'])
  258. })
  259. it('localizes canonical built-in and contribution descriptions on every candidate request', async () => {
  260. let locale = 'zh'
  261. const commands: CommandDescriptor[] = [
  262. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' },
  263. { name: 'goal', description: 'scoped goal override' },
  264. { name: 'custom', description: 'plugin-authored copy' },
  265. ]
  266. const { command, source } = await bench({
  267. commands: () => Promise.resolve({ commands }),
  268. translate: (namespace, key) => `${locale}:${namespace}:${key}`,
  269. })
  270. command.register(themeContribution({ description: () => `${locale}:theme` }))
  271. // Rows read as [name, label, description]; the empty query orders by section.
  272. const faces = async () => (await source.candidates(proj('s1'), req(''))).map(c => [c.name, c.label, c.description])
  273. await expect(faces()).resolves.toEqual([
  274. ['goal', undefined, 'scoped goal override'],
  275. ['compact', 'zh:command:label.compact', 'zh:command:description.compact'],
  276. ['custom', undefined, 'plugin-authored copy'],
  277. ['theme', undefined, 'zh:theme'],
  278. ])
  279. locale = 'en'
  280. await expect(faces()).resolves.toEqual([
  281. ['goal', undefined, 'scoped goal override'],
  282. ['compact', 'en:command:label.compact', 'en:command:description.compact'],
  283. ['custom', undefined, 'plugin-authored copy'],
  284. ['theme', undefined, 'en:theme'],
  285. ])
  286. })
  287. it('a contribution/host name collision fails loud', async () => {
  288. const { command, source } = await bench()
  289. command.register(themeContribution({ name: 'plan' }))
  290. await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
  291. })
  292. describe('menu presentation (design doc for #3567)', () => {
  293. /** First-party definitions plus an unrelated command, in Host registration order. */
  294. const SHIPPED: CommandDescriptor[] = [
  295. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' },
  296. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-session-log-export'), name: 'export', description: 'Download this Session log as a ZIP archive' },
  297. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'), name: 'feedback', description: 'Record feedback about this session', input: { hint: '<text>' } },
  298. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
  299. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
  300. { definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
  301. { name: 'deploy', description: 'third-party command' },
  302. ]
  303. const Glyph = () => null
  304. const fileContribution = (run = vi.fn()): CommandContribution => ({
  305. name: 'file',
  306. label: () => 'command:label.file',
  307. icon: Glyph,
  308. available: () => true,
  309. ui: { kind: 'action', run },
  310. })
  311. const modelContribution = (): CommandContribution => ({
  312. name: 'model',
  313. label: () => '模型',
  314. description: () => '选择本会话使用的模型',
  315. icon: Glyph,
  316. available: () => true,
  317. ui: themeUi(),
  318. })
  319. it('an empty query lists Add then Commands in usage order; built-in rows carry their localized face, other rows their own text', async () => {
  320. const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
  321. command.register(modelContribution())
  322. command.register(fileContribution())
  323. const rows = await source.candidates(proj('s1'), req(''))
  324. expect(rows.map(row => row.name)).toEqual([
  325. 'file', 'goal', 'plan', 'feedback', 'compact', 'permission', 'model', 'export', 'deploy',
  326. ])
  327. expect(rows.map(row => row.section)).toEqual([
  328. ...Array<string>(4).fill('command:section.add'),
  329. ...Array<string>(5).fill('command:section.commands'),
  330. ])
  331. expect(rows[1]).toEqual({
  332. name: 'goal',
  333. label: 'command:label.goal',
  334. description: 'command:description.goal',
  335. icon: IconGoalOutline16,
  336. hint: '<objective>',
  337. section: 'command:section.add',
  338. })
  339. expect(rows[0]).toEqual({ name: 'file', label: 'command:label.file', icon: Glyph, section: 'command:section.add' })
  340. expect(rows[6]).toMatchObject({ name: 'model', label: '模型', description: '选择本会话使用的模型', icon: Glyph })
  341. // A third-party command keeps its catalog text and gets no glyph.
  342. expect(rows[8]).toEqual({ name: 'deploy', description: 'third-party command', section: 'command:section.commands' })
  343. })
  344. it('a same-name override keeps its own presentation even when it copies the first-party description', async () => {
  345. const commands: CommandDescriptor[] = [{ name: 'goal', description: en['description.goal'], input: { hint: 'x' } }]
  346. const { source } = await bench({ commands: () => Promise.resolve({ commands }) })
  347. const [row] = await source.candidates(proj('s1'), req(''))
  348. expect(row).toEqual({ name: 'goal', description: en['description.goal'], hint: 'x', section: 'command:section.add' })
  349. expect(source.matchSpace!(proj('s1'), '/目标')).toBeUndefined()
  350. expect(await source.matchEnter!(proj('s1'), '/目标 x', new AbortController().signal, { attachments: 0 })).toBeUndefined()
  351. expect(source.matchSpace!(proj('s1'), '/goal')).toHaveProperty('claim.name', 'goal')
  352. })
  353. it.each([['en', en], ['zh', zh]] as const)('description edits preserve menu claims and bilingual parsing under %s', async (_locale, dictionary) => {
  354. const commands = SHIPPED.map(command => ({ ...command, description: command.description + '.' }))
  355. const { fiber, source, warm } = await bench({
  356. commands: () => Promise.resolve({ commands }),
  357. translate: (_namespace, key) => dictionary[key as CommandKey],
  358. })
  359. onTestFinished(() => fiber.dispose())
  360. await warm(proj('s1'))
  361. const rows = await source.candidates(proj('s1'), req(''))
  362. for (const name of ['goal', 'plan', 'feedback'] as const) {
  363. expect(rows.find(row => row.name === name)?.label).toBe(dictionary[`label.${name}`])
  364. const picked = menuPick(source, name, proj('s1'))
  365. expect(picked).toHaveProperty('claim.token', `/${dictionary[`token.${name}`]} `)
  366. for (const spelling of [en[`token.${name}`], zh[`token.${name}`]]) {
  367. expect(source.matchSpace!(proj('s1'), `/${spelling}`)).toHaveProperty('claim.name', name)
  368. expect(await source.matchEnter!(proj('s1'), `/${spelling} text`, new AbortController().signal, { attachments: 0 }))
  369. .toHaveProperty('claim.token', `/${spelling} `)
  370. }
  371. }
  372. })
  373. it('a typed query ranks rows flat by name or label and drops the section headings', async () => {
  374. const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
  375. command.register(modelContribution())
  376. const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name)
  377. await expect(names('模型')).resolves.toEqual(['model'])
  378. await expect(names('label.goal')).resolves.toEqual(['goal'])
  379. await expect(names('ex')).resolves.toEqual(['export'])
  380. // Prefix hits lead; the empty-query section order no longer applies.
  381. await expect(names('pl')).resolves.toEqual(['plan', 'deploy'])
  382. const rows = await source.candidates(proj('s1'), req('pl'))
  383. expect(rows.every(row => row.section === undefined)).toBe(true)
  384. })
  385. it('an action contribution: the menu pick consumes the span and runs it; bare enter runs it even with attachments; an argued line misses', async () => {
  386. const run = vi.fn()
  387. const { command, source, mint, warm } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
  388. command.register(fileContribution(run))
  389. const scope = mint('s1')
  390. const consumes: ConsumeTokenRequest[] = []
  391. scope.ctx.on('slash/input-consume-token', (r) => { consumes.push(r); return true })
  392. await warm(proj('s1'))
  393. expect(menuPick(source, 'file', proj('s1'))).toBe('handled')
  394. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
  395. expect(run).toHaveBeenCalledExactlyOnceWith(proj('s1'))
  396. expect(await source.matchEnter!(proj('s1'), '/file', new AbortController().signal, { attachments: 2 })).toBe('handled')
  397. expect(consumes[1]).toEqual({ guard: { kind: 'bare-token', token: '/file' } })
  398. expect(run).toHaveBeenCalledTimes(2)
  399. expect(await source.matchEnter!(proj('s1'), '/file now', new AbortController().signal, { attachments: 0 })).toBeUndefined()
  400. expect(source.matchSpace!(proj('s1'), '/file')).toBeUndefined()
  401. })
  402. it('a built-in claim shows the localized token and submits the catalog name', async () => {
  403. const { source, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
  404. await warm(proj('s1'))
  405. const outcome = menuPick(source, 'plan', proj('s1'))
  406. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the claim')
  407. expect(outcome.claim.token).toBe('/command:token.plan ')
  408. await outcome.claim.submit('do x', new Context(), [])
  409. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan do x', images: [] }])
  410. })
  411. it('a typed localized token resolves to the built-in command on space and enter; the Host line carries the catalog name', async () => {
  412. const { source, mint, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) })
  413. mint('s1')
  414. await warm(proj('s1'))
  415. const space = source.matchSpace!(proj('s1'), '/计划')
  416. if (space === undefined || space === 'handled' || !('claim' in space)) throw new Error('expected the plan claim')
  417. expect(space.claim.hint).toBe('[off|message]')
  418. // The claim keeps the typed spelling (the draft carries it and the
  419. // arguments are read after it); the submission sends the catalog name.
  420. expect(space.claim.token).toBe('/计划 ')
  421. expect(space.claim.name).toBe('plan')
  422. const enter = await source.matchEnter!(proj('s1'), '/目标 ship it', new AbortController().signal, { attachments: 0 })
  423. if (enter === undefined || enter === 'handled' || !('claim' in enter)) throw new Error('expected the goal claim')
  424. expect(enter.claim.attachments).toBe(true)
  425. expect(enter.claim.token).toBe('/目标 ')
  426. await enter.claim.submit('ship it', new Context(), [])
  427. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }])
  428. const typed = await source.matchEnter!(proj('s1'), '/plan now', new AbortController().signal, { attachments: 0 })
  429. if (typed === undefined || typed === 'handled' || !('claim' in typed)) throw new Error('expected the plan claim')
  430. expect(typed.claim.token).toBe('/plan ')
  431. executeCalls.length = 0
  432. expect(await source.matchEnter!(proj('s1'), '/压缩', new AbortController().signal, { attachments: 0 })).toBe('handled')
  433. await vi.waitFor(() => { expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/compact', images: [] }]) })
  434. })
  435. })
  436. })
  437. describe('decorations (bare-invocation UI on host commands)', () => {
  438. const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
  439. name: 'goal',
  440. available: () => true,
  441. ui: themeUi(),
  442. ...over,
  443. })
  444. it('adds no catalog row: the host row stands alone', async () => {
  445. const { command, source } = await bench()
  446. command.decorate(goalDecoration())
  447. const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
  448. expect(names).toEqual(['goal', 'plan'])
  449. })
  450. it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
  451. const { command, source, mint, warm } = await bench()
  452. command.decorate(goalDecoration())
  453. const scope = mint('s1')
  454. await warm(proj('s1'))
  455. expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { attachments: 0 })).toBe('handled')
  456. expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
  457. const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { attachments: 0 })
  458. if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
  459. expect(argued.claim.token).toBe('/goal ')
  460. command.dismiss('goal')
  461. expect(menuPick(source, 'goal', proj('s1'))).toBe('handled')
  462. expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
  463. })
  464. it('space never consults the decoration (host claim)', async () => {
  465. const { command, source, warm } = await bench()
  466. command.decorate(goalDecoration())
  467. await warm(proj('s1'))
  468. const outcome = source.matchSpace!(proj('s1'), '/goal')
  469. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
  470. expect(outcome.claim.token).toBe('/goal ')
  471. })
  472. it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
  473. const { command, source, mint, warm } = await bench()
  474. command.decorate(goalDecoration({ name: 'phantom' }))
  475. const scope = mint('s1')
  476. await warm(proj('s1'))
  477. expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { attachments: 0 })).toBeUndefined()
  478. expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
  479. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  480. })
  481. it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
  482. const { command, source, warm, executeCalls } = await bench()
  483. command.decorate(goalDecoration({ name: 'plan', available: () => false }))
  484. await warm(proj('s1'))
  485. expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })).toBe('handled')
  486. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
  487. expect(menuPick(source, 'plan', proj('s1'))).toBe('handled')
  488. expect(executeCalls).toHaveLength(2)
  489. })
  490. it('duplicate decoration names fail loud', async () => {
  491. const { command } = await bench()
  492. const dispose = command.decorate(goalDecoration())
  493. expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
  494. dispose()
  495. expect(() => { command.decorate(goalDecoration())() }).not.toThrow()
  496. })
  497. })
  498. describe('dispatch (menu column)', () => {
  499. it('contribution → opens the session popup with the open-time projection, no execute', async () => {
  500. const { command, source, mint, warm, executeCalls } = await bench()
  501. const options = vi.fn((_s: ClientSessionContext) => Promise.resolve([{ id: 'dark', label: 'Dark' }]))
  502. command.register(themeContribution({ ui: themeUi({ options }) }))
  503. const scope = mint('s1')
  504. await warm(proj('s1'))
  505. expect(menuPick(source, 'theme', proj('s1'))).toBe('handled')
  506. const popup = command.popupFor(scope.ctx)
  507. expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme' })
  508. expect(options).toHaveBeenCalledExactlyOnceWith(proj('s1'), expect.any(AbortSignal))
  509. expect(executeCalls).toEqual([])
  510. })
  511. it('an unavailable contribution falls through to the host catalog', async () => {
  512. const { command, source, mint, warm } = await bench()
  513. command.register(themeContribution({ available: () => false }))
  514. const scope = mint('s1')
  515. await warm(proj('s1'))
  516. expect(menuPick(source, 'theme', proj('s1'))).toBeUndefined() // no host 'theme' either
  517. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  518. })
  519. it('host leadingInput → {claim} with token "/name " and hint; claiming never executes', async () => {
  520. const { source, warm, executeCalls } = await bench()
  521. await warm(proj('s1'))
  522. const outcome = menuPick(source, 'goal', proj('s1'))
  523. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  524. expect(outcome.claim.token).toBe('/goal ')
  525. expect(outcome.claim.hint).toBe('goal text')
  526. expect(executeCalls).toEqual([])
  527. })
  528. it('action decoration: a menu pick consumes the span and runs the callback without executing', async () => {
  529. const { command, source, mint, warm, executeCalls } = await bench()
  530. const scope = mint('s1')
  531. const consumes: ConsumeTokenRequest[] = []
  532. scope.ctx.on('slash/input-consume-token', (r) => {
  533. consumes.push(r)
  534. return true
  535. })
  536. const run = vi.fn()
  537. command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } })
  538. await warm(proj('s1'))
  539. expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
  540. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
  541. expect(run).toHaveBeenCalledWith(proj('s1'))
  542. expect(executeCalls).toEqual([])
  543. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  544. })
  545. it('action decoration: a bare enter runs even with attachments; an argued line bypasses it', async () => {
  546. const { command, source, mint, warm } = await bench()
  547. const scope = mint('s1')
  548. const consumes: ConsumeTokenRequest[] = []
  549. scope.ctx.on('slash/input-consume-token', (r) => {
  550. consumes.push(r)
  551. return true
  552. })
  553. const run = vi.fn()
  554. command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } })
  555. await warm(proj('s1'))
  556. await expect(source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 1 })).resolves.toBe('handled')
  557. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
  558. expect(run).toHaveBeenCalledTimes(1)
  559. // An argued line never consults the decoration.
  560. await expect(source.matchEnter!(proj('s1'), '/plan later', new AbortController().signal, { attachments: 0 })).resolves.not.toBe('handled')
  561. expect(run).toHaveBeenCalledTimes(1)
  562. })
  563. it('host bare → consume-token span guard on the session scope + detached execute', async () => {
  564. const { source, mint, warm, executeCalls, executions } = await bench()
  565. const scope = mint('s1')
  566. const consumes: ConsumeTokenRequest[] = []
  567. scope.ctx.on('slash/input-consume-token', (r) => {
  568. consumes.push(r)
  569. return true
  570. })
  571. await warm(proj('s1'))
  572. expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
  573. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
  574. await vi.waitFor(() => {
  575. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
  576. expect(executions).toEqual([{
  577. sessionId: sid('s1'),
  578. name: 'plan',
  579. result: { kind: 'success' },
  580. }])
  581. })
  582. })
  583. it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => {
  584. const { source, warm } = await bench()
  585. await warm(proj('s1'))
  586. expect(menuPick(source, 'gone', proj('s1'))).toBeUndefined()
  587. })
  588. })
  589. describe('matchSpace (space column)', () => {
  590. it('answers undefined from a not-ready key (no waiting, no RPC)', async () => {
  591. const { source, listCalls } = await bench()
  592. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  593. expect(listCalls).toEqual([])
  594. })
  595. it('hot leadingInput exact token → {claim}; the key axis is the session', async () => {
  596. const { source, warm } = await bench()
  597. await warm(proj('s2'))
  598. const outcome = source.matchSpace!(proj('s2'), '/attach')
  599. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  600. expect(outcome.claim.token).toBe('/attach ')
  601. // s1's key is still cold: the same token answers undefined there.
  602. expect(source.matchSpace!(proj('s1'), '/attach')).toBeUndefined()
  603. })
  604. it('bare kind and contribution names stay plain text', async () => {
  605. const { command, source, warm } = await bench()
  606. command.register(themeContribution())
  607. await warm(proj('s1'))
  608. expect(source.matchSpace!(proj('s1'), '/plan')).toBeUndefined()
  609. expect(source.matchSpace!(proj('s1'), '/theme')).toBeUndefined()
  610. })
  611. it('unknown token / non-slash token → undefined', async () => {
  612. const { source, warm } = await bench()
  613. await warm(proj('s1'))
  614. expect(source.matchSpace!(proj('s1'), '/nope')).toBeUndefined()
  615. expect(source.matchSpace!(proj('s1'), 'plan')).toBeUndefined()
  616. })
  617. })
  618. describe('matchEnter (enter column)', () => {
  619. const signal = () => new AbortController().signal
  620. it('strong-waits a cold key before adjudicating', async () => {
  621. let release!: (value: { commands: CommandDescriptor[] }) => void
  622. const { source } = await bench({
  623. commands: () => new Promise((resolve) => { release = resolve }),
  624. })
  625. const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 })
  626. release({ commands: S1_CMDS })
  627. const outcome = await wait
  628. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  629. expect(outcome.claim.token).toBe('/goal ')
  630. })
  631. it('rejects when warmup fails (never a silent downgrade)', async () => {
  632. const { source } = await bench({
  633. commands: () => Promise.reject(new Error('warmup boom')),
  634. })
  635. await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { attachments: 0 })).rejects.toThrow('warmup boom')
  636. })
  637. it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
  638. const { source, warm } = await bench()
  639. await warm(proj('s1'))
  640. for (const line of ['/goal', '/goal refactor the loop']) {
  641. const outcome = await source.matchEnter!(proj('s1'), line, signal(), { attachments: 0 })
  642. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  643. expect(outcome.claim.token).toBe('/goal ')
  644. }
  645. })
  646. it('bare host command executes detached with the bare-token consume guard', async () => {
  647. const { source, mint, warm, executeCalls } = await bench()
  648. const scope = mint('s1')
  649. const consumes: ConsumeTokenRequest[] = []
  650. scope.ctx.on('slash/input-consume-token', (r) => {
  651. consumes.push(r)
  652. return true
  653. })
  654. await warm(proj('s1'))
  655. await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 0 })).resolves.toBe('handled')
  656. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
  657. await Promise.resolve()
  658. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
  659. })
  660. it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
  661. const { source, warm, executeCalls } = await bench()
  662. await warm(proj('s1'))
  663. await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { attachments: 0 })).resolves.toBeUndefined()
  664. expect(executeCalls).toEqual([])
  665. })
  666. it('contribution: bare token opens the popup without touching the directory; args → undefined', async () => {
  667. const { command, source, mint, listCalls } = await bench()
  668. command.register(themeContribution())
  669. const scope = mint('s1')
  670. await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 0 })).resolves.toBe('handled')
  671. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
  672. expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
  673. await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { attachments: 0 })).resolves.toBeUndefined()
  674. })
  675. it('unknown name, bare "/", and non-slash lines → undefined', async () => {
  676. const { source, warm } = await bench()
  677. await warm(proj('s1'))
  678. await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { attachments: 0 })).resolves.toBeUndefined()
  679. await expect(source.matchEnter!(proj('s1'), '/', signal(), { attachments: 0 })).resolves.toBeUndefined()
  680. await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { attachments: 0 })).resolves.toBeUndefined()
  681. })
  682. })
  683. describe('matchEnter envelope policy (images)', () => {
  684. const signal = () => new AbortController().signal
  685. const IMG_CMDS: CommandDescriptor[] = [
  686. ...S1_CMDS,
  687. { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', attachments: true } },
  688. ]
  689. const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' }
  690. it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => {
  691. const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) })
  692. await warm(proj('s1'))
  693. await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { attachments: 1 }))
  694. .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"goal"}')
  695. const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { attachments: 1 })
  696. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  697. expect(outcome.claim.token).toBe('/vision ')
  698. expect(outcome.claim.attachments).toBe(true)
  699. })
  700. it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => {
  701. const { command, source, mint, warm } = await bench()
  702. command.register(themeContribution())
  703. command.decorate({ name: 'plan', available: () => true, ui: themeUi() })
  704. const scope = mint('s1')
  705. await warm(proj('s1'))
  706. await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 1 }))
  707. .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"theme"}')
  708. await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 2 }))
  709. .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}')
  710. expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
  711. })
  712. it('bare host detached execute refuses images before any RPC', async () => {
  713. const { source, warm, executeCalls } = await bench()
  714. await warm(proj('s1'))
  715. await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 1 }))
  716. .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}')
  717. expect(executeCalls).toEqual([])
  718. })
  719. it('claim.submit forwards the images to execute; consumption follows the handler outcome', async () => {
  720. let result: CommandResult = { kind: 'error', text: 'handler refused' }
  721. const { source, warm, executeCalls } = await bench({
  722. commands: () => Promise.resolve({ commands: IMG_CMDS }),
  723. execute: () => Promise.resolve({ matched: true, result }),
  724. })
  725. await warm(proj('s1'))
  726. const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { attachments: 1 })
  727. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  728. // Handler error: the error outcome keeps draft and images in the composer.
  729. await expect(outcome.claim.submit('x', new Context(), [png]))
  730. .resolves.toEqual({ kind: 'error', text: 'handler refused' })
  731. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/vision x', images: [png] }])
  732. result = { kind: 'success', text: 'described' }
  733. await expect(outcome.claim.submit('x', new Context(), [png])).resolves.toEqual({ kind: 'success' })
  734. })
  735. it('an imageless submission keeps the always-success admission mapping over a handler error', async () => {
  736. const { source, warm } = await bench({
  737. execute: () => Promise.resolve({ matched: true, result: { kind: 'error', text: 'late failure' } }),
  738. })
  739. await warm(proj('s1'))
  740. const outcome = source.matchSpace!(proj('s1'), '/goal')
  741. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  742. await expect(outcome.claim.submit('x', new Context(), [])).resolves.toEqual({ kind: 'success' })
  743. })
  744. })
  745. describe('execute payload', () => {
  746. it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => {
  747. const { source, warm, executeCalls, executions } = await bench({
  748. execute: () => Promise.resolve({ matched: true }),
  749. })
  750. await warm(proj('s1'))
  751. const outcome = source.matchSpace!(proj('s1'), '/goal')
  752. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  753. const settled = await outcome.claim.submit('ship it', new Context(), [])
  754. expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }])
  755. // Pure admission: no outcome text ever rides the submit result — the
  756. // durable command lifecycle events render the outcome in the flow.
  757. expect(settled).toEqual({ kind: 'success' })
  758. expect(executions).toEqual([{
  759. sessionId: sid('s1'),
  760. name: 'goal',
  761. result: { kind: 'success' },
  762. }])
  763. })
  764. it('contains local acknowledgment listeners without changing an admitted result', async () => {
  765. const b = await bench({ execute: () => Promise.resolve({ matched: true }) })
  766. await b.warm(proj('s1'))
  767. const outcome = b.source.matchSpace!(proj('s1'), '/goal')
  768. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  769. const syncFailure = new Error('sync observer failed')
  770. const asyncFailure = new Error('async observer failed')
  771. const after = vi.fn()
  772. const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined)
  773. b.ctx.on('command/executed', () => { throw syncFailure })
  774. const rejectingListener = (() => Promise.reject(asyncFailure)) as unknown as () => void
  775. b.ctx.on('command/executed', rejectingListener)
  776. b.ctx.on('command/executed', after)
  777. await expect(outcome.claim.submit('ship it', new Context(), [])).resolves.toEqual({ kind: 'success' })
  778. expect(after).toHaveBeenCalledOnce()
  779. await Promise.resolve()
  780. await Promise.resolve()
  781. expect(warn).toHaveBeenCalledWith('client command: a command/executed listener for "%s" failed', 'goal')
  782. expect(warn).toHaveBeenCalledWith(syncFailure)
  783. expect(warn).toHaveBeenCalledWith(asyncFailure)
  784. })
  785. it('maps matched:false to an error outcome and a matched bare result to success', async () => {
  786. const claimOf = async (opts: BenchOptions) => {
  787. const b = await bench(opts)
  788. await b.warm(proj('s1'))
  789. const outcome = b.source.matchSpace!(proj('s1'), '/goal')
  790. if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
  791. return outcome.claim
  792. }
  793. const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
  794. const bad = await first.submit('x', new Context(), [])
  795. expect(bad.kind).toBe('error')
  796. const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
  797. await expect(second.submit('', new Context(), [])).resolves.toEqual({ kind: 'success' })
  798. })
  799. })
  800. describe('detached admission notices', () => {
  801. const flush = () => new Promise(resolve => setTimeout(resolve, 0))
  802. it('admitted outcomes stay silent; admission miss and transport rejection notice as errors', async () => {
  803. let mode: 'admitted' | 'miss' | 'reject' = 'admitted'
  804. const { source, mint, warm, notices } = await bench({
  805. execute: () => {
  806. if (mode === 'reject') return Promise.reject(new Error('network down'))
  807. return Promise.resolve({ matched: mode === 'admitted' })
  808. },
  809. })
  810. mint('s1')
  811. await warm(proj('s1'))
  812. // Admitted: the durable lifecycle events own the outcome — no notice.
  813. menuPick(source, 'plan', proj('s1'))
  814. await flush()
  815. expect(notices).toEqual([])
  816. // Admission miss (matched:false): immediate composer feedback stays.
  817. mode = 'miss'
  818. await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })
  819. await flush()
  820. expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
  821. notices.length = 0
  822. mode = 'reject'
  823. menuPick(source, 'plan', proj('s1'))
  824. await flush()
  825. // A dead Remote call and a rejected one now read alike: both arrive as a
  826. // failed result, so the notice names the endpoint either way.
  827. expect(notices).toEqual([{
  828. scope: sid('s1'),
  829. level: 'error',
  830. text: 'command.execute failed: gateway/internal: network down',
  831. }])
  832. })
  833. it('a torn-down scope drops the failure notice', async () => {
  834. const { source, warm, notices } = await bench({
  835. execute: () => Promise.reject(new Error('orphan failure')),
  836. })
  837. await warm(proj('ghost')) // never minted: scopeFor misses
  838. menuPick(source, 'plan', proj('ghost'))
  839. await flush()
  840. expect(notices).toEqual([])
  841. })
  842. it('contains a carrier rejection and drops notices when the conversation consumer unloads', async () => {
  843. const b = await bench()
  844. b.mint('s1')
  845. await b.warm(proj('s1'))
  846. vi.spyOn(b.ctx.remote.commands, 'execute').mockRejectedValue('carrier stopped')
  847. menuPick(b.source, 'plan', proj('s1'))
  848. await flush()
  849. expect(b.notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'carrier stopped' }])
  850. b.notices.length = 0
  851. b.removeConversation()
  852. menuPick(b.source, 'plan', proj('s1'))
  853. await flush()
  854. expect(b.notices).toEqual([])
  855. })
  856. })
  857. describe('register (contribution face)', () => {
  858. it('duplicate registration throws; the disposer frees the name', async () => {
  859. const { command } = await bench()
  860. const dispose = command.register(themeContribution())
  861. expect(() => command.register(themeContribution())).toThrow('duplicate contribution')
  862. dispose()
  863. command.register(themeContribution())()
  864. })
  865. })
  866. describe('popupFor', () => {
  867. it('does not open a popup for an absent scope or reuse a removed sessions service', async () => {
  868. const b = await bench()
  869. b.command.register(themeContribution())
  870. expect(menuPick(b.source, 'theme', proj('closed'))).toBe('handled')
  871. b.removeSessions()
  872. expect(() => b.command.popupFor(b.ctx)).toThrow('sessions service unavailable')
  873. })
  874. it('dismisses matching popups and confirmations while preserving other commands and drafts', async () => {
  875. const { command, source, mint, focuses } = await bench()
  876. const pending = Promise.withResolvers<readonly SelectOption[]>()
  877. let pendingSignal: AbortSignal | undefined
  878. const onSelect = vi.fn()
  879. command.register(themeContribution({ ui: themeUi({
  880. options: (session, signal) => {
  881. if (session.sessionId === sid('s2')) {
  882. pendingSignal = signal
  883. return pending.promise
  884. }
  885. return Promise.resolve([{
  886. id: 'dark',
  887. label: 'Dark',
  888. confirmation: {
  889. title: 'Confirm theme', description: 'Change theme', acknowledgeLabel: 'Acknowledge',
  890. cancelLabel: 'Cancel', confirmLabel: 'Confirm',
  891. },
  892. }])
  893. },
  894. onSelect,
  895. }) }))
  896. command.register(themeContribution({ name: 'other' }))
  897. const scope = mint('s1')
  898. const first = command.popupFor(scope.ctx)
  899. const second = command.popupFor(mint('s2').ctx)
  900. const other = command.popupFor(mint('other').ctx)
  901. const consume = vi.fn(() => true as const)
  902. scope.ctx.on('slash/input-consume-token', consume)
  903. menuPick(source, 'theme', proj('s1'))
  904. menuPick(source, 'theme', proj('s2'))
  905. menuPick(source, 'other', proj('other'))
  906. await Promise.resolve()
  907. await first.select(0)
  908. first.acknowledge(true)
  909. expect(first.state.getSnapshot().confirming?.id).toBe('dark')
  910. expect(second.state.getSnapshot().status).toBe('pending')
  911. command.dismiss('theme')
  912. expect(first.state.getSnapshot()).toMatchObject({ open: false, options: [], confirming: null })
  913. expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
  914. expect(pendingSignal?.aborted).toBe(true)
  915. expect(other.state.getSnapshot().open).toBe(true)
  916. pending.resolve([{ id: 'late', label: 'Late' }])
  917. await Promise.resolve()
  918. await first.confirm()
  919. expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
  920. expect(onSelect).not.toHaveBeenCalled()
  921. expect(consume).not.toHaveBeenCalled()
  922. // The stale catalog takes the rows away; the composer keeps the keyboard.
  923. expect(focuses).toEqual([sid('s1'), sid('s2')])
  924. })
  925. it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
  926. const { ctx, command, mint } = await bench()
  927. const a = mint('s1')
  928. const first = command.popupFor(a.ctx)
  929. expect(command.popupFor(a.ctx)).toBe(first)
  930. expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
  931. expect(() => command.popupFor(ctx)).toThrow('requires a retained Session scope')
  932. })
  933. it('a successful select dispatches the scoped consume-token and focuses the composer', async () => {
  934. const { command, source, mint, focuses } = await bench()
  935. const onSelect = vi.fn()
  936. command.register(themeContribution({ ui: themeUi({ onSelect }) }))
  937. const scope = mint('s1')
  938. const consumes: ConsumeTokenRequest[] = []
  939. scope.ctx.on('slash/input-consume-token', (r) => {
  940. consumes.push(r)
  941. return true
  942. })
  943. expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
  944. const popup = command.popupFor(scope.ctx)
  945. await Promise.resolve() // options land
  946. await popup.select(0)
  947. expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
  948. expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
  949. expect(focuses).toEqual([sid('s1')])
  950. })
  951. it('the enter path opens with the bare-token guard', async () => {
  952. const { command, source, mint } = await bench()
  953. command.register(themeContribution())
  954. const scope = mint('s1')
  955. const consumes: ConsumeTokenRequest[] = []
  956. scope.ctx.on('slash/input-consume-token', (r) => {
  957. consumes.push(r)
  958. return true
  959. })
  960. await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 })
  961. const popup = command.popupFor(scope.ctx)
  962. await Promise.resolve()
  963. await popup.select(0)
  964. expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/theme' } }])
  965. })
  966. it('the scope disposer disposes the controller and a re-mint resolves fresh', async () => {
  967. const { command, source, mint } = await bench()
  968. command.register(themeContribution())
  969. const scope = mint('s1')
  970. await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 })
  971. const popup = command.popupFor(scope.ctx)
  972. expect(popup.state.getSnapshot().open).toBe(true)
  973. await scope.fiber.dispose()
  974. expect(popup.state.getSnapshot().open).toBe(false)
  975. expect(command.popupFor(mint('s1').ctx)).not.toBe(popup)
  976. })
  977. })
  978. describe('directory invalidation events', () => {
  979. it('commands/change repulls in the background while the old snapshot serves', async () => {
  980. let round = 0
  981. const { source, warm, remote } = await bench({
  982. commands: () => {
  983. round += 1
  984. return Promise.resolve({
  985. commands: round === 1
  986. ? S1_CMDS
  987. : [{ name: 'fresh', description: '', input: { hint: 'h' } }],
  988. })
  989. },
  990. })
  991. await warm(proj('s1'))
  992. remote.emit('commands/change', [])
  993. await new Promise(resolve => setTimeout(resolve, 0))
  994. expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
  995. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  996. })
  997. it('agent-preset/selected drops and repulls the recomposed session while leaving others served', async () => {
  998. const rounds = new Map<SessionId, number>()
  999. const { source, warm, remote } = await bench({
  1000. commands: (payload) => {
  1001. const round = (rounds.get(payload.sessionId) ?? 0) + 1
  1002. rounds.set(payload.sessionId, round)
  1003. return Promise.resolve({
  1004. commands: round === 1
  1005. ? S1_CMDS
  1006. : [{ name: 'fresh', description: '', input: { hint: 'h' } }],
  1007. })
  1008. },
  1009. })
  1010. await warm(proj('s1'))
  1011. await warm(proj('s2'))
  1012. // A preset switch changes which commands one session's agent resolves;
  1013. // every other session keeps the catalog its own composition serves.
  1014. remote.emit('agent-preset/selected', [sid('s1'), 'minimal'])
  1015. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  1016. expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
  1017. await new Promise(resolve => setTimeout(resolve, 0))
  1018. expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
  1019. expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
  1020. expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
  1021. })
  1022. it('connection/reset hard-drops every session key until its rewarm lands', async () => {
  1023. let block = false
  1024. let release!: (value: { commands: CommandDescriptor[] }) => void
  1025. const { ctx, source, warm } = await bench({
  1026. commands: () => (block
  1027. ? new Promise((resolve) => { release = resolve })
  1028. : Promise.resolve({ commands: S2_CMDS })),
  1029. })
  1030. await warm(proj('s2'))
  1031. expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
  1032. block = true
  1033. ctx.emit('connection/reset')
  1034. // Hard reset: silent until the rewarm lands.
  1035. expect(source.matchSpace!(proj('s2'), '/attach')).toBeUndefined()
  1036. release({ commands: S2_CMDS })
  1037. await new Promise(resolve => setTimeout(resolve, 0))
  1038. expect(source.matchSpace!(proj('s2'), '/attach')).not.toBeUndefined()
  1039. })
  1040. })