fixture-commands.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. /**
  2. * Fixture commands/skills domains: contract-shape conformance for the two
  3. * domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
  4. * parse/dispatch, skill.list session resolution, and the FixtureApiClient
  5. * dispatch rows.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import type { SessionId } from '../src/client/api.ts'
  9. import { RpcId } from '../src/client/api.ts'
  10. import type { RpcRequest } from '../src/client/api.ts'
  11. import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
  12. const sid = (id: string): SessionId => id as SessionId
  13. let reqCount = 0
  14. const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
  15. const signal = new AbortController().signal
  16. describe('createFixtureApi commands/skills', () => {
  17. it('serves the addressed session catalog with rpcId echo', async () => {
  18. const api = createFixtureApi()
  19. const request = req({ sessionId: sid('fx-alpha') })
  20. const response = await api.commands.list(request)
  21. expect(response.rpcId).toBe(request.rpcId)
  22. if (!response.result.ok) throw new Error('list failed')
  23. const commands = response.result.value.commands
  24. expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
  25. // input hint rides only the commands declaring it.
  26. const echo = commands.find(c => c.name === 'echo')
  27. expect(echo?.input?.hint).toBeTruthy()
  28. expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
  29. })
  30. it('rejects a catalog request for an unknown session', async () => {
  31. const api = createFixtureApi()
  32. const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
  33. expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  34. })
  35. it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
  36. const api = createFixtureApi()
  37. const frames: unknown[] = []
  38. const abort = new AbortController()
  39. const stream = api.events.mux(req({}), abort.signal)
  40. const pump = (async () => {
  41. for await (const frame of stream) {
  42. frames.push(frame.payload)
  43. if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
  44. }
  45. })()
  46. const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
  47. if (!response.result.ok) throw new Error('execute failed')
  48. expect(response.result.value).toMatchObject({ matched: true })
  49. expect(response.result.value.commandId).toBeTruthy()
  50. await pump
  51. const events = frames
  52. .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
  53. .map(f => f.event)
  54. expect(events).toMatchObject([
  55. { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
  56. { type: 'command/done', data: { kind: 'success', text: 'hello world' } },
  57. ])
  58. expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
  59. })
  60. it('addresses execute to the session; an unknown session errs', async () => {
  61. const api = createFixtureApi()
  62. const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal)
  63. if (!hit.result.ok) throw new Error('execute failed')
  64. expect(hit.result.value.matched).toBe(true)
  65. const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
  66. expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  67. })
  68. it('falls to matched:false on unknown names and non-command lines', async () => {
  69. const api = createFixtureApi()
  70. for (const line of ['/nope', 'plain text', '/']) {
  71. const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
  72. if (!response.result.ok) throw new Error('execute failed')
  73. // Pure admission value: the matched bit is the whole response shape.
  74. expect(response.result.value).toEqual({ matched: false })
  75. }
  76. })
  77. it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
  78. const api = createFixtureApi()
  79. const response = await api.skills.list(req({ sessionId: sid('fx-alpha') }))
  80. if (!response.result.ok) throw new Error('skill list failed')
  81. expect(response.result.value.skills[0]?.name).toBe('fixture-demo')
  82. const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') }))
  83. expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  84. })
  85. })
  86. describe('FixtureApiClient command/skill dispatch', () => {
  87. it('routes the three method keys through the in-memory dispatch table', async () => {
  88. const client = new FixtureApiClient()
  89. const list = await client.commands.list({ sessionId: sid('fx-alpha') })
  90. if (!list.result.ok) throw new Error('command.list failed')
  91. expect(list.result.value.commands.length).toBeGreaterThan(0)
  92. const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
  93. if (!executed.result.ok) throw new Error('command.execute failed')
  94. expect(executed.result.value.matched).toBe(true)
  95. const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
  96. if (!skills.result.ok) throw new Error('skill.list failed')
  97. expect(skills.result.value.skills.length).toBeGreaterThan(0)
  98. })
  99. })