remote-event-protocol.host.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. isRemoteJsonValue,
  4. parseRemoteEventResult,
  5. parseRemoteStreamClientMessage,
  6. projectRemoteEventRequest,
  7. projectRemoteEventRejection,
  8. restoreRemoteEventRejection,
  9. } from '../src/stream-protocol.ts'
  10. describe('Remote Event result protocol', () => {
  11. it('accepts delegation, values, and structured rejections', () => {
  12. expect(parseRemoteEventResult({
  13. clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' },
  14. })).toEqual({ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' } })
  15. expect(parseRemoteEventResult({
  16. clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' },
  17. })).toEqual({ clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' } })
  18. expect(parseRemoteEventResult({
  19. clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } },
  20. })).toEqual({
  21. clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } },
  22. })
  23. expect(parseRemoteEventResult({
  24. clientId: 'client-1',
  25. eventId: 'event-minimal',
  26. outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } },
  27. })).toEqual({
  28. clientId: 'client-1',
  29. eventId: 'event-minimal',
  30. outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } },
  31. })
  32. expect(parseRemoteEventResult({
  33. clientId: 'client-1',
  34. eventId: 'event-4',
  35. outcome: {
  36. kind: 'rejected',
  37. error: {
  38. name: 'ApprovalError',
  39. message: 'declined',
  40. code: 'DECLINED',
  41. details: { retryable: false },
  42. },
  43. },
  44. })).toEqual({
  45. clientId: 'client-1',
  46. eventId: 'event-4',
  47. outcome: {
  48. kind: 'rejected',
  49. error: {
  50. name: 'ApprovalError',
  51. message: 'declined',
  52. code: 'DECLINED',
  53. details: { retryable: false },
  54. },
  55. },
  56. })
  57. })
  58. it.each([
  59. null,
  60. [],
  61. {},
  62. { clientId: '', eventId: 'event-1', outcome: { kind: 'next' } },
  63. { clientId: 'client-1', eventId: '', outcome: { kind: 'next' } },
  64. { clientId: 'client-1', eventId: 'event-1', outcome: null },
  65. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, extra: true },
  66. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next', value: null } },
  67. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', extra: true } },
  68. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', value: undefined } },
  69. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'unknown' } },
  70. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: null } },
  71. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: '', message: 'bad' } } },
  72. { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: 'Error', message: 1 } } },
  73. {
  74. clientId: 'client-1',
  75. eventId: 'event-1',
  76. outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', code: 1 } },
  77. },
  78. {
  79. clientId: 'client-1',
  80. eventId: 'event-1',
  81. outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', details: 1n } },
  82. },
  83. {
  84. clientId: 'client-1',
  85. eventId: 'event-1',
  86. outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', extra: true } },
  87. },
  88. ])('rejects an invalid result frame: %#', (value) => {
  89. expect(() => parseRemoteEventResult(value)).toThrow('api gateway: invalid Remote event')
  90. })
  91. it('rejects symbol properties in rejection records', () => {
  92. const error = { name: 'Error', message: 'bad', [Symbol('hidden')]: true }
  93. expect(() => parseRemoteEventResult({
  94. clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error },
  95. })).toThrow('api gateway: invalid Remote event rejection')
  96. })
  97. })
  98. describe('Remote Event request projection', () => {
  99. it('removes only the direct Agent and signal fields', () => {
  100. const agent = { kind: 'agent' }
  101. const abort = new AbortController()
  102. const nested = { agent, signal: 'payload' }
  103. const projected = projectRemoteEventRequest({
  104. agent,
  105. signal: abort.signal,
  106. prompt: 'approve?',
  107. nested,
  108. }, agent)
  109. expect(projected).toEqual({
  110. request: { prompt: 'approve?', nested },
  111. signal: abort.signal,
  112. })
  113. expect(Object.getPrototypeOf(projected.request)).toBeNull()
  114. })
  115. it('accepts a null-prototype request and an omitted signal', () => {
  116. const agent = { kind: 'agent' }
  117. const request = Object.assign(Object.create(null) as Record<string, unknown>, {
  118. agent,
  119. accepted: true,
  120. })
  121. expect(projectRemoteEventRequest(request, agent)).toEqual({
  122. request: { accepted: true },
  123. })
  124. })
  125. it('requires the scoped Agent as a direct own field', () => {
  126. const agent = { kind: 'agent' }
  127. expect(() => projectRemoteEventRequest(null, agent))
  128. .toThrow('must carry its scoped Agent directly')
  129. expect(() => projectRemoteEventRequest({}, agent))
  130. .toThrow('must carry its scoped Agent directly')
  131. expect(() => projectRemoteEventRequest({ agent: {} }, agent))
  132. .toThrow('must carry its scoped Agent directly')
  133. expect(() => projectRemoteEventRequest(Object.create({ agent }), agent))
  134. .toThrow('must carry its scoped Agent directly')
  135. })
  136. it('rejects an invalid direct signal', () => {
  137. const agent = { kind: 'agent' }
  138. expect(() => projectRemoteEventRequest({ agent, signal: 'abort' }, agent))
  139. .toThrow('request signal must be an AbortSignal')
  140. })
  141. it('rejects non-JSON payload fields', () => {
  142. const agent = { kind: 'agent' }
  143. expect(() => projectRemoteEventRequest({ agent, value: 1n }, agent))
  144. .toThrow('request is not lossless JSON data')
  145. const cycle: Record<string, unknown> = {}
  146. cycle.self = cycle
  147. expect(() => projectRemoteEventRequest({ agent, cycle }, agent))
  148. .toThrow('request is not lossless JSON data')
  149. })
  150. it('rejects symbol and non-enumerable payload fields', () => {
  151. const agent = { kind: 'agent' }
  152. expect(() => projectRemoteEventRequest({ agent, [Symbol('hidden')]: true }, agent))
  153. .toThrow('request has a non-JSON property')
  154. const hidden = { agent }
  155. Object.defineProperty(hidden, 'value', { value: true })
  156. expect(() => projectRemoteEventRequest(hidden, agent))
  157. .toThrow('request has a non-JSON property')
  158. })
  159. })
  160. describe('Remote Event rejection projection', () => {
  161. it('preserves stable error fields in both directions', () => {
  162. const reason = Object.assign(new Error('declined'), {
  163. name: 'ApprovalError',
  164. code: 'DECLINED',
  165. details: { retryable: false },
  166. })
  167. expect(projectRemoteEventRejection(reason)).toEqual({
  168. name: 'ApprovalError',
  169. message: 'declined',
  170. code: 'DECLINED',
  171. details: { retryable: false },
  172. })
  173. const restored = restoreRemoteEventRejection({
  174. name: 'ApprovalError',
  175. message: 'declined',
  176. code: 'DECLINED',
  177. details: { retryable: false },
  178. }) as Error & { code?: string; details?: unknown }
  179. expect(restored).toMatchObject({
  180. name: 'ApprovalError',
  181. message: 'declined',
  182. code: 'DECLINED',
  183. details: { retryable: false },
  184. })
  185. })
  186. it('normalizes arbitrary reasons and omits non-JSON optional fields', () => {
  187. expect(projectRemoteEventRejection('offline')).toEqual({
  188. name: 'Error', message: 'offline',
  189. })
  190. expect(projectRemoteEventRejection(undefined)).toEqual({
  191. name: 'Error', message: 'undefined',
  192. })
  193. expect(projectRemoteEventRejection({
  194. name: 1, message: 2, code: 3, details: 1n,
  195. })).toEqual({
  196. name: 'Error', message: '[object Object]',
  197. })
  198. const restored = restoreRemoteEventRejection({ name: 'Error', message: 'offline' })
  199. expect(restored).toMatchObject({ name: 'Error', message: 'offline' })
  200. expect(restored).not.toHaveProperty('code')
  201. expect(restored).not.toHaveProperty('details')
  202. })
  203. })
  204. describe('Remote Event JSON values', () => {
  205. it('accepts lossless JSON values, null-prototype objects, and repeated references', () => {
  206. const shared = { value: 1 }
  207. const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, {
  208. enabled: true,
  209. })
  210. expect(isRemoteJsonValue({
  211. null: null,
  212. string: 'value',
  213. boolean: true,
  214. number: 1.5,
  215. array: [shared, shared],
  216. nullPrototype,
  217. })).toBe(true)
  218. })
  219. it.each([
  220. undefined,
  221. 1n,
  222. Symbol('value'),
  223. () => undefined,
  224. NaN,
  225. Number.POSITIVE_INFINITY,
  226. -0,
  227. ])('rejects a non-lossless scalar: %s', (value) => {
  228. expect(isRemoteJsonValue(value)).toBe(false)
  229. })
  230. it('rejects cycles and non-plain arrays and objects', () => {
  231. const cycle: Record<string, unknown> = {}
  232. cycle.self = cycle
  233. expect(isRemoteJsonValue(cycle)).toBe(false)
  234. class Fixture {
  235. value = 1
  236. }
  237. expect(isRemoteJsonValue(new Fixture())).toBe(false)
  238. const customArray = [1]
  239. Object.setPrototypeOf(customArray, null)
  240. expect(isRemoteJsonValue(customArray)).toBe(false)
  241. expect(isRemoteJsonValue(Object.assign([1], { extra: true }))).toBe(false)
  242. const sparse = new Array<unknown>(2)
  243. sparse[1] = 'value'
  244. expect(isRemoteJsonValue(sparse)).toBe(false)
  245. const disguisedSparse = Object.assign(new Array<unknown>(2), { extra: true })
  246. disguisedSparse[1] = 'value'
  247. expect(isRemoteJsonValue(disguisedSparse)).toBe(false)
  248. expect(isRemoteJsonValue([undefined])).toBe(false)
  249. const symbolic = { [Symbol('value')]: true }
  250. expect(isRemoteJsonValue(symbolic)).toBe(false)
  251. const hidden = {}
  252. Object.defineProperty(hidden, 'value', { value: true })
  253. expect(isRemoteJsonValue(hidden)).toBe(false)
  254. expect(isRemoteJsonValue({ nested: undefined })).toBe(false)
  255. })
  256. })
  257. describe('Remote stream client protocol', () => {
  258. it('rejects the removed logical-stream input message', () => {
  259. expect(() => parseRemoteStreamClientMessage(JSON.stringify({
  260. type: 'input', streamId: 'stream-1', value: { answer: true },
  261. }))).toThrow('api gateway: invalid Remote stream client message')
  262. })
  263. })