gateway-stream.host.spec.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083
  1. import { randomUUID } from 'node:crypto'
  2. import { once } from 'node:events'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import WebSocket, { type RawData } from 'ws'
  5. import { Context, Service, symbols } from '@deepseek-ai/cordis'
  6. import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
  7. import WebServer from '@deepseek-ai/dsh-host-webserver'
  8. import {
  9. bindTypertRemote,
  10. Remote,
  11. type InvocationDescriptor,
  12. type TypertContextMap,
  13. type TypertContextWire,
  14. TypertRemoteFailure,
  15. } from '@deepseek-ai/dsh-typert-protocol'
  16. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  17. import TypertGatewayService, {
  18. TypertGatewayError,
  19. type TypertRemoteEventDispatch,
  20. type TypertRemoteEventInvocation,
  21. type TypertRemoteEventOutcome,
  22. } from '@deepseek-ai/dsh-api-gateway'
  23. import { z } from 'zod'
  24. import type {
  25. RemoteEventClientId,
  26. RemoteEventInvocationFrame,
  27. } from '../src/stream-protocol.ts'
  28. vi.mock('node:crypto', async (importOriginal) => {
  29. const actual = await importOriginal<typeof import('node:crypto')>()
  30. return { ...actual, randomUUID: vi.fn(actual.randomUUID) }
  31. })
  32. const randomUuid = vi.mocked(randomUUID)
  33. type AgentWireId = TypertContextWire<TypertContextMap['agent']>
  34. const agentId = (value: string): AgentWireId => value as AgentWireId
  35. class FeedService extends Service {
  36. readonly typertRemote = bindTypertRemote(this, 'feed')
  37. readonly signals: AbortSignal[] = []
  38. returns = 0
  39. constructor(ctx: Context) {
  40. super(ctx, 'feed')
  41. }
  42. @Remote({ mode: 'stream' })
  43. async *follow(label: string, signal: AbortSignal): AsyncIterable<string> {
  44. this.signals.push(signal)
  45. try {
  46. yield `${label}:ready`
  47. await new Promise<void>((resolve) => {
  48. if (signal.aborted) resolve()
  49. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  50. })
  51. } finally {
  52. this.returns += 1
  53. }
  54. }
  55. @Remote({ mode: 'stream' })
  56. *sync(label: string): Iterable<string> {
  57. yield `${label}:one`
  58. yield `${label}:two`
  59. }
  60. @Remote({ mode: 'stream' })
  61. *invalid(): Iterable<string> {
  62. yield 42 as unknown as string
  63. }
  64. @Remote({ mode: 'stream' })
  65. *nonJson(): Iterable<unknown> {
  66. yield 1n
  67. }
  68. @Remote({ mode: 'stream' })
  69. missing(): Iterable<string> {
  70. return null as unknown as Iterable<string>
  71. }
  72. @Remote({ mode: 'stream' })
  73. *src(label: string): Iterable<string> {
  74. yield `${label}:src`
  75. }
  76. @Remote({ mode: 'stream' })
  77. abortBeforeOpen(signal: AbortSignal): Iterable<string> {
  78. if (signal.aborted) throw new Error('fixture observed pre-open cancellation')
  79. return []
  80. }
  81. @Remote({ mode: 'stream' })
  82. reject(): Iterable<string> {
  83. throw new TypertRemoteFailure({
  84. code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false },
  85. })
  86. }
  87. @Remote({ mode: 'stream' })
  88. rejectWithNonJsonDetails(): Iterable<string> {
  89. throw new TypertRemoteFailure({
  90. code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n },
  91. })
  92. }
  93. unary(label: string): string {
  94. return label
  95. }
  96. }
  97. const roots: Context[] = []
  98. class RemoteEventSourceProbe {
  99. readonly source = (signal: AbortSignal): AsyncIterable<TypertRemoteEventDispatch> => {
  100. this.signal = signal
  101. return this.iterate(signal)
  102. }
  103. signal: AbortSignal | undefined
  104. private readonly dispatches: TypertRemoteEventDispatch[] = []
  105. private wake: (() => void) | undefined
  106. push(dispatch: TypertRemoteEventDispatch): void {
  107. this.dispatches.push(dispatch)
  108. this.wake?.()
  109. this.wake = undefined
  110. }
  111. private async *iterate(signal: AbortSignal): AsyncGenerator<TypertRemoteEventDispatch> {
  112. const aborted = (): void => {
  113. this.wake?.()
  114. this.wake = undefined
  115. }
  116. signal.addEventListener('abort', aborted, { once: true })
  117. try {
  118. while (!signal.aborted) {
  119. while (this.dispatches.length > 0) {
  120. yield this.dispatches.shift() as TypertRemoteEventDispatch
  121. }
  122. if (signal.aborted) return
  123. await new Promise<void>((resolve) => { this.wake = resolve })
  124. this.wake = undefined
  125. }
  126. } finally {
  127. signal.removeEventListener('abort', aborted)
  128. }
  129. }
  130. }
  131. interface PendingInvocationProbe {
  132. readonly dispatch: TypertRemoteEventInvocation
  133. readonly outcome: Promise<TypertRemoteEventOutcome>
  134. readonly resolve: (outcome: TypertRemoteEventOutcome) => void
  135. readonly reject: (reason: unknown) => void
  136. }
  137. function pendingInvocation(
  138. context: Context,
  139. signal?: AbortSignal,
  140. prompt = 'ship',
  141. ): PendingInvocationProbe {
  142. const subject = { ctx: context }
  143. const settled = Promise.withResolvers<TypertRemoteEventOutcome>()
  144. const resolve = vi.fn((outcome: TypertRemoteEventOutcome) => {
  145. settled.resolve(outcome)
  146. })
  147. const reject = vi.fn((reason: unknown) => {
  148. settled.reject(reason)
  149. })
  150. return {
  151. dispatch: {
  152. event: 'fixture/approval',
  153. request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) },
  154. context: { value: context, subject },
  155. resolve,
  156. reject,
  157. },
  158. outcome: settled.promise,
  159. resolve,
  160. reject,
  161. }
  162. }
  163. afterEach(async () => {
  164. randomUuid.mockClear()
  165. await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
  166. })
  167. describe('Typert Remote streams', () => {
  168. it('opens decoded carrier payloads through the in-process wire adapter', async () => {
  169. const { ctx } = await setup(false)
  170. const source = await ctx.typertGateway.wireStream.open(
  171. 'feed/sync',
  172. { args: { label: 'wire' } },
  173. new AbortController().signal,
  174. )
  175. await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two'])
  176. })
  177. it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => {
  178. const { ctx, service } = await setup(false)
  179. const abort = new AbortController()
  180. const source = await ctx.typertGateway.stream({
  181. namespace: 'feed',
  182. method: 'follow',
  183. args: { label: 'a' },
  184. signal: abort.signal,
  185. })
  186. const iterator = source[Symbol.asyncIterator]()
  187. await expect(iterator.next()).resolves.toEqual({ done: false, value: 'a:ready' })
  188. const pending = iterator.next()
  189. abort.abort(new Error('fixture cancellation'))
  190. await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted')
  191. expect(service.signals).toEqual([abort.signal])
  192. expect(service.returns).toBe(1)
  193. await expect(collect(await ctx.typertGateway.stream({
  194. namespace: 'feed', method: 'sync', args: { label: 'b' },
  195. }))).resolves.toEqual(['b:one', 'b:two'])
  196. await expect(collect(await ctx.typertGateway.stream({
  197. namespace: 'feed', method: 'invalid', args: {},
  198. }))).resolves.toEqual([42])
  199. await expect(collect(await ctx.typertGateway.stream({
  200. namespace: 'feed', method: 'nonJson', args: {},
  201. }))).resolves.toEqual([1n])
  202. await expect(ctx.typertGateway.stream({
  203. namespace: 'feed', method: 'missing', args: {},
  204. })).rejects.toMatchObject({ code: 'result-invalid' })
  205. await expect(collect(await ctx.typertGateway.stream({
  206. namespace: 'feed', method: 'src', args: { label: 'c' },
  207. }))).resolves.toEqual(['c:src'])
  208. const abortedBeforeOpen = new AbortController()
  209. abortedBeforeOpen.abort(new Error('cancelled before open'))
  210. await expect(ctx.typertGateway.stream({
  211. namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal,
  212. })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted')
  213. const abortedBeforeIteration = new AbortController()
  214. abortedBeforeIteration.abort(new Error('cancelled before iteration'))
  215. const preCancelled = await ctx.typertGateway.stream({
  216. namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal,
  217. })
  218. await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted')
  219. })
  220. it('keeps unary and stream invocation modes distinct', async () => {
  221. const { ctx } = await setup(false)
  222. await expect(ctx.typertGateway.invoke({
  223. namespace: 'feed', method: 'sync', args: { label: 'a' },
  224. })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial<TypertGatewayError>)
  225. await expect(ctx.typertGateway.stream({
  226. namespace: 'feed', method: 'unary', args: { label: 'a' },
  227. })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial<TypertGatewayError>)
  228. })
  229. it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
  230. const { ctx, service } = await setup(true)
  231. const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
  232. await once(socket, 'open')
  233. const frames: Record<string, unknown>[] = []
  234. socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
  235. sendOpen(socket, 'a', 'feed/follow', { label: 'a' })
  236. sendOpen(socket, 'b', 'feed/follow', { label: 'b' })
  237. await vi.waitFor(() => {
  238. expect(frames).toEqual(expect.arrayContaining([
  239. { type: 'item', streamId: 'a', value: 'a:ready' },
  240. { type: 'item', streamId: 'b', value: 'b:ready' },
  241. ]))
  242. })
  243. expect(service.signals.map(signal => signal.aborted)).toEqual([false, false])
  244. expect(service.returns).toBe(0)
  245. socket.send(JSON.stringify({ type: 'cancel', streamId: 'a' }))
  246. await vi.waitFor(() => { expect(service.returns).toBe(1) })
  247. expect(service.signals[0]?.aborted).toBe(true)
  248. expect(service.signals[1]?.aborted).toBe(false)
  249. sendOpen(socket, 'sync', 'feed/sync', { label: 's' })
  250. sendOpen(socket, 'invalid', 'feed/invalid', {})
  251. sendOpen(socket, 'non-json', 'feed/nonJson', {})
  252. sendOpen(socket, 'rejected', 'feed/reject', {})
  253. await vi.waitFor(() => {
  254. expect(frames.filter(frame => frame.streamId === 'sync')).toEqual([
  255. { type: 'item', streamId: 'sync', value: 's:one' },
  256. { type: 'item', streamId: 'sync', value: 's:two' },
  257. { type: 'end', streamId: 'sync' },
  258. ])
  259. expect(frames.filter(frame => frame.streamId === 'invalid')).toEqual([
  260. { type: 'item', streamId: 'invalid', value: 42 },
  261. { type: 'end', streamId: 'invalid' },
  262. ])
  263. expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({
  264. type: 'error', error: { code: 'internal' },
  265. })
  266. expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({
  267. type: 'error',
  268. streamId: 'rejected',
  269. error: {
  270. code: 'fixture-rejected',
  271. message: 'fixture rejected the stream',
  272. details: { retryable: false },
  273. },
  274. })
  275. })
  276. const closed = once(socket, 'close')
  277. sendOpen(socket, 'broken-error', 'feed/rejectWithNonJsonDetails', {})
  278. const closeEvent = await closed
  279. expect(closeEvent[0]).toBe(1011)
  280. expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
  281. await vi.waitFor(() => { expect(service.returns).toBe(2) })
  282. expect(service.signals[1]?.aborted).toBe(true)
  283. })
  284. it('carries the registered Remote event source and withdraws its active stream', async () => {
  285. const { ctx } = await setup(true)
  286. let sourceSignal: AbortSignal | undefined
  287. const sourceClosed = vi.fn()
  288. const publish = Promise.withResolvers<undefined>()
  289. const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => {
  290. sourceSignal = signal
  291. return (async function *() {
  292. try {
  293. await publish.promise
  294. yield { event: 'fixture/changed', args: ['settings'] }
  295. await new Promise<void>((resolve) => {
  296. if (signal.aborted) resolve()
  297. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  298. })
  299. } finally {
  300. sourceClosed()
  301. }
  302. })()
  303. }
  304. const unregister = ctx.typertGateway.registerRemoteEvents(source)
  305. expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
  306. .toThrow('forwarded Remote event source is already registered')
  307. const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
  308. await once(socket, 'open')
  309. const frames: Record<string, unknown>[] = []
  310. socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
  311. sendOpen(socket, 'events', '$events', {})
  312. await vi.waitFor(() => {
  313. const eventFrames = frames.filter(frame => frame.streamId === 'events')
  314. expect(eventFrames).toHaveLength(1)
  315. expect(eventFrames[0]).toMatchObject({
  316. type: 'item', streamId: 'events', value: { type: 'ready' },
  317. })
  318. expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
  319. })
  320. publish.resolve(undefined)
  321. await vi.waitFor(() => {
  322. const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2)
  323. expect(eventFrames).toHaveLength(2)
  324. expect(eventFrames[0]).toMatchObject({
  325. type: 'item', streamId: 'events', value: { type: 'ready' },
  326. })
  327. expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
  328. expect(eventFrames[1]).toEqual({
  329. type: 'item', streamId: 'events', value: {
  330. type: 'emit', event: 'fixture/changed', args: ['settings'],
  331. },
  332. })
  333. })
  334. expect(sourceSignal?.aborted).toBe(false)
  335. await unregister()
  336. expect(sourceClosed).toHaveBeenCalledOnce()
  337. await vi.waitFor(() => {
  338. expect(sourceSignal?.aborted).toBe(true)
  339. expect(frames).toContainEqual({ type: 'end', streamId: 'events' })
  340. })
  341. const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source)
  342. await unregister()
  343. expect(() => { ctx.typertGateway.registerRemoteEvents(source) })
  344. .toThrow('forwarded Remote event source is already registered')
  345. await unregisterReplacement()
  346. socket.close()
  347. })
  348. it('rejects a scoped dispatch yielded after its Remote event source is withdrawn', async () => {
  349. const { ctx } = await setup(false)
  350. const publish = Promise.withResolvers<undefined>()
  351. const agent = ctx.extend()
  352. const pending = pendingInvocation(agent)
  353. const source = (): AsyncIterable<TypertRemoteEventDispatch> => (async function* () {
  354. await publish.promise
  355. yield pending.dispatch
  356. })()
  357. const unregister = ctx.typertGateway.registerRemoteEvents(source)
  358. const rejected = expect(pending.outcome).rejects.toThrow(
  359. 'forwarded Remote event source was removed',
  360. )
  361. publish.resolve(undefined)
  362. await unregister()
  363. await rejected
  364. expect(pending.reject).toHaveBeenCalledTimes(1)
  365. expect(pending.resolve).not.toHaveBeenCalled()
  366. })
  367. it('cancels a pending waterfall when its source rejects during removal', async () => {
  368. const { ctx } = await setup(true)
  369. const agent = ctx.extend()
  370. ctx.typert.contexts.registerHost('agent', {
  371. wire: 'agentId',
  372. wireTypeSymbol: '@fixture#AgentId',
  373. identity: candidate => candidate === agent ? agentId('agent-removal') : undefined,
  374. resolve: id => id === 'agent-removal' ? agent : undefined,
  375. })
  376. const pending = pendingInvocation(agent)
  377. const rejected = expect(pending.outcome).rejects.toThrow(
  378. 'forwarded Remote event source was removed',
  379. )
  380. const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () {
  381. yield pending.dispatch
  382. await new Promise<void>((resolve) => {
  383. if (signal.aborted) resolve()
  384. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  385. })
  386. throw new Error('fixture source rejected during removal')
  387. })())
  388. const client = await openEventClient(ctx, 'events-removal')
  389. await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
  390. await unregister()
  391. await rejected
  392. expect(pending.reject).toHaveBeenCalledTimes(1)
  393. expect(pending.resolve).not.toHaveBeenCalled()
  394. await vi.waitFor(() => {
  395. expect(client.frames).toContainEqual({ type: 'end', streamId: client.streamId })
  396. })
  397. client.socket.close()
  398. })
  399. it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => {
  400. const { ctx } = await setup(false)
  401. const source = new RemoteEventSourceProbe()
  402. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  403. for (const event of [42, ''] as const) {
  404. const invalidName = pendingInvocation(ctx)
  405. const rejected = expect(invalidName.outcome).rejects.toThrow(
  406. 'Remote event name must be a nonempty string',
  407. )
  408. source.push({
  409. ...invalidName.dispatch,
  410. event: event as unknown as string,
  411. })
  412. await rejected
  413. }
  414. const unavailable = pendingInvocation(ctx)
  415. source.push(unavailable.dispatch)
  416. await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' })
  417. expect(unavailable.reject).not.toHaveBeenCalled()
  418. let selected = ctx.extend()
  419. let identity: unknown = 1n
  420. ctx.typert.contexts.registerHost('agent', {
  421. wire: 'agentId',
  422. wireTypeSymbol: '@fixture#AgentId',
  423. identity: candidate => candidate === selected ? identity as AgentWireId : undefined,
  424. resolve: () => selected,
  425. })
  426. const nonJsonIdentity = pendingInvocation(selected)
  427. const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow(
  428. 'require a non-empty Agent identity',
  429. )
  430. source.push(nonJsonIdentity.dispatch)
  431. await nonJsonRejected
  432. identity = 'agent-invalid-request'
  433. const invalidRequest = pendingInvocation(selected)
  434. const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow(
  435. 'must carry its scoped Agent directly',
  436. )
  437. source.push({
  438. ...invalidRequest.dispatch,
  439. request: {},
  440. })
  441. await invalidRequestRejected
  442. const staleFiber = ctx.plugin(() => {})
  443. await staleFiber
  444. selected = staleFiber.ctx
  445. identity = 'agent-stale'
  446. await staleFiber.dispose()
  447. const stale = pendingInvocation(selected)
  448. source.push(stale.dispatch)
  449. await expect(stale.outcome).resolves.toEqual({ kind: 'next' })
  450. expect(stale.reject).not.toHaveBeenCalled()
  451. selected = ctx.extend()
  452. identity = 'agent-cancelled'
  453. const abort = new AbortController()
  454. abort.abort('fixture non-error cancellation')
  455. const cancelled = pendingInvocation(selected, abort.signal)
  456. const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({
  457. message: 'typert gateway: Remote event was cancelled',
  458. cause: 'fixture non-error cancellation',
  459. })
  460. source.push(cancelled.dispatch)
  461. await cancelledOutcome
  462. await unregister()
  463. })
  464. it('rejects notification arguments that are not lossless JSON arrays', async () => {
  465. const { ctx } = await setup(false)
  466. const frames = [
  467. { event: 'fixture/changed', args: {} },
  468. { event: 'fixture/changed', args: [1n] },
  469. ]
  470. for (const frame of frames) {
  471. let sourceSignal: AbortSignal | undefined
  472. const unregister = ctx.typertGateway.registerRemoteEvents((signal) => {
  473. sourceSignal = signal
  474. return (async function* () {
  475. yield frame as unknown as TypertRemoteEventDispatch
  476. })()
  477. })
  478. await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
  479. const reason: unknown = sourceSignal?.reason
  480. if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error')
  481. expect(reason.message).toContain('arguments are not lossless JSON data')
  482. await unregister()
  483. }
  484. })
  485. it('retries a colliding Remote event id before publishing the second waterfall', async () => {
  486. const { ctx } = await setup(false)
  487. const source = new RemoteEventSourceProbe()
  488. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  489. const agent = ctx.extend()
  490. ctx.typert.contexts.registerHost('agent', {
  491. wire: 'agentId',
  492. wireTypeSymbol: '@fixture#AgentId',
  493. identity: candidate => candidate === agent ? agentId('agent-collision') : undefined,
  494. resolve: id => id === 'agent-collision' ? agent : undefined,
  495. })
  496. const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType<typeof randomUUID>
  497. const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType<typeof randomUUID>
  498. randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
  499. const firstAbort = new AbortController()
  500. const secondAbort = new AbortController()
  501. const first = pendingInvocation(agent, firstAbort.signal, 'first')
  502. const second = pendingInvocation(agent, secondAbort.signal, 'second')
  503. source.push(first.dispatch)
  504. await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
  505. source.push(second.dispatch)
  506. await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(3) })
  507. const firstReason = new Error('cancel first collision fixture')
  508. const secondReason = new Error('cancel second collision fixture')
  509. const firstRejected = expect(first.outcome).rejects.toBe(firstReason)
  510. const secondRejected = expect(second.outcome).rejects.toBe(secondReason)
  511. firstAbort.abort(firstReason)
  512. secondAbort.abort(secondReason)
  513. await firstRejected
  514. await secondRejected
  515. await unregister()
  516. })
  517. it('retries a colliding Remote event Client id before opening the second generation', async () => {
  518. const { ctx } = await setup(true)
  519. const source = new RemoteEventSourceProbe()
  520. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  521. const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType<typeof randomUUID>
  522. const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType<typeof randomUUID>
  523. randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
  524. const first = await openEventClient(ctx, 'events-client-id-a')
  525. const second = await openEventClient(ctx, 'events-client-id-b')
  526. expect(first.clientId).toBe(firstId)
  527. expect(second.clientId).toBe(secondId)
  528. expect(randomUuid).toHaveBeenCalledTimes(3)
  529. first.socket.close()
  530. second.socket.close()
  531. await unregister()
  532. })
  533. it('fans one scoped waterfall out and accepts the first Client result', async () => {
  534. const { ctx } = await setup(true)
  535. const source = new RemoteEventSourceProbe()
  536. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  537. const agent = ctx.extend()
  538. ctx.typert.contexts.registerHost('agent', {
  539. wire: 'agentId',
  540. wireTypeSymbol: '@fixture#AgentId',
  541. identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
  542. resolve: id => id === 'agent-1' ? agent : undefined,
  543. })
  544. const first = await openEventClient(ctx, 'events-a')
  545. const second = await openEventClient(ctx, 'events-b')
  546. const pending = pendingInvocation(agent)
  547. source.push(pending.dispatch)
  548. await vi.waitFor(() => {
  549. expect(deliveredInvocation(first)).toBeDefined()
  550. expect(deliveredInvocation(second)).toBeDefined()
  551. })
  552. const firstFrame = deliveredInvocation(first)!
  553. const secondFrame = deliveredInvocation(second)!
  554. expect(firstFrame.eventId).toBe(secondFrame.eventId)
  555. expect(firstFrame).toMatchObject({
  556. type: 'waterfall',
  557. event: 'fixture/approval',
  558. agentId: 'agent-1',
  559. request: { prompt: 'ship' },
  560. })
  561. expect(firstFrame).not.toHaveProperty('deliveryId')
  562. expect(secondFrame).not.toHaveProperty('deliveryId')
  563. await sendEventResult(second, secondFrame, {
  564. kind: 'result', value: 'allowed',
  565. })
  566. await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
  567. await vi.waitFor(() => {
  568. expect(first.frames).toContainEqual({
  569. type: 'item',
  570. streamId: first.streamId,
  571. value: { type: 'cancel', eventId: firstFrame.eventId },
  572. })
  573. })
  574. await sendEventResult(first, firstFrame, {
  575. kind: 'result', value: 'rejected',
  576. })
  577. expect(pending.resolve).toHaveBeenCalledTimes(1)
  578. expect(pending.reject).not.toHaveBeenCalled()
  579. first.socket.close()
  580. second.socket.close()
  581. await unregister()
  582. })
  583. it('rejects the Host waterfall with the first Client listener rejection', async () => {
  584. const { ctx } = await setup(true)
  585. const source = new RemoteEventSourceProbe()
  586. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  587. const agent = ctx.extend()
  588. ctx.typert.contexts.registerHost('agent', {
  589. wire: 'agentId',
  590. wireTypeSymbol: '@fixture#AgentId',
  591. identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined,
  592. resolve: id => id === 'agent-rejected' ? agent : undefined,
  593. })
  594. const client = await openEventClient(ctx, 'events-rejected')
  595. const pending = pendingInvocation(agent)
  596. source.push(pending.dispatch)
  597. await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
  598. const frame = deliveredInvocation(client)!
  599. const rejected = expect(pending.outcome).rejects.toMatchObject({
  600. name: 'UserQuestionError',
  601. message: 'the user cancelled ask_user_question',
  602. code: 'ASK_CANCELLED',
  603. details: { questionId: 'question-1' },
  604. })
  605. await sendEventResult(client, frame, {
  606. kind: 'rejected',
  607. error: {
  608. name: 'UserQuestionError',
  609. message: 'the user cancelled ask_user_question',
  610. code: 'ASK_CANCELLED',
  611. details: { questionId: 'question-1' },
  612. },
  613. })
  614. await rejected
  615. expect(pending.reject).toHaveBeenCalledTimes(1)
  616. expect(pending.resolve).not.toHaveBeenCalled()
  617. client.socket.close()
  618. await unregister()
  619. })
  620. it('delegates to the Host only after every active Client returns next', async () => {
  621. const { ctx } = await setup(true)
  622. const source = new RemoteEventSourceProbe()
  623. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  624. const agent = ctx.extend()
  625. ctx.typert.contexts.registerHost('agent', {
  626. wire: 'agentId',
  627. wireTypeSymbol: '@fixture#AgentId',
  628. identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
  629. resolve: id => id === 'agent-1' ? agent : undefined,
  630. })
  631. const first = await openEventClient(ctx, 'events-next-a')
  632. const second = await openEventClient(ctx, 'events-next-b')
  633. const pending = pendingInvocation(agent)
  634. source.push(pending.dispatch)
  635. await vi.waitFor(() => {
  636. expect(deliveredInvocation(first)).toBeDefined()
  637. expect(deliveredInvocation(second)).toBeDefined()
  638. })
  639. const firstFrame = deliveredInvocation(first)!
  640. const secondFrame = deliveredInvocation(second)!
  641. await sendEventResult(first, firstFrame, { kind: 'next' })
  642. expect(pending.resolve).not.toHaveBeenCalled()
  643. await sendEventResult(second, secondFrame, { kind: 'next' })
  644. await expect(pending.outcome).resolves.toEqual({ kind: 'next' })
  645. expect(pending.resolve).toHaveBeenCalledTimes(1)
  646. expect(pending.reject).not.toHaveBeenCalled()
  647. first.socket.close()
  648. second.socket.close()
  649. await unregister()
  650. })
  651. it('delivers a pending waterfall to the first Client that connects', async () => {
  652. const { ctx } = await setup(true)
  653. const source = new RemoteEventSourceProbe()
  654. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  655. const agent = ctx.extend()
  656. ctx.typert.contexts.registerHost('agent', {
  657. wire: 'agentId',
  658. wireTypeSymbol: '@fixture#AgentId',
  659. identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined,
  660. resolve: id => id === 'agent-late-client' ? agent : undefined,
  661. })
  662. const pending = pendingInvocation(agent, undefined, 'before-connect')
  663. source.push(pending.dispatch)
  664. await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
  665. const client = await openEventClient(ctx, 'events-first-client')
  666. await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
  667. const frame = deliveredInvocation(client)!
  668. expect(frame).toMatchObject({
  669. type: 'waterfall',
  670. event: 'fixture/approval',
  671. agentId: 'agent-late-client',
  672. request: { prompt: 'before-connect' },
  673. })
  674. await sendEventResult(client, frame, { kind: 'result', value: 'allowed' })
  675. await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
  676. client.socket.close()
  677. await unregister()
  678. })
  679. it('replays a pending event id to a replacement Client generation', async () => {
  680. const { ctx } = await setup(true)
  681. const source = new RemoteEventSourceProbe()
  682. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  683. const agent = ctx.extend()
  684. ctx.typert.contexts.registerHost('agent', {
  685. wire: 'agentId',
  686. wireTypeSymbol: '@fixture#AgentId',
  687. identity: candidate => candidate === agent ? agentId('agent-1') : undefined,
  688. resolve: id => id === 'agent-1' ? agent : undefined,
  689. })
  690. const original = await openEventClient(ctx, 'events-original')
  691. const pending = pendingInvocation(agent)
  692. source.push(pending.dispatch)
  693. await vi.waitFor(() => { expect(deliveredInvocation(original)).toBeDefined() })
  694. const originalFrame = deliveredInvocation(original)!
  695. const closed = once(original.socket, 'close')
  696. original.socket.close()
  697. await closed
  698. const replacement = await openEventClient(ctx, 'events-replacement')
  699. await vi.waitFor(() => { expect(deliveredInvocation(replacement)).toBeDefined() })
  700. const replayed = deliveredInvocation(replacement)!
  701. expect(replayed.eventId).toBe(originalFrame.eventId)
  702. expect(replayed).not.toHaveProperty('deliveryId')
  703. await sendEventResult(replacement, replayed, {
  704. kind: 'result', value: 'allowed',
  705. })
  706. await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
  707. replacement.socket.close()
  708. await unregister()
  709. })
  710. it('cancels pending deliveries when the Host signal or Context ends', async () => {
  711. const { ctx } = await setup(true)
  712. const source = new RemoteEventSourceProbe()
  713. const unregister = ctx.typertGateway.registerRemoteEvents(source.source)
  714. const signalAgent = ctx.extend()
  715. const contextFiber = ctx.plugin(() => {})
  716. await contextFiber
  717. const contextAgent = contextFiber.ctx
  718. ctx.typert.contexts.registerHost('agent', {
  719. wire: 'agentId',
  720. wireTypeSymbol: '@fixture#AgentId',
  721. identity: (candidate) => {
  722. if (candidate === signalAgent) return agentId('agent-signal')
  723. if (candidate === contextAgent) return agentId('agent-context')
  724. return undefined
  725. },
  726. resolve: (id) => {
  727. if (id === 'agent-signal') return signalAgent
  728. if (id === 'agent-context') return contextAgent
  729. return undefined
  730. },
  731. })
  732. const client = await openEventClient(ctx, 'events-cancel')
  733. const abort = new AbortController()
  734. const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal')
  735. source.push(signalPending.dispatch)
  736. await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
  737. const signalFrame = deliveredInvocation(client)!
  738. expect(signalFrame).toMatchObject({
  739. type: 'waterfall',
  740. agentId: 'agent-signal',
  741. request: { prompt: 'signal' },
  742. })
  743. const signalReason = new Error('Host caller cancelled')
  744. const signalOutcome = expect(signalPending.outcome).rejects.toBe(signalReason)
  745. abort.abort(signalReason)
  746. await signalOutcome
  747. await vi.waitFor(() => {
  748. expect(client.frames).toContainEqual({
  749. type: 'item',
  750. streamId: client.streamId,
  751. value: { type: 'cancel', eventId: signalFrame.eventId },
  752. })
  753. })
  754. const contextPending = pendingInvocation(contextAgent, undefined, 'context')
  755. source.push(contextPending.dispatch)
  756. let contextFrame: RemoteEventInvocationFrame | undefined
  757. await vi.waitFor(() => {
  758. contextFrame = client.frames
  759. .filter(frame => frame.type === 'item' && frame.streamId === client.streamId)
  760. .map(frame => frame.value)
  761. .find(value => typeof value === 'object'
  762. && value !== null
  763. && Reflect.get(value, 'event') === 'fixture/approval'
  764. && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined
  765. expect(contextFrame).toBeDefined()
  766. })
  767. const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released')
  768. await contextFiber.dispose()
  769. await contextOutcome
  770. await vi.waitFor(() => {
  771. expect(client.frames).toContainEqual({
  772. type: 'item',
  773. streamId: client.streamId,
  774. value: { type: 'cancel', eventId: contextFrame!.eventId },
  775. })
  776. })
  777. client.socket.close()
  778. await unregister()
  779. })
  780. it('validates the internal Remote event request and reports an absent source', async () => {
  781. const { ctx } = await setup(true)
  782. const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
  783. await once(socket, 'open')
  784. const frames: Record<string, unknown>[] = []
  785. socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
  786. sendOpen(socket, 'missing', '$events', {})
  787. await vi.waitFor(() => {
  788. expect(frames.find(frame => frame.streamId === 'missing')?.type).toBe('error')
  789. expect(streamErrorMessage(frames, 'missing')).toContain('source is unavailable')
  790. })
  791. let sourceCalls = 0
  792. const unregister = ctx.typertGateway.registerRemoteEvents(() => {
  793. sourceCalls += 1
  794. return (async function *(): AsyncIterable<never> {})()
  795. })
  796. const invalidPayloads: readonly unknown[] = [
  797. null,
  798. [],
  799. {},
  800. { other: {} },
  801. { args: null },
  802. { args: [] },
  803. { args: { extra: true } },
  804. ]
  805. invalidPayloads.forEach((payload, index) => {
  806. socket.send(JSON.stringify({
  807. type: 'open', streamId: `invalid-${String(index)}`, endpoint: '$events', payload,
  808. }))
  809. })
  810. await vi.waitFor(() => {
  811. expect(frames.filter(frame => String(frame.streamId).startsWith('invalid-'))).toHaveLength(invalidPayloads.length)
  812. })
  813. for (const [index] of invalidPayloads.entries()) {
  814. const streamId = `invalid-${String(index)}`
  815. expect(frames.find(frame => frame.streamId === streamId)?.type).toBe('error')
  816. expect(streamErrorMessage(frames, streamId)).toContain('requires an empty args object')
  817. }
  818. expect(sourceCalls).toBe(1)
  819. await unregister()
  820. socket.close()
  821. })
  822. it('applies Connection trusted-host policy before accepting the Gateway socket', async () => {
  823. const { ctx } = await setup(true)
  824. const socket = new WebSocket(
  825. `ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`,
  826. { headers: { host: 'untrusted.example' } },
  827. )
  828. socket.on('error', () => {})
  829. const responseEvent: unknown[] = await once(socket, 'unexpected-response')
  830. const request = responseEvent[0]
  831. const response = responseEvent[1]
  832. const rejected = response as { statusCode?: number; resume(): void }
  833. expect(rejected.statusCode).toBe(403)
  834. rejected.resume()
  835. ;(request as { abort(): void }).abort()
  836. })
  837. })
  838. async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
  839. const ctx = new Context()
  840. roots.push(ctx)
  841. if (transport) {
  842. await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 })
  843. }
  844. await ctx.plugin(TypertRegistry)
  845. await ctx.plugin(TypertGatewayService)
  846. if (transport) {
  847. await ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
  848. }
  849. await ctx.plugin(FeedService)
  850. ctx.typert.register({
  851. package: '@fixture/feed',
  852. face: 'host',
  853. schemas: [],
  854. model: { services: [], events: [], objects: [] },
  855. invocations: descriptors(),
  856. })
  857. const receiver = ctx.get('feed') as unknown as FeedService & { [symbols.original]?: FeedService }
  858. return { ctx, service: receiver[symbols.original] ?? receiver }
  859. }
  860. function descriptors(): InvocationDescriptor[] {
  861. const label = {
  862. name: 'label',
  863. wire: 'label',
  864. source: 'json' as const,
  865. codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', schema: z.string() },
  866. }
  867. const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({
  868. id: `@fixture/feed#feed/${method}`,
  869. service: 'feed',
  870. namespace: 'feed',
  871. method,
  872. mode: 'stream',
  873. invocation: { kind: 'direct' },
  874. parameters,
  875. result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema },
  876. })
  877. return [
  878. { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
  879. stream('sync', [label], z.string()),
  880. stream('invalid', [], z.string()),
  881. stream('nonJson', [], z.unknown()),
  882. stream('missing', [], z.string()),
  883. { ...stream('abortBeforeOpen', [], z.string()), cancellation: { parameter: 'signal' } },
  884. stream('reject', [], z.string()),
  885. stream('rejectWithNonJsonDetails', [], z.string()),
  886. {
  887. id: '@fixture/feed#feed/unary',
  888. service: 'feed',
  889. namespace: 'feed',
  890. method: 'unary',
  891. invocation: { kind: 'direct' },
  892. parameters: [label],
  893. result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema: z.string() },
  894. },
  895. ]
  896. }
  897. interface RemoteEventTestClient {
  898. readonly socket: WebSocket
  899. readonly frames: Record<string, unknown>[]
  900. readonly streamId: string
  901. readonly clientId: RemoteEventClientId
  902. readonly origin: string
  903. }
  904. async function openEventClient(ctx: Context, streamId: string): Promise<RemoteEventTestClient> {
  905. const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
  906. const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`)
  907. await once(socket, 'open')
  908. const frames: Record<string, unknown>[] = []
  909. socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
  910. sendOpen(socket, streamId, '$events', {})
  911. let clientId: RemoteEventClientId | undefined
  912. await vi.waitFor(() => {
  913. const ready = frames.find(frame => frame.type === 'item'
  914. && frame.streamId === streamId
  915. && typeof frame.value === 'object'
  916. && frame.value !== null
  917. && Reflect.get(frame.value, 'type') === 'ready')
  918. const candidate: unknown = ready === undefined ? undefined : Reflect.get(ready.value as object, 'clientId')
  919. expect(typeof candidate).toBe('string')
  920. if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId
  921. })
  922. if (clientId === undefined) throw new Error('Remote event stream omitted its Client id')
  923. return { socket, frames, streamId, clientId, origin }
  924. }
  925. function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined {
  926. for (const frame of client.frames) {
  927. if (frame.type !== 'item' || frame.streamId !== client.streamId) continue
  928. const value = frame.value
  929. if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'eventId')) continue
  930. return value as RemoteEventInvocationFrame
  931. }
  932. return undefined
  933. }
  934. async function sendEventResult(
  935. client: RemoteEventTestClient,
  936. frame: RemoteEventInvocationFrame,
  937. outcome:
  938. | { readonly kind: 'next' }
  939. | { readonly kind: 'result'; readonly value?: unknown }
  940. | {
  941. readonly kind: 'rejected'
  942. readonly error: {
  943. readonly name: string
  944. readonly message: string
  945. readonly code?: string
  946. readonly details?: unknown
  947. }
  948. },
  949. ): Promise<void> {
  950. const rpcId = `remote-event-result-${client.streamId}`
  951. const response = await fetch(`${client.origin}/api/$events/result`, {
  952. method: 'POST',
  953. headers: { 'content-type': 'application/json' },
  954. body: JSON.stringify({
  955. type: 'client-request',
  956. rpcId,
  957. method: '$events/result',
  958. payload: {
  959. args: { clientId: client.clientId, eventId: frame.eventId, outcome },
  960. },
  961. }),
  962. })
  963. expect(response.status).toBe(200)
  964. const body = await response.json() as { readonly result?: { readonly ok?: boolean; readonly error?: { message?: string } } }
  965. if (body.result?.ok !== true) {
  966. throw new Error(body.result?.error?.message ?? 'Remote event result failed')
  967. }
  968. }
  969. function sendOpen(socket: WebSocket, streamId: string, endpoint: string, args: object): void {
  970. socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }))
  971. }
  972. function rawText(data: RawData): string {
  973. if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
  974. if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
  975. return Buffer.from(data).toString('utf8')
  976. }
  977. function streamErrorMessage(frames: readonly Record<string, unknown>[], streamId: string): string | undefined {
  978. const error = frames.find(frame => frame.streamId === streamId)?.error
  979. if (typeof error !== 'object' || error === null) return undefined
  980. const message = Reflect.get(error, 'message') as unknown
  981. return typeof message === 'string' ? message : undefined
  982. }
  983. async function collect(source: AsyncIterable<unknown>): Promise<unknown[]> {
  984. const values: unknown[] = []
  985. for await (const value of source) values.push(value)
  986. return values
  987. }