gateway.spec.ts 42 KB

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