1
0

client.spec.ts 25 KB

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