rpc-schemas.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { describe, expect, it } from 'vitest'
  2. import { RpcId, transportError } from '../src/api/rpc.ts'
  3. import {
  4. clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
  5. rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
  6. } from '../src/api/rpc.schema.ts'
  7. import { z } from 'zod'
  8. import {
  9. contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
  10. sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
  11. sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
  12. sessionPromptValueSchema, sessionSummarySchema,
  13. } from '../src/api/sessions.schema.ts'
  14. import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
  15. import {
  16. workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
  17. workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
  18. workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
  19. workspaceListRequestSchema, workspaceListValueSchema,
  20. workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
  21. } from '../src/api/workspace.schema.ts'
  22. import {
  23. commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
  24. commandListRequestSchema, commandListValueSchema,
  25. } from '../src/api/commands.schema.ts'
  26. import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
  27. import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
  28. import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
  29. import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
  30. describe('RpcId', () => {
  31. it('brands a raw string at zero runtime cost', () => {
  32. expect(RpcId('abc')).toBe('abc')
  33. expect(rpcIdSchema.parse('abc')).toBe('abc')
  34. // No min-length: the id is an opaque echo token (see rpcIdSchema's contract).
  35. expect(rpcIdSchema.parse('')).toBe('')
  36. expect(() => rpcIdSchema.parse(42)).toThrow()
  37. })
  38. })
  39. describe('transportError', () => {
  40. it('folds Error and non-Error throws into the internal error branch', () => {
  41. expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
  42. expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
  43. })
  44. })
  45. describe('rpcErrorSchema', () => {
  46. it('accepts every code branch with its required details', () => {
  47. expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
  48. expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
  49. expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
  50. expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
  51. expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
  52. expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
  53. expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
  54. expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
  55. expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
  56. expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
  57. expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
  58. })
  59. it('rejects a known code with missing details', () => {
  60. expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
  61. expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
  62. })
  63. })
  64. describe('rpcResultSchema', () => {
  65. it('accepts both result branches and rejects hybrids', () => {
  66. const schema = rpcResultSchema(z.object({ n: z.number() }))
  67. expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
  68. const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
  69. expect(err).toMatchObject({ ok: false })
  70. expect(() => schema.parse({ ok: true, error: {} })).toThrow()
  71. })
  72. })
  73. describe('wire full-form schemas', () => {
  74. it('parses the four quadrants and the union discriminates on type', () => {
  75. const cq = { type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }
  76. const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
  77. const rq = { type: 'server-request', rpcId: 'r2', method: 'session/event', payload: { a: 1 } }
  78. const cr = { type: 'client-response', rpcId: 'r2', result: { ok: true, value: null } }
  79. expect(clientRequestSchema.parse(cq).method).toBe('session.list')
  80. expect(serverResponseSchema.parse(sr).rpcId).toBe('r1')
  81. expect(serverRequestSchema.parse(rq).method).toBe('session/event')
  82. expect(clientResponseSchema.parse(cr).rpcId).toBe('r2')
  83. for (const message of [cq, sr, rq, cr]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
  84. expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
  85. })
  86. it('rejects a quadrant missing its members', () => {
  87. expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
  88. expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow()
  89. })
  90. })
  91. describe('rpcReceiptSchema', () => {
  92. it('accepts both receipt branches with the closed reason set', () => {
  93. expect(rpcReceiptSchema.parse({ accepted: true })).toEqual({ accepted: true })
  94. expect(rpcReceiptSchema.parse({ accepted: false, reason: 'not-pending' })).toEqual({ accepted: false, reason: 'not-pending' })
  95. expect(rpcReceiptSchema.parse({ accepted: false, reason: 'bad-response' })).toEqual({ accepted: false, reason: 'bad-response' })
  96. expect(() => rpcReceiptSchema.parse({ accepted: false, reason: 'other' })).toThrow()
  97. })
  98. })
  99. describe('sessions domain schemas', () => {
  100. it('validates ids, summaries, and the event passthrough envelope', () => {
  101. expect(sessionIdSchema.parse('s1')).toBe('s1')
  102. expect(() => sessionIdSchema.parse('')).toThrow()
  103. expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
  104. expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
  105. // blank is mandatory: a summary without it fails the parse.
  106. expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
  107. const event = sessionEventSchema.parse({ type: 'user/message', seq: 0, time: 1, data: { any: true } })
  108. expect(event).toMatchObject({ type: 'user/message' })
  109. expect(() => sessionEventSchema.parse({ type: 'user/message', seq: -1, time: 1, data: {} })).toThrow()
  110. })
  111. it('validates the per-method request/value pairs', () => {
  112. expect(sessionListRequestSchema.parse({})).toEqual({})
  113. expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
  114. expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
  115. expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
  116. // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
  117. expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
  118. expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
  119. expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
  120. expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
  121. expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
  122. expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
  123. const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
  124. expect(prompt.mode).toBe('queue')
  125. expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
  126. expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
  127. expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
  128. expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
  129. expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
  130. })
  131. })
  132. describe('host domain schemas', () => {
  133. it('validates describe request/value', () => {
  134. expect(hostDescribeRequestSchema.parse({})).toEqual({})
  135. const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
  136. expect(value.attachedSessions).toBe(2)
  137. expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
  138. })
  139. })
  140. describe('workspace domain schemas', () => {
  141. const view = {
  142. workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'],
  143. createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
  144. }
  145. it('validates ids, the view row, and list request/value', () => {
  146. expect(workspaceIdSchema.parse('w1')).toBe('w1')
  147. expect(() => workspaceIdSchema.parse('')).toThrow()
  148. expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
  149. expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
  150. expect(workspaceListRequestSchema.parse({})).toEqual({})
  151. expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
  152. })
  153. it('create requires exactly one of path/name (both refine arms)', () => {
  154. expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
  155. expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
  156. expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
  157. expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
  158. expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
  159. })
  160. it('rename requires a non-blank title (both refine arms)', () => {
  161. expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
  162. expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
  163. expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
  164. })
  165. it('validates workspace deletion payload and receipt', () => {
  166. expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1')
  167. expect(() => workspaceDeleteRequestSchema.parse({})).toThrow()
  168. expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
  169. expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
  170. })
  171. it('insertSessionBefore accepts an anchored and an anchorless move', () => {
  172. expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
  173. expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
  174. expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
  175. expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
  176. })
  177. })
  178. describe('commands domain schemas', () => {
  179. it('validates the catalog request/value pair', () => {
  180. expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
  181. // The wire is session-addressed only: a sessionId-less payload fails.
  182. expect(() => commandListRequestSchema.parse({})).toThrow()
  183. expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
  184. const value = commandListValueSchema.parse({ commands: [
  185. { name: 'plan', description: 'Toggle plan mode' },
  186. { name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
  187. ] })
  188. expect(value.commands[1]?.input?.hint).toBe('<goal>')
  189. expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
  190. expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
  191. expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
  192. })
  193. it('validates the execute request/value pair with both matched branches', () => {
  194. expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
  195. // Both members are mandatory: dropping either fails the parse.
  196. expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
  197. expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
  198. expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
  199. const matched = commandExecuteValueSchema.parse({ matched: true, result: { kind: 'success', text: 'done' } })
  200. expect(matched.result?.kind).toBe('success')
  201. expect(commandExecuteValueSchema.parse({ matched: true, result: { kind: 'error', text: 'bad' } }).result?.kind).toBe('error')
  202. expect(() => commandExecuteValueSchema.parse({ matched: true, result: { kind: 'other' } })).toThrow()
  203. })
  204. })
  205. describe('skills domain schemas', () => {
  206. it('validates the list request/value pair', () => {
  207. expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' })
  208. // The wire is session-addressed only: a sessionId-less payload fails.
  209. expect(() => skillListRequestSchema.parse({})).toThrow()
  210. expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
  211. const value = skillListValueSchema.parse({ skills: [
  212. { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
  213. { name: 'bare', description: 'No guidance' },
  214. ] })
  215. expect(value.skills[0]?.whenToUse).toBe('when committing')
  216. expect(value.skills[1]?.whenToUse).toBeUndefined()
  217. expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
  218. })
  219. })
  220. describe('events frame schemas', () => {
  221. it('accepts every mux frame branch', () => {
  222. const frames = [
  223. { type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
  224. { type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
  225. { type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 },
  226. { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
  227. { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
  228. { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
  229. { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
  230. { type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
  231. { type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
  232. { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
  233. ]
  234. for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
  235. expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
  236. for (const invalid of [
  237. { type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 },
  238. { type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 },
  239. { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 },
  240. { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' },
  241. { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN },
  242. ]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
  243. expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
  244. })
  245. it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => {
  246. expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
  247. })
  248. it('rejects a queued frame missing its members', () => {
  249. expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [{ type: 'text' }], source: { kind: 'user' } })).toThrow()
  250. expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' }, steering: false })).toThrow()
  251. expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
  252. })
  253. it('accepts every host frame branch', () => {
  254. const frames = [
  255. { type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
  256. { type: 'host/session-added', sessionId: 's', blank: true },
  257. { type: 'host/session-removed', sessionId: 's' },
  258. { type: 'host/session-status', sessionId: 's', running: true },
  259. { type: 'host/agent-error', sessionId: 's', message: 'boom' },
  260. { type: 'host/workspace-changed', workspace: {
  261. workspaceId: 'w', path: '/w', title: 'w', sessionIds: [],
  262. createdAt: '0', updatedAt: '0',
  263. } },
  264. { type: 'host/workspace-removed', workspaceId: 'w' },
  265. { type: 'host/commands-changed' },
  266. { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
  267. ]
  268. for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
  269. })
  270. })
  271. describe('respond payload schemas', () => {
  272. it('validates approval and question answer payloads', () => {
  273. expect(approvalRequestIdSchema.parse('a1')).toBe('a1')
  274. const approval = approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'rejected' })
  275. expect(approval.outcome).toBe('rejected')
  276. expect(() => approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'cancelled' })).toThrow()
  277. const answer = askUserQuestionAnswerSchema.parse({ answers: [{ id: 'q', selected: ['x'], custom: 'c' }] })
  278. expect(answer.answers[0]?.selected).toEqual(['x'])
  279. const payload = questionResponsePayloadSchema.parse({ sessionId: 's', answer: { answers: [] } })
  280. expect(payload.sessionId).toBe('s')
  281. })
  282. })