gateway.host.spec.ts 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448
  1. import { createServer } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import { describe, expect, it } from 'vitest'
  4. import { Context, Service, symbols } from '@deepseek-ai/cordis'
  5. import { z } from 'zod'
  6. import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
  7. import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
  8. import {
  9. bindTypertRemote,
  10. Remote,
  11. RemoteScope,
  12. TypertLookupFailure,
  13. type InvocationDescriptor,
  14. type TypertContext,
  15. type TypertLookup,
  16. type TypertLookupProvider,
  17. } from '@deepseek-ai/dsh-typert-protocol'
  18. import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry'
  19. import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway'
  20. interface FixtureAgent {
  21. readonly id: string
  22. }
  23. interface MarkedContext extends Context {
  24. readonly fixtureScope?: string
  25. }
  26. declare module '@deepseek-ai/dsh-typert-protocol' {
  27. interface TypertLookupMap {
  28. gatewayFixture: TypertLookup<FixtureAgent, string>
  29. gatewayFixtureAlias: TypertLookup<FixtureAgent, string>
  30. }
  31. interface TypertContextMap {
  32. gatewayFixture: TypertContext<string>
  33. }
  34. }
  35. const emptyModel: TypertContribution['model'] = {
  36. services: [],
  37. events: [],
  38. objects: [],
  39. }
  40. class GoalService extends Service {
  41. readonly typertRemote = bindTypertRemote(this, 'goals')
  42. readonly calls: string[] = []
  43. lastSignal: AbortSignal | undefined
  44. nextResult: unknown = undefined
  45. businessError: Error | undefined
  46. constructor(ctx: Context) {
  47. super(ctx, 'goals')
  48. }
  49. @Remote
  50. create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown {
  51. this.calls.push('create')
  52. this.lastSignal = signal
  53. return {
  54. agentId: agent.id,
  55. title: request.title,
  56. scope: (this.ctx as MarkedContext).fixtureScope ?? 'root',
  57. }
  58. }
  59. @RemoteScope('gatewayFixture')
  60. rename(request: { readonly title: string }): unknown {
  61. this.calls.push('rename')
  62. return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' }
  63. }
  64. @Remote
  65. passthrough(value: unknown): unknown {
  66. this.calls.push('passthrough')
  67. return this.nextResult === undefined ? value : this.nextResult
  68. }
  69. @Remote
  70. maybe(value: string | null | undefined): string | null | undefined {
  71. this.calls.push('maybe')
  72. return value
  73. }
  74. @Remote
  75. fail(request: unknown): never {
  76. void request
  77. this.calls.push('fail')
  78. throw this.businessError ?? new Error('fixture business failure')
  79. }
  80. strictOnly(request: { readonly title: string }): unknown {
  81. this.calls.push('strictOnly')
  82. return this.nextResult === undefined ? request : this.nextResult
  83. }
  84. }
  85. type FakeRpcResult =
  86. | { readonly ok: true; readonly value: unknown }
  87. | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } }
  88. type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<FakeRpcResult>
  89. class FakeConnectionService extends Service {
  90. channel: string | undefined
  91. authority: string | undefined
  92. matches: ((endpoint: string) => boolean) | undefined
  93. handler: FakeRpcHandler | undefined
  94. constructor(ctx: Context) {
  95. super(ctx, 'connection')
  96. }
  97. get rpc() {
  98. const owner = this.ctx
  99. return {
  100. intercept: (
  101. channel: string,
  102. matches: (endpoint: string) => boolean,
  103. handler: FakeRpcHandler,
  104. options: { readonly authority: string },
  105. ) =>
  106. owner.effect(() => {
  107. this.channel = channel
  108. this.authority = options.authority
  109. this.matches = matches
  110. this.handler = handler
  111. return () => {
  112. this.channel = undefined
  113. this.authority = undefined
  114. this.matches = undefined
  115. this.handler = undefined
  116. }
  117. }),
  118. }
  119. }
  120. }
  121. function fakeHttpServer(routes: WebRoute[]): Pick<WebServer, 'register' | 'tapIndex' | 'port'> {
  122. return {
  123. register(route) {
  124. if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
  125. throw new Error(`duplicate route ${route.path}`)
  126. }
  127. routes.push(route)
  128. return () => { routes.splice(routes.indexOf(route), 1) }
  129. },
  130. tapIndex: () => () => {},
  131. port: 0,
  132. }
  133. }
  134. async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; close(): Promise<void> }> {
  135. const server = createServer((request, response) => {
  136. void route.handler(request, response)
  137. })
  138. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  139. const address = server.address() as AddressInfo
  140. return {
  141. origin: `http://127.0.0.1:${String(address.port)}`,
  142. close: () => new Promise<void>((resolve, reject) => {
  143. server.close((error) => {
  144. if (error === undefined || error === null) resolve()
  145. else reject(error)
  146. })
  147. }),
  148. }
  149. }
  150. class FirstSharedService extends Service {
  151. readonly typertRemote = bindTypertRemote(this, 'firstShared', { namespace: 'shared' })
  152. constructor(ctx: Context) {
  153. super(ctx, 'firstShared')
  154. }
  155. @Remote
  156. run(value: string): string {
  157. return value
  158. }
  159. }
  160. class SecondSharedService extends Service {
  161. readonly typertRemote = bindTypertRemote(this, 'secondShared', { namespace: 'shared' })
  162. constructor(ctx: Context) {
  163. super(ctx, 'secondShared')
  164. }
  165. @Remote
  166. run(value: string): string {
  167. return value
  168. }
  169. }
  170. class DefaultParameterService extends Service {
  171. readonly typertRemote = bindTypertRemote(this, 'defaultParameter', { namespace: 'invalid-default' })
  172. constructor(ctx: Context) {
  173. super(ctx, 'defaultParameter')
  174. }
  175. @Remote
  176. run(value = 'fallback'): string {
  177. return value
  178. }
  179. }
  180. class DestructuredParameterService extends Service {
  181. readonly typertRemote = bindTypertRemote(this, 'destructuredParameter', { namespace: 'invalid-destructure' })
  182. constructor(ctx: Context) {
  183. super(ctx, 'destructuredParameter')
  184. }
  185. @Remote
  186. run({ value }: { readonly value: string }): string {
  187. return value
  188. }
  189. }
  190. class RestParameterService extends Service {
  191. readonly typertRemote = bindTypertRemote(this, 'restParameter', { namespace: 'invalid-rest' })
  192. constructor(ctx: Context) {
  193. super(ctx, 'restParameter')
  194. }
  195. @Remote
  196. run(...values: readonly unknown[]): string {
  197. return values.map(String).join(',')
  198. }
  199. }
  200. class NonFinalSignalService extends Service {
  201. readonly typertRemote = bindTypertRemote(this, 'nonFinalSignal', { namespace: 'invalid-signal' })
  202. constructor(ctx: Context) {
  203. super(ctx, 'nonFinalSignal')
  204. }
  205. @Remote
  206. run(signal: AbortSignal, value: string): string {
  207. return signal.aborted ? '' : value
  208. }
  209. }
  210. class WrongBindingService extends Service {
  211. readonly typertRemote = bindTypertRemote(this, 'notWrongBinding', { namespace: 'wrong-binding' })
  212. constructor(ctx: Context) {
  213. super(ctx, 'wrongBinding')
  214. }
  215. @Remote
  216. run(value: string): string {
  217. return value
  218. }
  219. }
  220. class ExportedMethodService extends Service {
  221. readonly typertRemote = bindTypertRemote(this, 'exportedMethod', { namespace: 'exported' })
  222. constructor(ctx: Context) {
  223. super(ctx, 'exportedMethod')
  224. }
  225. @Remote('execute')
  226. run(value: string): string {
  227. return value
  228. }
  229. }
  230. class EmptyMethodService extends Service {
  231. readonly typertRemote = bindTypertRemote(this, 'emptyMethod', { namespace: 'empty' })
  232. constructor(ctx: Context) {
  233. super(ctx, 'emptyMethod')
  234. }
  235. @Remote
  236. ping(): string {
  237. return 'pong'
  238. }
  239. }
  240. class CollidingWireService extends Service {
  241. readonly typertRemote = bindTypertRemote(this, 'collidingWire', { namespace: 'colliding-wire' })
  242. constructor(ctx: Context) {
  243. super(ctx, 'collidingWire')
  244. }
  245. @Remote
  246. run(agent: FixtureAgent, agentId: string): string {
  247. return `${agent.id}:${agentId}`
  248. }
  249. }
  250. class ContextWireService extends Service {
  251. readonly typertRemote = bindTypertRemote(this, 'contextWire', { namespace: 'context-wire' })
  252. constructor(ctx: Context) {
  253. super(ctx, 'contextWire')
  254. }
  255. @RemoteScope('gatewayFixture')
  256. run(agentId: string): string {
  257. return agentId
  258. }
  259. }
  260. class NoBindingService extends Service {
  261. constructor(ctx: Context) {
  262. super(ctx, 'noBinding')
  263. }
  264. run(value: string): string {
  265. return value
  266. }
  267. }
  268. class ObservedClaimService extends Service {
  269. private readonly binding = bindTypertRemote(this, 'observedClaim', { namespace: 'observed-claim' })
  270. bindingReads = 0
  271. constructor(ctx: Context) {
  272. super(ctx, 'observedClaim')
  273. }
  274. get typertRemote() {
  275. this.bindingReads += 1
  276. return this.binding
  277. }
  278. @Remote
  279. run(value: string): string {
  280. return value
  281. }
  282. }
  283. class MissingMethodService extends Service {
  284. readonly typertRemote = bindTypertRemote(this, 'missingMethod', { namespace: 'missing-method' })
  285. constructor(ctx: Context) {
  286. super(ctx, 'missingMethod')
  287. }
  288. @Remote
  289. run(value: string): string {
  290. return value
  291. }
  292. }
  293. class InheritedMethodBase extends Service {
  294. readonly typertRemote = bindTypertRemote(this, 'inheritedMethod', { namespace: 'inherited' })
  295. constructor(ctx: Context) {
  296. super(ctx, 'inheritedMethod')
  297. }
  298. @Remote
  299. run(value: string): string {
  300. return value
  301. }
  302. }
  303. class InheritedMethodService extends InheritedMethodBase {}
  304. describe('TypertGatewayService', () => {
  305. it('invokes a strict direct method with schema decoding and a live lookup', async () => {
  306. const { ctx, service } = await setup()
  307. const agent = { id: 'agent-1' }
  308. registerAgentLookup(ctx, agent)
  309. registerStrict(ctx, [createDescriptor()])
  310. const caller = ctx.extend({ fixtureScope: 'direct-caller' })
  311. const abort = new AbortController()
  312. await expect(caller.typertGateway.invoke({
  313. namespace: 'goals',
  314. method: 'create',
  315. args: { agentId: 'agent-1', request: { title: ' ship ' } },
  316. signal: abort.signal,
  317. })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' })
  318. expect(service.calls).toEqual(['create'])
  319. expect(service.lastSignal).toBe(abort.signal)
  320. await expect(caller.typertGateway.invoke({
  321. namespace: 'goals',
  322. method: 'create',
  323. args: { agentId: 'agent-1', request: { title: 'again' } },
  324. })).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' })
  325. expect(service.lastSignal).toBeInstanceOf(AbortSignal)
  326. expect(service.lastSignal?.aborted).toBe(false)
  327. })
  328. it('resolves strict Remote Scope identity without adding a business argument', async () => {
  329. const { ctx, service } = await setup()
  330. const scoped = ctx.extend({ fixtureScope: 'agent-scope' })
  331. ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
  332. registerStrict(ctx, [renameDescriptor()])
  333. await expect(ctx.typertGateway.invoke({
  334. namespace: 'goals',
  335. method: 'rename',
  336. args: { agentId: 'agent-1', request: { title: 'land' } },
  337. })).resolves.toEqual({ title: 'land', scope: 'agent-scope' })
  338. expect(service.calls).toEqual(['rename'])
  339. })
  340. it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => {
  341. const { ctx, service } = await setup()
  342. const agent = { id: 'agent-1' }
  343. registerAgentLookup(ctx, agent)
  344. const caller = ctx.extend({ fixtureScope: 'direct-src' })
  345. const abort = new AbortController()
  346. await expect(caller.typertGateway.invoke({
  347. namespace: 'goals',
  348. method: 'create',
  349. args: { agentId: 'agent-1', request: { title: 'ship' } },
  350. signal: abort.signal,
  351. })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
  352. expect(service.lastSignal).toBe(abort.signal)
  353. })
  354. it('does not downgrade an observed SRC lookup after its provider unloads', async () => {
  355. const { ctx, service } = await setup()
  356. const dispose = registerAgentLookup(ctx, { id: 'agent-1' })
  357. await dispose()
  358. await expectCode(ctx.typertGateway.invoke({
  359. namespace: 'goals',
  360. method: 'create',
  361. args: { agentId: 'agent-1', request: { title: 'ship' } },
  362. }), 'lookup-unavailable')
  363. expect(service.calls).toEqual([])
  364. })
  365. it('derives SRC Remote Scope identity and preserves the scoped Proxy receiver', async () => {
  366. const { ctx } = await setup()
  367. const scoped = ctx.extend({ fixtureScope: 'agent-src' })
  368. ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
  369. await expect(ctx.typertGateway.invoke({
  370. namespace: 'goals',
  371. method: 'rename',
  372. args: { agentId: 'agent-1', request: { title: 'land' } },
  373. })).resolves.toEqual({ title: 'land', scope: 'agent-src' })
  374. })
  375. it('derives exported, empty, inherited, and distinct-namespace SRC methods', async () => {
  376. const ctx = await setupGateway()
  377. await ctx.plugin(ExportedMethodService)
  378. await ctx.plugin(EmptyMethodService)
  379. await ctx.plugin(InheritedMethodService)
  380. await expect(ctx.typertGateway.invoke({
  381. namespace: 'exported', method: 'execute', args: { value: 'ship' },
  382. })).resolves.toBe('ship')
  383. await expect(ctx.typertGateway.invoke({
  384. namespace: 'empty', method: 'ping', args: {},
  385. })).resolves.toBe('pong')
  386. await expect(ctx.typertGateway.invoke({
  387. namespace: 'inherited', method: 'run', args: { value: 'land' },
  388. })).resolves.toBe('land')
  389. await expectCode(ctx.typertGateway.invoke({
  390. namespace: 'other', method: 'absent', args: {},
  391. }), 'invocation-unavailable')
  392. })
  393. it('rejects SRC wire collisions and unavailable Context providers', async () => {
  394. const colliding = await setupGateway()
  395. await colliding.plugin(CollidingWireService)
  396. registerAgentLookup(colliding, { id: 'agent-1' })
  397. await expectCode(colliding.typertGateway.invoke({
  398. namespace: 'colliding-wire',
  399. method: 'run',
  400. args: { agentId: 'agent-1' },
  401. }), 'signature-invalid')
  402. const missing = await setup()
  403. await expectCode(missing.ctx.typertGateway.invoke({
  404. namespace: 'goals',
  405. method: 'rename',
  406. args: { agentId: 'agent-1', request: { title: 'land' } },
  407. }), 'context-unavailable')
  408. const contextCollision = await setupGateway()
  409. await contextCollision.plugin(ContextWireService)
  410. contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend()))
  411. await expectCode(contextCollision.typertGateway.invoke({
  412. namespace: 'context-wire',
  413. method: 'run',
  414. args: { agentId: 'agent-1' },
  415. }), 'signature-invalid')
  416. })
  417. it('re-reads Service and providers on every strict invocation', async () => {
  418. const { ctx, serviceFiber } = await setup()
  419. const agent = { id: 'agent-1' }
  420. const disposeLookup = registerAgentLookup(ctx, agent)
  421. registerStrict(ctx, [createDescriptor()])
  422. await disposeLookup()
  423. await expectCode(ctx.typertGateway.invoke({
  424. namespace: 'goals',
  425. method: 'create',
  426. args: { agentId: 'agent-1', request: { title: 'ship' } },
  427. }), 'lookup-unavailable')
  428. registerAgentLookup(ctx, agent)
  429. await serviceFiber.dispose()
  430. await expectCode(ctx.typertGateway.invoke({
  431. namespace: 'goals',
  432. method: 'create',
  433. args: { agentId: 'agent-1', request: { title: 'ship' } },
  434. }), 'service-unavailable')
  435. })
  436. it('re-reads and contains Context providers', async () => {
  437. const { ctx } = await setup()
  438. const scoped = ctx.extend()
  439. const dispose = ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
  440. registerStrict(ctx, [renameDescriptor()])
  441. await dispose()
  442. await expectCode(ctx.typertGateway.invoke({
  443. namespace: 'goals',
  444. method: 'rename',
  445. args: { agentId: 'agent-1', request: { title: 'land' } },
  446. }), 'context-unavailable')
  447. ctx.typert.contexts.registerHost('gatewayFixture', {
  448. ...contextProvider(scoped),
  449. resolve: () => { throw new Error('provider failed') },
  450. })
  451. const error = await expectCode(ctx.typertGateway.invoke({
  452. namespace: 'goals',
  453. method: 'rename',
  454. args: { agentId: 'agent-1', request: { title: 'land' } },
  455. }), 'context-failed')
  456. expect(error.cause).toEqual(new Error('provider failed'))
  457. })
  458. it('preserves a Host Context policy rejection for the active RPC adapter', async () => {
  459. const { ctx } = await setup()
  460. const rejection = new TypertLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } })
  461. ctx.typert.contexts.registerHost('gatewayFixture', {
  462. ...contextProvider(ctx.extend()),
  463. resolve: async () => { throw rejection },
  464. })
  465. registerStrict(ctx, [renameDescriptor()])
  466. await expect(ctx.typertGateway.invoke({
  467. namespace: 'goals',
  468. method: 'rename',
  469. args: { agentId: 'agent-1', request: { title: 'land' } },
  470. })).rejects.toBe(rejection)
  471. })
  472. it('reports Context provider metadata mismatch and unresolved identities', async () => {
  473. const { ctx } = await setup()
  474. registerStrict(ctx, [renameDescriptor()])
  475. const scoped = ctx.extend()
  476. const mismatch = ctx.typert.contexts.registerHost('gatewayFixture', {
  477. ...contextProvider(scoped),
  478. wire: 'differentAgentId',
  479. })
  480. await expectCode(ctx.typertGateway.invoke({
  481. namespace: 'goals',
  482. method: 'rename',
  483. args: { agentId: 'agent-1', request: { title: 'land' } },
  484. }), 'provider-mismatch')
  485. await mismatch()
  486. ctx.typert.contexts.registerHost('gatewayFixture', {
  487. ...contextProvider(scoped),
  488. resolve: () => undefined,
  489. })
  490. await expectCode(ctx.typertGateway.invoke({
  491. namespace: 'goals',
  492. method: 'rename',
  493. args: { agentId: 'agent-1', request: { title: 'land' } },
  494. }), 'context-not-found')
  495. })
  496. it('contains lookup provider failures and missing identities', async () => {
  497. const { ctx } = await setup()
  498. registerStrict(ctx, [createDescriptor()])
  499. const throwing = ctx.typert.lookups.register('gatewayFixture', {
  500. ...agentLookup({ id: 'agent-1' }),
  501. resolve: async () => { throw new Error('lookup failed') },
  502. })
  503. const failure = await expectCode(ctx.typertGateway.invoke({
  504. namespace: 'goals',
  505. method: 'create',
  506. args: { agentId: 'agent-1', request: { title: 'ship' } },
  507. }), 'lookup-failed')
  508. expect(failure.cause).toEqual(new Error('lookup failed'))
  509. await throwing()
  510. const missing = ctx.typert.lookups.register('gatewayFixture', {
  511. ...agentLookup({ id: 'agent-1' }),
  512. resolve: () => Promise.resolve(undefined),
  513. })
  514. await expectCode(ctx.typertGateway.invoke({
  515. namespace: 'goals',
  516. method: 'create',
  517. args: { agentId: 'agent-1', request: { title: 'ship' } },
  518. }), 'lookup-not-found')
  519. await missing()
  520. ctx.typert.lookups.register('gatewayFixture', {
  521. ...agentLookup({ id: 'agent-1' }),
  522. resolve: async id => ({ id }),
  523. })
  524. await expect(ctx.typertGateway.invoke({
  525. namespace: 'goals',
  526. method: 'create',
  527. args: { agentId: 'agent-1', request: { title: 'ship' } },
  528. })).resolves.toMatchObject({ agentId: 'agent-1', title: 'ship' })
  529. })
  530. it('never downgrades an observed strict endpoint after definition disposal', async () => {
  531. const { ctx } = await setup()
  532. const dispose = registerStrict(ctx, [passthroughDescriptor()])
  533. await dispose()
  534. await expectCode(ctx.typertGateway.invoke({
  535. namespace: 'goals',
  536. method: 'passthrough',
  537. args: { value: 'would pass through SRC' },
  538. }), 'definition-unavailable')
  539. })
  540. it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => {
  541. const ctx = new Context()
  542. await ctx.plugin(TypertRegistry)
  543. const dispose = registerStrict(ctx, [passthroughDescriptor()])
  544. await ctx.plugin(TypertGatewayService)
  545. await ctx.plugin(GoalService)
  546. await dispose()
  547. await expectCode(ctx.typertGateway.invoke({
  548. namespace: 'goals',
  549. method: 'passthrough',
  550. args: { value: 'would pass through SRC' },
  551. }), 'definition-unavailable')
  552. })
  553. it('retains the no-downgrade guard across Gateway Service reloads', async () => {
  554. const ctx = new Context()
  555. await ctx.plugin(TypertRegistry)
  556. const gatewayFiber = ctx.plugin(TypertGatewayService)
  557. await gatewayFiber
  558. await ctx.plugin(GoalService)
  559. const dispose = registerStrict(ctx, [passthroughDescriptor()])
  560. await dispose()
  561. await gatewayFiber.dispose()
  562. await ctx.plugin(TypertGatewayService)
  563. await expectCode(ctx.typertGateway.invoke({
  564. namespace: 'goals',
  565. method: 'passthrough',
  566. args: { value: 'would pass through SRC' },
  567. }), 'definition-unavailable')
  568. })
  569. it('rejects ambiguous SRC endpoints independently of reflection order', async () => {
  570. const ctx = await setupGateway()
  571. await ctx.plugin(FirstSharedService)
  572. await ctx.plugin(SecondSharedService)
  573. const error = await expectCode(ctx.typertGateway.invoke({
  574. namespace: 'shared',
  575. method: 'run',
  576. args: { value: 'ship' },
  577. }), 'ambiguous-endpoint')
  578. expect(error.message).toContain('firstShared, secondShared')
  579. })
  580. it('rejects SRC signatures that cannot map one wire field to each position', async () => {
  581. const cases = [
  582. { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } },
  583. { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } },
  584. { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } },
  585. { plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } },
  586. ] as const
  587. for (const testCase of cases) {
  588. const ctx = await setupGateway()
  589. await ctx.plugin(testCase.plugin)
  590. await expectCode(ctx.typertGateway.invoke({
  591. namespace: testCase.namespace,
  592. method: 'run',
  593. args: testCase.args,
  594. }), 'signature-invalid')
  595. }
  596. })
  597. it('rejects a SRC parameter matching more than one lookup provider', async () => {
  598. const { ctx } = await setup()
  599. const provider = agentLookup({ id: 'agent-1' })
  600. ctx.typert.lookups.register('gatewayFixture', provider)
  601. ctx.typert.lookups.register('gatewayFixtureAlias', provider)
  602. await expectCode(ctx.typertGateway.invoke({
  603. namespace: 'goals',
  604. method: 'create',
  605. args: { agentId: 'agent-1', request: { title: 'ship' } },
  606. }), 'signature-invalid')
  607. })
  608. it('requires exact wire fields before invoking business code', async () => {
  609. const { ctx, service } = await setup()
  610. registerAgentLookup(ctx, { id: 'agent-1' })
  611. await expectCode(ctx.typertGateway.invoke({
  612. namespace: 'goals',
  613. method: 'create',
  614. args: { request: { title: 'ship' } },
  615. }), 'arguments-invalid')
  616. await expectCode(ctx.typertGateway.invoke({
  617. namespace: 'goals',
  618. method: 'create',
  619. args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true },
  620. }), 'arguments-invalid')
  621. await expectCode(ctx.typertGateway.invoke({
  622. namespace: 'goals',
  623. method: 'create',
  624. args: [] as unknown as Record<string, unknown>,
  625. }), 'arguments-invalid')
  626. expect(service.calls).toEqual([])
  627. })
  628. it('validates strict input without decoding the business result', async () => {
  629. const { ctx, service } = await setup()
  630. registerStrict(ctx, [strictOnlyDescriptor()])
  631. await expectCode(ctx.typertGateway.invoke({
  632. namespace: 'goals',
  633. method: 'strictOnly',
  634. args: { request: { title: 1 } },
  635. }), 'input-invalid')
  636. service.nextResult = { title: 1 }
  637. await expect(ctx.typertGateway.invoke({
  638. namespace: 'goals',
  639. method: 'strictOnly',
  640. args: { request: { title: 'ship' } },
  641. })).resolves.toEqual({ title: 1 })
  642. })
  643. it('does not inspect non-JSON business results', async () => {
  644. const { ctx, service } = await setup()
  645. registerStrict(ctx, [strictOnlyDescriptor()])
  646. service.nextResult = 1n
  647. await expect(ctx.typertGateway.invoke({
  648. namespace: 'goals',
  649. method: 'strictOnly',
  650. args: { request: { title: 'ship' } },
  651. })).resolves.toBe(1n)
  652. })
  653. it.each([
  654. undefined,
  655. Number.NaN,
  656. Number.POSITIVE_INFINITY,
  657. 1n,
  658. Symbol('value'),
  659. () => 'value',
  660. new Date(0),
  661. new Map(),
  662. [, 'sparse'],
  663. ])('rejects non-JSON SRC input %#', async (value) => {
  664. const { ctx } = await setup()
  665. await expectCode(ctx.typertGateway.invoke({
  666. namespace: 'goals',
  667. method: 'passthrough',
  668. args: { value },
  669. }), 'input-invalid')
  670. })
  671. it('admits an omitted SRC field and hands the Host method undefined', async () => {
  672. const { ctx, service } = await setup()
  673. // A weak descriptor reads parameter names from the JavaScript signature and
  674. // cannot see which are optional, so an absent field is admitted; the case
  675. // above keeps an explicitly undefined field rejected.
  676. await expect(ctx.typertGateway.invoke({
  677. namespace: 'goals',
  678. method: 'passthrough',
  679. args: {},
  680. })).resolves.toBeUndefined()
  681. expect(service.calls).toContain('passthrough')
  682. })
  683. it('rejects cyclic SRC input without inspecting SRC results', async () => {
  684. const { ctx, service } = await setup()
  685. const cyclic: { self?: unknown } = {}
  686. cyclic.self = cyclic
  687. await expectCode(ctx.typertGateway.invoke({
  688. namespace: 'goals',
  689. method: 'passthrough',
  690. args: { value: cyclic },
  691. }), 'input-invalid')
  692. const result = new Date(0)
  693. service.nextResult = result
  694. await expect(ctx.typertGateway.invoke({
  695. namespace: 'goals',
  696. method: 'passthrough',
  697. args: { value: null },
  698. })).resolves.toBe(result)
  699. })
  700. it('accepts dense JSON and rejects decorated arrays and object properties', async () => {
  701. const { ctx } = await setup()
  702. await expect(ctx.typertGateway.invoke({
  703. namespace: 'goals',
  704. method: 'passthrough',
  705. args: { value: [1, { nested: true }] },
  706. })).resolves.toEqual([1, { nested: true }])
  707. const sparseWithExtra = Array(1) as unknown[] & { extra?: boolean }
  708. sparseWithExtra.extra = true
  709. const symbolArray = [1]
  710. Object.defineProperty(symbolArray, Symbol('extra'), { value: true })
  711. const symbolObject = { value: true }
  712. Object.defineProperty(symbolObject, Symbol('extra'), { value: true })
  713. const hidden = {}
  714. Object.defineProperty(hidden, 'value', { value: true, enumerable: false })
  715. const accessor = {}
  716. Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true })
  717. for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) {
  718. await expectCode(ctx.typertGateway.invoke({
  719. namespace: 'goals', method: 'passthrough', args: { value },
  720. }), 'input-invalid')
  721. }
  722. })
  723. it('validates strict provider identity against generated wire metadata', async () => {
  724. const { ctx } = await setup()
  725. ctx.typert.lookups.register('gatewayFixture', {
  726. ...agentLookup({ id: 'agent-1' }),
  727. wire: 'differentAgentId',
  728. })
  729. registerStrict(ctx, [createDescriptor()])
  730. await expectCode(ctx.typertGateway.invoke({
  731. namespace: 'goals',
  732. method: 'create',
  733. args: { agentId: 'agent-1', request: { title: 'ship' } },
  734. }), 'provider-mismatch')
  735. })
  736. it('validates binding identity and active method availability', async () => {
  737. const ctx = await setupGateway()
  738. await ctx.plugin(WrongBindingService)
  739. await expectCode(ctx.typertGateway.invoke({
  740. namespace: 'wrong-binding',
  741. method: 'run',
  742. args: { value: 'ship' },
  743. }), 'binding-invalid')
  744. await ctx.plugin(GoalService)
  745. registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }])
  746. await expectCode(ctx.typertGateway.invoke({
  747. namespace: 'goals',
  748. method: 'missing',
  749. args: { value: 'ship' },
  750. }), 'method-unavailable')
  751. })
  752. it('requires a visible binding and supports explicitly provided plain Services', async () => {
  753. const ctx = await setupGateway()
  754. await ctx.plugin(NoBindingService)
  755. registerStrict(ctx, [{
  756. ...passthroughDescriptor(),
  757. id: '@fixture/gateway#no-binding/run',
  758. service: 'noBinding',
  759. namespace: 'no-binding',
  760. method: 'run',
  761. }])
  762. await expectCode(ctx.typertGateway.invoke({
  763. namespace: 'no-binding', method: 'run', args: { value: 'ship' },
  764. }), 'binding-invalid')
  765. const plain: {
  766. typertRemote?: ReturnType<typeof bindTypertRemote>
  767. run(value: string): string
  768. } = { run: value => value }
  769. plain.typertRemote = bindTypertRemote(plain, 'plainRemote', { namespace: 'plain' })
  770. ctx.provide('plainRemote', plain)
  771. ctx.typert.register({
  772. package: '@fixture/plain',
  773. face: 'host',
  774. schemas: [],
  775. model: emptyModel,
  776. invocations: [{
  777. ...passthroughDescriptor(),
  778. id: '@fixture/plain#plain/run',
  779. service: 'plainRemote',
  780. namespace: 'plain',
  781. method: 'run',
  782. }],
  783. })
  784. await expect(ctx.typertGateway.invoke({
  785. namespace: 'plain', method: 'run', args: { value: 'land' },
  786. })).resolves.toBe('land')
  787. })
  788. it('reports a SRC marker whose prototype implementation disappeared', async () => {
  789. const ctx = await setupGateway()
  790. await ctx.plugin(MissingMethodService)
  791. const descriptor = Object.getOwnPropertyDescriptor(MissingMethodService.prototype, 'run')!
  792. Object.defineProperty(MissingMethodService.prototype, 'run', {
  793. configurable: true,
  794. value: 42,
  795. })
  796. try {
  797. await expectCode(ctx.typertGateway.invoke({
  798. namespace: 'missing-method', method: 'run', args: { value: 'ship' },
  799. }), 'method-unavailable')
  800. } finally {
  801. Object.defineProperty(MissingMethodService.prototype, 'run', descriptor)
  802. }
  803. })
  804. it('preserves business exception identity after invocation begins', async () => {
  805. const { ctx, service } = await setup()
  806. const failure = new Error('business identity')
  807. service.businessError = failure
  808. await expect(ctx.typertGateway.invoke({
  809. namespace: 'goals',
  810. method: 'fail',
  811. args: { request: { reason: 'fixture' } },
  812. })).rejects.toBe(failure)
  813. })
  814. it('reports an absent endpoint without retaining receiver state', async () => {
  815. const { ctx } = await setup()
  816. await expectCode(ctx.typertGateway.invoke({
  817. namespace: 'goals',
  818. method: 'absent',
  819. args: {},
  820. }), 'invocation-unavailable')
  821. })
  822. it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => {
  823. const ctx = new Context().extend({ fixtureScope: 'rpc-caller' })
  824. await ctx.plugin(TypertRegistry)
  825. await ctx.plugin(FakeConnectionService)
  826. const gatewayFiber = ctx.plugin(TypertGatewayService)
  827. await gatewayFiber
  828. await ctx.plugin(GoalService)
  829. const connection = rawConnection(ctx)
  830. expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
  831. registerAgentLookup(ctx, { id: 'agent-1' })
  832. registerStrict(ctx, [createDescriptor(), maybeDescriptor()])
  833. expect(connection.matches?.('goals/create')).toBe(true)
  834. expect(connection.matches?.('goals/passthrough')).toBe(true)
  835. expect(connection.matches?.('goals')).toBe(false)
  836. expect(connection.matches?.('goals/missing')).toBe(false)
  837. expect(connection.matches?.('legacy/list')).toBe(false)
  838. const abort = new AbortController()
  839. const signal = abort.signal
  840. const handler = connection.handler
  841. if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
  842. await expect(handler('goals/create', {
  843. args: { agentId: 'agent-1', request: { title: 'ship' } },
  844. }, signal)).resolves.toEqual({
  845. ok: true,
  846. value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' },
  847. })
  848. const service = rawGoalService(ctx)
  849. expect(service.lastSignal).toBe(signal)
  850. abort.abort(new Error('client disconnected'))
  851. expect(service.lastSignal?.aborted).toBe(true)
  852. const invalid = await handler('goals/create', { invalid: true }, signal)
  853. expect(invalid).toMatchObject({
  854. ok: false,
  855. error: { code: 'internal' },
  856. })
  857. if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
  858. expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
  859. await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({
  860. ok: true,
  861. value: undefined,
  862. })
  863. await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({
  864. ok: true,
  865. value: null,
  866. })
  867. for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
  868. const result = await handler(endpoint, { args: {} }, signal)
  869. expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
  870. if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded')
  871. expect(result.error.message).toContain('invalid Remote endpoint')
  872. }
  873. for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) {
  874. const result = await handler('goals/create', payload, signal)
  875. expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
  876. if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
  877. expect(result.error.message).toContain('plain-object args field')
  878. }
  879. service.businessError = 'non-error failure' as unknown as Error
  880. await expect(handler(
  881. 'goals/fail',
  882. { args: { request: null } },
  883. new AbortController().signal,
  884. )).resolves.toEqual({
  885. ok: false,
  886. error: { code: 'internal', message: 'non-error failure', details: {} },
  887. })
  888. // A business rejection observed while the carrier signal is already aborted
  889. // is the caller's cancellation, not an internal gateway fault.
  890. const cancelledCall = new AbortController()
  891. cancelledCall.abort(new Error('client disconnected'))
  892. service.businessError = new Error('fixture business failure')
  893. await expect(handler(
  894. 'goals/fail',
  895. { args: { request: null } },
  896. cancelledCall.signal,
  897. )).resolves.toEqual({
  898. ok: false,
  899. error: {
  900. code: 'cancelled',
  901. message: 'Remote invocation "goals/fail" was aborted',
  902. details: {},
  903. },
  904. })
  905. await gatewayFiber.dispose()
  906. expect(connection.handler).toBeUndefined()
  907. })
  908. it('claims and validates in-process Remote event results for the active Client generation', async () => {
  909. const ctx = new Context()
  910. await ctx.plugin(TypertRegistry)
  911. await ctx.plugin(FakeConnectionService)
  912. await ctx.plugin(TypertGatewayService)
  913. const connection = rawConnection(ctx)
  914. const handler = connection.handler
  915. if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
  916. expect(connection.matches?.('$events/result')).toBe(true)
  917. const result = {
  918. args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } },
  919. }
  920. const inactive = await handler('$events/result', result, new AbortController().signal)
  921. expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } })
  922. if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded')
  923. expect(inactive.error.message).toContain('identifies no active event stream')
  924. const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () {
  925. await new Promise<void>((resolve) => {
  926. if (signal.aborted) resolve()
  927. else signal.addEventListener('abort', () => { resolve() }, { once: true })
  928. })
  929. })())
  930. const carrier = new AbortController()
  931. const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal)
  932. const opening = await events.next()
  933. expect(opening).toMatchObject({ done: false, value: { type: 'ready' } })
  934. if (opening.done) throw new Error('Remote event stream ended before ready')
  935. const clientId: unknown = Reflect.get(opening.value as object, 'clientId')
  936. if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id')
  937. for (const payload of [null, [], {}, { other: {} }]) {
  938. const invalid = await handler('$events/result', payload, carrier.signal)
  939. expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } })
  940. if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded')
  941. expect(invalid.error.message).toContain('requires exactly one plain-object args field')
  942. }
  943. await expect(handler('$events/result', {
  944. args: { clientId, eventId: 'missing', outcome: { kind: 'next' } },
  945. }, carrier.signal)).resolves.toEqual({
  946. ok: true,
  947. value: undefined,
  948. })
  949. await events.return(undefined)
  950. await unregister()
  951. await ctx.fiber.dispose()
  952. })
  953. it('preserves a lookup policy rejection through the Connection RPC result', async () => {
  954. const ctx = new Context()
  955. await ctx.plugin(TypertRegistry)
  956. await ctx.plugin(FakeConnectionService)
  957. await ctx.plugin(TypertGatewayService)
  958. await ctx.plugin(GoalService)
  959. registerStrict(ctx, [createDescriptor()])
  960. const failure = {
  961. code: 'agent-busy',
  962. message: 'session is owned by subagent routing',
  963. details: { reason: 'use subagent delivery for this child session' },
  964. }
  965. ctx.typert.lookups.register('gatewayFixture', {
  966. ...agentLookup({ id: 'agent-1' }),
  967. resolve: () => { throw new TypertLookupFailure(failure) },
  968. })
  969. const handler = rawConnection(ctx).handler
  970. if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
  971. await expect(handler('goals/create', {
  972. args: { agentId: 'agent-1', request: { title: 'ship' } },
  973. }, new AbortController().signal)).resolves.toEqual({ ok: false, error: failure })
  974. })
  975. it('caches SRC ownership until the Cordis Service set changes', async () => {
  976. const ctx = new Context()
  977. await ctx.plugin(TypertRegistry)
  978. await ctx.plugin(FakeConnectionService)
  979. await ctx.plugin(TypertGatewayService)
  980. const observedFiber = ctx.plugin(ObservedClaimService)
  981. await observedFiber
  982. const connection = rawConnection(ctx)
  983. const observed = ctx.get('observedClaim') as unknown as ObservedClaimService & {
  984. [symbols.original]?: ObservedClaimService
  985. }
  986. const service = observed[symbols.original] ?? observed
  987. expect(connection.matches?.('legacy/list')).toBe(false)
  988. expect(connection.matches?.('legacy/list')).toBe(false)
  989. expect(service.bindingReads).toBe(1)
  990. expect(connection.matches?.('observed-claim/run')).toBe(true)
  991. expect(connection.matches?.('observed-claim/run')).toBe(true)
  992. expect(service.bindingReads).toBe(1)
  993. const unrelatedFiber = ctx.plugin(NoBindingService)
  994. await unrelatedFiber
  995. expect(connection.matches?.('legacy/list')).toBe(false)
  996. expect(service.bindingReads).toBe(2)
  997. await observedFiber.dispose()
  998. expect(connection.matches?.('observed-claim/run')).toBe(false)
  999. await unrelatedFiber.dispose()
  1000. })
  1001. it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => {
  1002. const ctx = new Context().extend({ fixtureScope: 'http-caller' })
  1003. const routes: WebRoute[] = []
  1004. ctx.provide('webServer', fakeHttpServer(routes) as WebServer)
  1005. const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
  1006. await connectionFiber
  1007. await ctx.plugin(TypertRegistry)
  1008. const gatewayFiber = ctx.plugin(TypertGatewayService)
  1009. await gatewayFiber
  1010. const goalFiber = ctx.plugin(GoalService)
  1011. await goalFiber
  1012. const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' })
  1013. const removeStrict = registerStrict(ctx, [createDescriptor()])
  1014. let strictActive = true
  1015. expect(routes).toHaveLength(1)
  1016. const server = await serveRoute(routes[0]!)
  1017. try {
  1018. const response = await fetch(`${server.origin}/api/goals/create`, {
  1019. method: 'POST',
  1020. headers: { 'content-type': 'application/json' },
  1021. body: JSON.stringify({
  1022. type: 'client-request',
  1023. rpcId: 'rpc-http',
  1024. method: 'goals/create',
  1025. payload: { args: { agentId: 'agent-1', request: { title: ' ship ' } } },
  1026. }),
  1027. })
  1028. expect(response.status).toBe(200)
  1029. await expect(response.json()).resolves.toEqual({
  1030. type: 'server-response',
  1031. rpcId: 'rpc-http',
  1032. result: {
  1033. ok: true,
  1034. value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' },
  1035. },
  1036. })
  1037. const invalid = await fetch(`${server.origin}/api/goals/create`, {
  1038. method: 'POST',
  1039. headers: { 'content-type': 'application/json' },
  1040. body: JSON.stringify({
  1041. type: 'client-request',
  1042. rpcId: 'rpc-invalid',
  1043. method: 'goals/create',
  1044. payload: { invalid: true },
  1045. }),
  1046. })
  1047. expect(invalid.status).toBe(200)
  1048. const invalidBody = await invalid.json() as unknown
  1049. expect(invalidBody).toMatchObject({
  1050. type: 'server-response',
  1051. rpcId: 'rpc-invalid',
  1052. result: {
  1053. ok: false,
  1054. error: { code: 'internal' },
  1055. },
  1056. })
  1057. expect(JSON.stringify(invalidBody)).toContain('plain-object args field')
  1058. await removeStrict()
  1059. strictActive = false
  1060. const withdrawn = await fetch(`${server.origin}/api/goals/create`, {
  1061. method: 'POST',
  1062. headers: { 'content-type': 'application/json' },
  1063. body: JSON.stringify({
  1064. type: 'client-request',
  1065. rpcId: 'rpc-withdrawn',
  1066. method: 'goals/create',
  1067. payload: { args: { agentId: 'agent-1', request: { title: 'ship' } } },
  1068. }),
  1069. })
  1070. expect(withdrawn.status).toBe(200)
  1071. const withdrawnBody = await withdrawn.json() as unknown
  1072. expect(withdrawnBody).toMatchObject({
  1073. type: 'server-response',
  1074. rpcId: 'rpc-withdrawn',
  1075. result: {
  1076. ok: false,
  1077. error: { code: 'internal' },
  1078. },
  1079. })
  1080. expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn')
  1081. const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' })
  1082. expect(unclaimed.status).toBe(404)
  1083. } finally {
  1084. await server.close()
  1085. if (strictActive) await removeStrict()
  1086. await removeLookup()
  1087. await goalFiber.dispose()
  1088. await gatewayFiber.dispose()
  1089. await connectionFiber.dispose()
  1090. }
  1091. expect(routes).toHaveLength(0)
  1092. })
  1093. })
  1094. async function setup(): Promise<{
  1095. readonly ctx: Context
  1096. readonly service: GoalService
  1097. readonly serviceFiber: ReturnType<Context['plugin']>
  1098. }> {
  1099. const ctx = await setupGateway()
  1100. const serviceFiber = ctx.plugin(GoalService)
  1101. await serviceFiber
  1102. return { ctx, service: rawGoalService(ctx), serviceFiber }
  1103. }
  1104. async function setupGateway(): Promise<Context> {
  1105. const ctx = new Context()
  1106. await ctx.plugin(TypertRegistry)
  1107. await ctx.plugin(TypertGatewayService)
  1108. return ctx
  1109. }
  1110. function rawGoalService(ctx: Context): GoalService {
  1111. const receiver = ctx.get('goals') as unknown as GoalService & { [symbols.original]?: GoalService }
  1112. return receiver[symbols.original] ?? receiver
  1113. }
  1114. function rawConnection(ctx: Context): FakeConnectionService {
  1115. const receiver = ctx.get('connection') as unknown as FakeConnectionService & {
  1116. [symbols.original]?: FakeConnectionService
  1117. }
  1118. return receiver[symbols.original] ?? receiver
  1119. }
  1120. interface GatewayEventHarness {
  1121. openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator
  1122. }
  1123. function rawGatewayEventHarness(ctx: Context): GatewayEventHarness {
  1124. const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & {
  1125. [symbols.original]?: GatewayEventHarness
  1126. }
  1127. return receiver[symbols.original] ?? receiver
  1128. }
  1129. function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise<void> {
  1130. return ctx.typert.register({
  1131. package: '@fixture/gateway',
  1132. face: 'host',
  1133. schemas: [],
  1134. model: emptyModel,
  1135. invocations: descriptors,
  1136. })
  1137. }
  1138. function registerAgentLookup(ctx: Context, agent: FixtureAgent): () => Promise<void> {
  1139. return ctx.typert.lookups.register('gatewayFixture', agentLookup(agent))
  1140. }
  1141. function agentLookup(agent: FixtureAgent): TypertLookupProvider<FixtureAgent, string> {
  1142. return {
  1143. parameter: 'agent',
  1144. wire: 'agentId',
  1145. hostTypeSymbol: '@fixture/domain#Agent',
  1146. wireTypeSymbol: '@fixture/domain#AgentId',
  1147. resolve: id => id === agent.id ? agent : undefined,
  1148. }
  1149. }
  1150. function contextProvider(context: Context) {
  1151. return {
  1152. wire: 'agentId',
  1153. wireTypeSymbol: '@fixture/domain#AgentId',
  1154. identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined,
  1155. resolve: (id: string) => id === 'agent-1' ? context : undefined,
  1156. }
  1157. }
  1158. function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] {
  1159. return { mode: 'strict', typeSymbol, schema }
  1160. }
  1161. function createDescriptor(): InvocationDescriptor {
  1162. return {
  1163. id: '@fixture/gateway#goals/create',
  1164. service: 'goals',
  1165. namespace: 'goals',
  1166. method: 'create',
  1167. invocation: { kind: 'direct' },
  1168. parameters: [
  1169. {
  1170. name: 'agent',
  1171. wire: 'agentId',
  1172. source: 'lookup',
  1173. lookup: 'gatewayFixture',
  1174. codec: strictCodec('@fixture/domain#AgentId', z.string()),
  1175. },
  1176. {
  1177. name: 'request',
  1178. wire: 'request',
  1179. source: 'json',
  1180. codec: strictCodec('@fixture/gateway#CreateRequest', z.object({
  1181. title: z.string().transform(value => value.trim()),
  1182. })),
  1183. },
  1184. ],
  1185. cancellation: { parameter: 'signal' },
  1186. result: strictCodec('@fixture/gateway#CreateResult', z.object({
  1187. agentId: z.string(),
  1188. title: z.string(),
  1189. scope: z.string(),
  1190. })),
  1191. }
  1192. }
  1193. function renameDescriptor(): InvocationDescriptor {
  1194. return {
  1195. id: '@fixture/gateway#goals/rename',
  1196. service: 'goals',
  1197. namespace: 'goals',
  1198. method: 'rename',
  1199. invocation: {
  1200. kind: 'context',
  1201. context: 'gatewayFixture',
  1202. wire: 'agentId',
  1203. codec: strictCodec('@fixture/domain#AgentId', z.string()),
  1204. },
  1205. parameters: [{
  1206. name: 'request',
  1207. wire: 'request',
  1208. source: 'json',
  1209. codec: strictCodec('@fixture/gateway#RenameRequest', z.object({ title: z.string() })),
  1210. }],
  1211. result: strictCodec('@fixture/gateway#RenameResult', z.object({
  1212. title: z.string(),
  1213. scope: z.string(),
  1214. })),
  1215. }
  1216. }
  1217. function passthroughDescriptor(): InvocationDescriptor {
  1218. return {
  1219. id: '@fixture/gateway#goals/passthrough',
  1220. service: 'goals',
  1221. namespace: 'goals',
  1222. method: 'passthrough',
  1223. invocation: { kind: 'direct' },
  1224. parameters: [{
  1225. name: 'value',
  1226. wire: 'value',
  1227. source: 'json',
  1228. codec: { mode: 'src-json' },
  1229. }],
  1230. result: { mode: 'src-json' },
  1231. }
  1232. }
  1233. function strictOnlyDescriptor(): InvocationDescriptor {
  1234. const value = strictCodec('@fixture/gateway#StrictValue', z.object({ title: z.string() }))
  1235. return {
  1236. id: '@fixture/gateway#goals/strictOnly',
  1237. service: 'goals',
  1238. namespace: 'goals',
  1239. method: 'strictOnly',
  1240. invocation: { kind: 'direct' },
  1241. parameters: [{ name: 'request', wire: 'request', source: 'json', codec: value }],
  1242. result: value,
  1243. }
  1244. }
  1245. function maybeDescriptor(): InvocationDescriptor {
  1246. const value = strictCodec(
  1247. '@fixture/gateway#MaybeValue',
  1248. z.union([z.string(), z.null(), z.undefined()]),
  1249. )
  1250. return {
  1251. id: '@fixture/gateway#goals/maybe',
  1252. service: 'goals',
  1253. namespace: 'goals',
  1254. method: 'maybe',
  1255. invocation: { kind: 'direct' },
  1256. parameters: [{
  1257. name: 'value',
  1258. wire: 'value',
  1259. source: 'json',
  1260. acceptsUndefined: true,
  1261. codec: value,
  1262. }],
  1263. result: value,
  1264. }
  1265. }
  1266. async function expectCode(
  1267. promise: Promise<unknown>,
  1268. code: TypertGatewayError['code'],
  1269. ): Promise<TypertGatewayError> {
  1270. try {
  1271. await promise
  1272. } catch (error) {
  1273. expect(error).toBeInstanceOf(TypertGatewayError)
  1274. expect(error).toMatchObject({ code })
  1275. return error as TypertGatewayError
  1276. }
  1277. throw new Error(`expected TypertGatewayError ${code}`)
  1278. }