fixture-commands.client.spec.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /**
  2. * Fixture commands/skills domains: session-addressed catalogs, execute
  3. * parse/dispatch and its logged lifecycle pair, and skills/list Session resolution.
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import type { SessionId } from '../src/client/api.ts'
  7. import { createFixtureFaces } from '../src/client/fixture.ts'
  8. /** Drive one commands Remote endpoint against the fixture state graph. */
  9. async function callRemote<T>(
  10. rpc: ReturnType<typeof createFixtureFaces>['rpc'],
  11. endpoint: string,
  12. args: Record<string, unknown>,
  13. ): Promise<T> {
  14. const result = await rpc.call('/api', endpoint, { args })
  15. if (!result.ok) throw new Error(`${endpoint} failed: ${result.error.code}`)
  16. return result.value as T
  17. }
  18. const sid = (id: string): SessionId => id as SessionId
  19. describe('createFixtureApi commands/skills', () => {
  20. it('serves the addressed session catalog', async () => {
  21. const { rpc } = createFixtureFaces()
  22. const commands = await callRemote<{ name: string; input?: { hint: string; attachments?: boolean } }[]>(
  23. rpc, 'commands/list', { agentId: sid('fx-alpha') })
  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. // Image acceptance is declared per descriptor; only goal and plan carry it.
  30. expect(commands.filter(c => c.input?.attachments === true).map(c => c.name)).toEqual(['goal', 'plan'])
  31. })
  32. it('rejects a catalog request for an unknown session', async () => {
  33. const { rpc } = createFixtureFaces()
  34. const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } })
  35. expect(result).toMatchObject({ ok: false, error: { code: 'session/not-found' } })
  36. })
  37. it('executes a known command line: pure admission plus a followed lifecycle pair', async () => {
  38. const { rpc } = createFixtureFaces()
  39. const frames: unknown[] = []
  40. const abort = new AbortController()
  41. const stream = rpc.open?.('/api', 'session/follow', {
  42. args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } },
  43. }, abort.signal)
  44. if (stream === undefined) throw new Error('fixture session follow stream is unavailable')
  45. const pump = (async () => {
  46. for await (const frame of stream) {
  47. frames.push(frame)
  48. if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort()
  49. }
  50. })()
  51. const execution = await callRemote<{ commandId: string } | undefined>(
  52. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hello world' })
  53. expect(execution?.commandId).toBeTruthy()
  54. await pump
  55. const events = frames
  56. .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'event')
  57. .map(f => f.event)
  58. expect(events).toMatchObject([
  59. { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
  60. { type: 'command/done', data: { kind: 'success', text: 'hello world' } },
  61. ])
  62. expect(events[0]?.data.commandId).toBe(events[1]?.data.commandId)
  63. })
  64. it('addresses execute to the session; an unknown session errs', async () => {
  65. const { rpc } = createFixtureFaces()
  66. const hit = await callRemote<{ commandId: string } | undefined>(
  67. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship' })
  68. expect(hit?.commandId).toBeTruthy()
  69. const missing = await rpc.call('/api', 'commands/execute', {
  70. args: { agentId: sid('fx-nope'), line: '/goal ship' },
  71. })
  72. expect(missing).toMatchObject({ ok: false, error: { code: 'session/not-found' } })
  73. })
  74. it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => {
  75. const { rpc } = createFixtureFaces()
  76. const frames: unknown[] = []
  77. const abort = new AbortController()
  78. const stream = rpc.open?.('/api', 'session/follow', {
  79. args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } },
  80. }, abort.signal)
  81. if (stream === undefined) throw new Error('fixture session follow stream is unavailable')
  82. const pump = (async () => {
  83. for await (const frame of stream) {
  84. frames.push(frame)
  85. if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort()
  86. }
  87. })()
  88. const png = { mediaType: 'image/png', data: 'AA==' }
  89. const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>(
  90. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] })
  91. expect(refused?.commandId).toBeTruthy()
  92. expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept attachments' })
  93. await pump
  94. const events = frames
  95. .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'event')
  96. .map(f => f.event)
  97. expect(events).toMatchObject([
  98. { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } },
  99. { type: 'command/done', data: { kind: 'error', text: '/echo does not accept attachments' } },
  100. ])
  101. })
  102. it('a declaring command accepts an image-carrying execute', async () => {
  103. const { rpc } = createFixtureFaces()
  104. const png = { mediaType: 'image/png', data: 'AA==' }
  105. const accepted = await callRemote<{ result: { kind: string } } | undefined>(
  106. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship it', images: [png] })
  107. expect(accepted?.result.kind).toBe('success')
  108. const planMessage = await callRemote<{ result: { kind: string } } | undefined>(
  109. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] })
  110. expect(planMessage?.result.kind).toBe('success')
  111. const imageOnlyPlan = await callRemote<{ result: { kind: string } } | undefined>(
  112. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan', images: [png] })
  113. expect(imageOnlyPlan?.result.kind).toBe('success')
  114. })
  115. it('mirrors the producer grammar rejections for control-only declaring lines', async () => {
  116. const { rpc } = createFixtureFaces()
  117. const png = { mediaType: 'image/png', data: 'AA==' }
  118. const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
  119. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] })
  120. expect(bareGoal?.result).toEqual({
  121. kind: 'error',
  122. text: 'Attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
  123. })
  124. const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
  125. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] })
  126. expect(refused?.result).toEqual({
  127. kind: 'error',
  128. text: 'Attachments cannot accompany /plan off.',
  129. })
  130. })
  131. it('answers no execution for an unknown name even when images accompany it', async () => {
  132. const { rpc } = createFixtureFaces()
  133. const png = { mediaType: 'image/png', data: 'AA==' }
  134. expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/nope', images: [png] }))
  135. .toBeUndefined()
  136. })
  137. it('answers no execution for unknown names and non-command lines', async () => {
  138. const { rpc } = createFixtureFaces()
  139. for (const line of ['/nope', 'plain text', '/']) {
  140. // Absence is the whole answer: nothing matched, so no lifecycle id exists.
  141. expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line }))
  142. .toBeUndefined()
  143. }
  144. })
  145. it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
  146. const { rpc } = createFixtureFaces()
  147. const skills = await callRemote<{ skills: Array<{ name: string }> }>(
  148. rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } },
  149. )
  150. expect(skills.skills[0]?.name).toBe('fixture-demo')
  151. const missingSession = await rpc.call('/api', 'skills/list', {
  152. args: { request: { sessionId: sid('fx-nope') } },
  153. })
  154. expect(missingSession).toMatchObject({ ok: false, error: { code: 'session/not-found' } })
  155. })
  156. })
  157. describe('fixture Connection command/skill dispatch', () => {
  158. it('routes the Remote command and skill rows through one state graph', async () => {
  159. const { rpc } = createFixtureFaces()
  160. const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') })
  161. expect(commands.length).toBeGreaterThan(0)
  162. const executed = await callRemote<{ commandId: string } | undefined>(
  163. rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' })
  164. expect(executed?.commandId).toBeTruthy()
  165. const skills = await callRemote<{ skills: unknown[] }>(
  166. rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } },
  167. )
  168. expect(skills.skills.length).toBeGreaterThan(0)
  169. })
  170. })