client.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import { Context } from 'cordis'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { z } from 'zod'
  4. import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
  5. import type {
  6. InvocationDescriptor,
  7. TypeRTContext,
  8. TypeRTRemoteContextApi,
  9. TypeRTRemoteNamespace,
  10. } from '@deepseek-ai/dsh-type-meta'
  11. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  12. import { apply, inject } from '../src/client/index.ts'
  13. declare module '@deepseek-ai/dsh-type-meta' {
  14. interface TypeRTContextMap {
  15. fixture: TypeRTContext<string>
  16. }
  17. interface TypeRTRemoteMap {
  18. 'goals/create': (
  19. agentId: string,
  20. request: { readonly objective: string },
  21. signal?: AbortSignal,
  22. ) => Promise<{ readonly ref: string }>
  23. }
  24. interface TypeRTRemoteContextMap {
  25. 'fixture:goals/create': (
  26. request: { readonly objective: string },
  27. signal?: AbortSignal,
  28. ) => Promise<{ readonly ref: string }>
  29. 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
  30. }
  31. interface TypeRTRemoteNamespaceMap {
  32. goals: TypeRTRemoteNamespace<'goals'>
  33. }
  34. }
  35. type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'>
  36. const idSchema = z.string().min(1)
  37. const requestSchema = z.object({ objective: z.string().min(1) })
  38. const createResultSchema = z.object({ ref: z.string().min(1) })
  39. const renameResultSchema = z.object({ renamed: z.boolean() })
  40. function directDescriptor(): InvocationDescriptor {
  41. return {
  42. id: '@fixture/goals#goals/create',
  43. service: 'goals',
  44. namespace: 'goals',
  45. method: 'create',
  46. invocation: { kind: 'direct' },
  47. scope: { context: 'fixture', wire: 'agentId' },
  48. parameters: [{
  49. name: 'agent',
  50. wire: 'agentId',
  51. source: 'lookup',
  52. lookup: 'fixture',
  53. codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
  54. }, {
  55. name: 'request',
  56. wire: 'request',
  57. source: 'json',
  58. codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
  59. }],
  60. cancellation: { parameter: 'signal' },
  61. result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
  62. }
  63. }
  64. function contextDescriptor(): InvocationDescriptor {
  65. return {
  66. id: '@fixture/goals#goals/rename',
  67. service: 'goals',
  68. namespace: 'goals',
  69. method: 'rename',
  70. invocation: {
  71. kind: 'context',
  72. context: 'fixture',
  73. wire: 'agentId',
  74. codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
  75. },
  76. parameters: [{
  77. name: 'request',
  78. wire: 'request',
  79. source: 'json',
  80. codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
  81. }],
  82. result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
  83. }
  84. }
  85. async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
  86. const ctx = new Context()
  87. await ctx.plugin(TypertRegistry)
  88. ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
  89. await ctx.plugin({ inject, apply })
  90. return ctx
  91. }
  92. describe('Client TypeRT API', () => {
  93. it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
  94. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  95. .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
  96. const ctx = await bench(call)
  97. let retained: typeof ctx.api.goals.create | undefined
  98. const assembly = ctx.plugin(Object.assign(
  99. (scope: Context) => {
  100. scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
  101. retained = scope.api.goals.create
  102. },
  103. { inject: ['api'] },
  104. ))
  105. await assembly
  106. await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
  107. expect(call).toHaveBeenCalledWith(
  108. '/api',
  109. 'goals/create',
  110. { args: { agentId: 'agent-1', request: { objective: 'ship' } } },
  111. expect.any(AbortSignal),
  112. )
  113. const callerAbort = new AbortController()
  114. await expect(ctx.api.goals.create(
  115. 'agent-1',
  116. { objective: 'cancel me' },
  117. callerAbort.signal,
  118. )).resolves.toEqual({ ref: 'goal-1' })
  119. const combinedSignal = call.mock.calls.at(-1)?.[3]
  120. expect(combinedSignal).toBeInstanceOf(AbortSignal)
  121. expect(combinedSignal).not.toBe(callerAbort.signal)
  122. const cancellation = new Error('caller cancelled')
  123. callerAbort.abort(cancellation)
  124. expect(combinedSignal?.aborted).toBe(true)
  125. expect(combinedSignal?.reason).toBe(cancellation)
  126. await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
  127. call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
  128. await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
  129. await assembly.dispose()
  130. expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
  131. expect(ctx.get('goals')).toBeUndefined()
  132. expect(ctx.typert.remotes.list()).toEqual([])
  133. await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
  134. })
  135. it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
  136. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  137. .mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
  138. const ctx = await bench(call)
  139. const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
  140. ctx.typert.contexts.registerClient('fixture', {
  141. identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
  142. })
  143. const assembly = ctx.plugin(Object.assign(
  144. (scope: Context) => {
  145. scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
  146. },
  147. { inject: ['api'] },
  148. ))
  149. await assembly
  150. await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
  151. expect(call).toHaveBeenCalledWith(
  152. '/api',
  153. 'goals/create',
  154. { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
  155. expect.any(AbortSignal),
  156. )
  157. await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' }))
  158. .rejects.toThrow('requires a "fixture" Context')
  159. await assembly.dispose()
  160. expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
  161. expect(ctx.get('goals')).toBeUndefined()
  162. })
  163. it('uses the caller Context identity for scoped namespace methods', async () => {
  164. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  165. .mockResolvedValue({ ok: true, value: { renamed: true } })
  166. const ctx = await bench(call)
  167. const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
  168. ctx.typert.contexts.registerClient('fixture', {
  169. identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
  170. })
  171. const assembly = ctx.plugin(Object.assign(
  172. (scope: Context) => {
  173. scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] })
  174. },
  175. { inject: ['api'] },
  176. ))
  177. await assembly
  178. await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
  179. expect(call).toHaveBeenCalledWith(
  180. '/api',
  181. 'goals/rename',
  182. { args: { agentId: 'agent-2', request: { objective: 'land' } } },
  183. expect.any(AbortSignal),
  184. )
  185. await expect((ctx as FixtureContext).goals.rename({ objective: 'land' }))
  186. .rejects.toThrow('requires a "fixture" Context')
  187. await assembly.dispose()
  188. expect(ctx.get('goals')).toBeUndefined()
  189. })
  190. it('rejects weak descriptors and namespace collisions before registration', async () => {
  191. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  192. const weak: InvocationDescriptor = {
  193. ...directDescriptor(),
  194. result: { mode: 'src-json' },
  195. }
  196. expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] }))
  197. .toThrow('has no strict codec')
  198. expect(() => ctx.api.mount({
  199. package: '@fixture/conflict',
  200. descriptors: [{ ...directDescriptor(), namespace: 'mount' }],
  201. })).toThrow('conflicts with the API service')
  202. expect(ctx.typert.remotes.list()).toEqual([])
  203. })
  204. it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
  205. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  206. .mockResolvedValue({ ok: true, value: { renamed: true } })
  207. const ctx = await bench(call)
  208. const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
  209. ctx.typert.contexts.registerClient('fixture', {
  210. identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
  211. })
  212. const direct = directDescriptor()
  213. const context = contextDescriptor()
  214. expect(() => ctx.api.mount({
  215. package: '@fixture/direct-duplicates',
  216. descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
  217. })).toThrow('repeats direct method')
  218. expect(() => ctx.api.mount({
  219. package: '@fixture/scoped-duplicates',
  220. descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
  221. })).toThrow('repeats scoped method')
  222. const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] })
  223. expect(() => ctx.api.mount({
  224. package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
  225. })).toThrow('direct method goals/create is already mounted')
  226. await disposeDirect()
  227. const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] })
  228. expect(() => ctx.api.mount({
  229. package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
  230. })).toThrow('scoped method goals/rename is already mounted')
  231. expect(() => ctx.api.mount({
  232. package: '@fixture/service-method-conflict',
  233. descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
  234. })).toThrow('conflicts with its namespace service')
  235. const scopedService = ctx.get('goals') as unknown as object
  236. Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
  237. expect(() => ctx.api.mount({
  238. package: '@fixture/service-own-property-conflict',
  239. descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
  240. })).toThrow('conflicts with its namespace service')
  241. Reflect.deleteProperty(scopedService, 'custom')
  242. await disposeScoped()
  243. expect(() => ctx.api.mount({
  244. package: '@fixture/context-property-conflict',
  245. descriptors: [{ ...context, namespace: 'typert' }],
  246. })).toThrow('conflicts with an existing Context property')
  247. const disposeMultipleScoped = ctx.api.mount({
  248. package: '@fixture/multiple-scoped',
  249. descriptors: [directDescriptor(), contextDescriptor()],
  250. })
  251. await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
  252. expect(call).toHaveBeenLastCalledWith(
  253. '/api',
  254. 'goals/rename',
  255. { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
  256. expect.any(AbortSignal),
  257. )
  258. await disposeMultipleScoped()
  259. })
  260. it('rolls back direct projection when scoped installation fails', async () => {
  261. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  262. const disposeScoped = ctx.api.mount({
  263. package: '@fixture/scoped-base',
  264. descriptors: [contextDescriptor()],
  265. })
  266. const defineProperty = Object.defineProperty
  267. let createDefinitions = 0
  268. const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  269. // The direct projection defines `create` first; fail the following scoped projection.
  270. if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure')
  271. return defineProperty(target, key, attributes)
  272. })
  273. try {
  274. expect(() => ctx.api.mount({
  275. package: '@fixture/failing-install',
  276. descriptors: [directDescriptor()],
  277. })).toThrow('simulated scoped installation failure')
  278. } finally {
  279. definePropertySpy.mockRestore()
  280. }
  281. expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
  282. expect(ctx.get('goals') !== undefined).toBe(true)
  283. expect(ctx.typert.remotes.list()).toHaveLength(1)
  284. const disposeRetry = ctx.api.mount({
  285. package: '@fixture/retry',
  286. descriptors: [directDescriptor()],
  287. })
  288. await disposeRetry()
  289. await disposeScoped()
  290. })
  291. it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
  292. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  293. const { scope: _scope, ...first } = directDescriptor()
  294. const second: InvocationDescriptor = {
  295. ...first,
  296. id: '@fixture/goals#goals/archive',
  297. method: 'archive',
  298. }
  299. const defineProperty = Object.defineProperty
  300. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  301. if (key === 'archive') throw new Error('fixture later-descriptor failure')
  302. return defineProperty(target, key, attributes)
  303. })
  304. try {
  305. expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
  306. .toThrow('fixture later-descriptor failure')
  307. } finally {
  308. spy.mockRestore()
  309. }
  310. expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
  311. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  312. const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
  313. expect(ctx.api.goals.create).toBeTypeOf('function')
  314. expect((ctx.api.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
  315. await retry()
  316. })
  317. it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
  318. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  319. const direct = directDescriptor()
  320. const context = contextDescriptor()
  321. expect(() => ctx.api.mount({
  322. package: '@fixture/weak-parameter',
  323. descriptors: [{
  324. ...direct,
  325. parameters: direct.parameters.map((parameter, index) => index === 0
  326. ? { ...parameter, codec: { mode: 'src-json' } }
  327. : parameter),
  328. }],
  329. })).toThrow('has no strict codec')
  330. expect(() => ctx.api.mount({
  331. package: '@fixture/weak-context',
  332. descriptors: [{
  333. ...context,
  334. invocation: { ...context.invocation, codec: { mode: 'src-json' } },
  335. } as InvocationDescriptor],
  336. })).toThrow('has no strict codec')
  337. expect(() => ctx.api.mount({
  338. package: '@fixture/malformed-scope',
  339. descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
  340. })).toThrow('scope must select its only lookup parameter')
  341. expect(() => ctx.api.mount({
  342. package: '@fixture/ambiguous-scope',
  343. descriptors: [{
  344. ...direct,
  345. parameters: [...direct.parameters, {
  346. name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
  347. codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
  348. }],
  349. }],
  350. })).toThrow('scope must select its only lookup parameter')
  351. })
  352. it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
  353. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  354. .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
  355. const ctx = await bench(call)
  356. const descriptor = directDescriptor()
  357. const dispose = ctx.api.mount({
  358. package: '@fixture/goals',
  359. descriptors: [descriptor, contextDescriptor()],
  360. })
  361. const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
  362. const goals = (ctx as FixtureContext).goals
  363. const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
  364. await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
  365. await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
  366. .rejects.toThrow('got 4')
  367. await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
  368. await expect((ctx as FixtureContext).goals.create({ objective: 'ship' }))
  369. .rejects.toThrow('no Client Context binder')
  370. ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
  371. await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
  372. ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
  373. ctx.set('connection', undefined)
  374. await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
  375. await dispose()
  376. })
  377. it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
  378. let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
  379. const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
  380. resolveCall = resolve
  381. })
  382. const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
  383. const ctx = await bench(call)
  384. const { scope: _scope, ...first } = directDescriptor()
  385. const second: InvocationDescriptor = {
  386. ...first,
  387. id: '@fixture/goals#goals/archive',
  388. method: 'archive',
  389. }
  390. const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] })
  391. const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' })
  392. await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
  393. await dispose()
  394. resolveCall({ ok: true, value: { ref: 'goal-1' } })
  395. await expect(invocation).rejects.toThrow('withdrawn during invocation')
  396. expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
  397. })
  398. it('preserves a __proto__ wire parameter as an own named argument', async () => {
  399. const call = vi.fn<ConnectionHandle['rpc']['call']>()
  400. .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
  401. const ctx = await bench(call)
  402. const { scope: _scope, ...base } = directDescriptor()
  403. const descriptor: InvocationDescriptor = {
  404. ...base,
  405. id: '@fixture/goals#goals/prototype',
  406. method: 'prototype',
  407. parameters: [{
  408. name: 'value',
  409. wire: '__proto__',
  410. source: 'json',
  411. codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
  412. }],
  413. }
  414. const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] })
  415. const method = (ctx.api.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
  416. await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
  417. const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
  418. expect(Object.getPrototypeOf(payload.args)).toBeNull()
  419. expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
  420. expect(payload.args.__proto__).toBe('wire-value')
  421. await dispose()
  422. })
  423. it('rolls back Remote registration when concrete method installation fails', async () => {
  424. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  425. const defineProperty = Object.defineProperty
  426. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  427. if (key === 'goals') throw new Error('fixture installation failure')
  428. return defineProperty(target, key, attributes)
  429. })
  430. try {
  431. expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
  432. .toThrow('fixture installation failure')
  433. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  434. } finally {
  435. spy.mockRestore()
  436. }
  437. const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
  438. expect(ctx.api.goals.create).toBeTypeOf('function')
  439. await retry()
  440. })
  441. it('withdraws a fresh direct namespace when its first method fails to install', async () => {
  442. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  443. const defineProperty = Object.defineProperty
  444. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  445. if (key === 'create') throw new Error('fixture direct method installation failure')
  446. return defineProperty(target, key, attributes)
  447. })
  448. try {
  449. expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] }))
  450. .toThrow('fixture direct method installation failure')
  451. } finally {
  452. spy.mockRestore()
  453. }
  454. expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
  455. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  456. const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] })
  457. expect(ctx.api.goals.create).toBeTypeOf('function')
  458. await retry()
  459. })
  460. it('withdraws a fresh scoped Service when its first method fails to install', async () => {
  461. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  462. const defineProperty = Object.defineProperty
  463. const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
  464. if (key === 'rename') throw new Error('fixture scoped installation failure')
  465. return defineProperty(target, key, attributes)
  466. })
  467. try {
  468. expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
  469. .toThrow('fixture scoped installation failure')
  470. } finally {
  471. spy.mockRestore()
  472. }
  473. expect(ctx.get('goals')).toBeUndefined()
  474. await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
  475. const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
  476. expect((ctx.get('goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
  477. await retry()
  478. })
  479. it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
  480. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
  481. const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
  482. expect(ctx.get('goals')).toBeDefined()
  483. await dispose()
  484. expect(ctx.get('goals')).toBeUndefined()
  485. const replacement = { owner: 'replacement' }
  486. const disposeReplacement = ctx.reflect.provide('goals', replacement)
  487. expect(ctx.get('goals')).toBe(replacement)
  488. await disposeReplacement()
  489. })
  490. it('throws RPC failures with the structured error as its cause', async () => {
  491. const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
  492. const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
  493. ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
  494. let failure: unknown
  495. try {
  496. await ctx.api.goals.create('agent-1', { objective: 'ship' })
  497. } catch (error) {
  498. failure = error
  499. }
  500. expect(failure).toBeInstanceOf(Error)
  501. if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
  502. expect(failure.message).toContain('internal: host failed')
  503. expect(failure.cause).toBe(rpcError)
  504. })
  505. })