agent-initiator.spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { runInNewContext } from 'node:vm'
  4. import AgentRegistry from '@deepseek-ai/dsh-agent'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import { SessionId } from '@deepseek-ai/dsh-session'
  7. function agent(id: string): Agent {
  8. return { id: SessionId(id) } as Agent
  9. }
  10. async function harness(): Promise<{
  11. ctx: Context
  12. service: AgentRegistry
  13. dispose: () => Promise<void>
  14. }> {
  15. const ctx = new Context()
  16. const fiber = await ctx.plugin(AgentRegistry)
  17. return {
  18. ctx,
  19. service: ctx.agents,
  20. dispose: fiber.dispose,
  21. }
  22. }
  23. /** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
  24. async function promptly<T>(task: Promise<T>): Promise<T> {
  25. const timeout = Promise.withResolvers<never>()
  26. const timer = setTimeout(() => { timeout.reject(new Error('initiator teardown did not settle promptly')) }, 1000)
  27. try {
  28. return await Promise.race([task, timeout.promise])
  29. } finally {
  30. clearTimeout(timer)
  31. }
  32. }
  33. describe('AgentRegistry initiator scope', () => {
  34. it('reports an absent initiator and requires an active boundary', async () => {
  35. const { service, dispose } = await harness()
  36. expect(service.currentInitiator()).toBeUndefined()
  37. expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
  38. await dispose()
  39. })
  40. it('preserves exact synchronous and Promise return identities across await', async () => {
  41. const { service, dispose } = await harness()
  42. const initiator = agent('identity')
  43. const value = { result: true }
  44. expect(service.withInitiator(initiator, () => {
  45. expect(service.requireInitiator()).toBe(initiator)
  46. return value
  47. })).toBe(value)
  48. const promise = service.withInitiator(initiator, async () => {
  49. expect(service.requireInitiator()).toBe(initiator)
  50. await Promise.resolve()
  51. expect(service.requireInitiator()).toBe(initiator)
  52. return value
  53. })
  54. expect(service.withInitiator(initiator, () => promise)).toBe(promise)
  55. await expect(promise).resolves.toBe(value)
  56. expect(service.currentInitiator()).toBeUndefined()
  57. await dispose()
  58. })
  59. it('tracks a branded Promise without calling its overridable then property', async () => {
  60. const { service, dispose } = await harness()
  61. const initiator = agent('overridden-then')
  62. const release = Promise.withResolvers<boolean>()
  63. void Object.defineProperty(release.promise, 'then', {
  64. value: () => { throw new Error('overridden then called') },
  65. })
  66. const pending = service.withInitiator(initiator, () => release.promise)
  67. expect(pending).toBe(release.promise)
  68. let disposed = false
  69. const disposal = dispose().then(() => { disposed = true })
  70. await Promise.resolve()
  71. expect(disposed).toBe(false)
  72. release.resolve(true)
  73. await new Promise<void>((resolve, reject) => {
  74. void Promise.prototype.then.call(pending, resolve, reject)
  75. })
  76. await disposal
  77. expect(disposed).toBe(true)
  78. })
  79. it('preserves a settled branded Promise when its species blocks observer construction', async () => {
  80. const { service, dispose } = await harness()
  81. const initiator = agent('invalid-species')
  82. const promise = Promise.resolve()
  83. const constructor = {}
  84. Object.defineProperty(constructor, Symbol.species, {
  85. get: () => { throw new Error('invalid species') },
  86. })
  87. void Object.defineProperty(promise, 'constructor', { value: constructor })
  88. expect(service.withInitiator(initiator, () => promise)).toBe(promise)
  89. await dispose()
  90. })
  91. it('isolates overlapping initiators', async () => {
  92. const { service, dispose } = await harness()
  93. const a = agent('a')
  94. const b = agent('b')
  95. const bothStarted = Promise.withResolvers<boolean>()
  96. const release = Promise.withResolvers<boolean>()
  97. let starts = 0
  98. const run = (initiator: Agent): Promise<void> => service.withInitiator(initiator, async () => {
  99. expect(service.requireInitiator()).toBe(initiator)
  100. starts += 1
  101. if (starts === 2) bothStarted.resolve(true)
  102. await release.promise
  103. expect(service.requireInitiator()).toBe(initiator)
  104. })
  105. const pending = [run(a), run(b)]
  106. await bothStarted.promise
  107. expect(service.currentInitiator()).toBeUndefined()
  108. release.resolve(true)
  109. await Promise.all(pending)
  110. await dispose()
  111. })
  112. it('restores nested and explicitly cleared boundaries', async () => {
  113. const { service, dispose } = await harness()
  114. const parent = agent('parent')
  115. const child = agent('child')
  116. service.withInitiator(parent, () => {
  117. expect(service.requireInitiator()).toBe(parent)
  118. service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) })
  119. expect(service.requireInitiator()).toBe(parent)
  120. service.withoutInitiator(() => {
  121. expect(service.currentInitiator()).toBeUndefined()
  122. expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
  123. })
  124. expect(service.requireInitiator()).toBe(parent)
  125. })
  126. expect(service.currentInitiator()).toBeUndefined()
  127. await dispose()
  128. })
  129. it('restores the parent after synchronous throws and rejected operations', async () => {
  130. const { service, dispose } = await harness()
  131. const parent = agent('parent')
  132. const child = agent('child')
  133. const syncError = new Error('sync failure')
  134. const asyncError = new Error('async failure')
  135. service.withInitiator(parent, () => {
  136. expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError)
  137. expect(service.requireInitiator()).toBe(parent)
  138. })
  139. await expect(service.withInitiator(child, async () => {
  140. await Promise.resolve()
  141. throw asyncError
  142. })).rejects.toBe(asyncError)
  143. expect(service.currentInitiator()).toBeUndefined()
  144. await dispose()
  145. })
  146. it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
  147. const { ctx, service, dispose } = await harness()
  148. const initiator = agent('draining')
  149. const release = Promise.withResolvers<boolean>()
  150. const pending = service.withInitiator(initiator, async () => {
  151. await release.promise
  152. expect(service.requireInitiator()).toBe(initiator)
  153. })
  154. let disposed = false
  155. const disposal = dispose().then(() => { disposed = true })
  156. await Promise.resolve()
  157. expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed')
  158. expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed')
  159. expect(disposed).toBe(false)
  160. expect(ctx.get('agents')).toBeUndefined()
  161. release.resolve(true)
  162. await pending
  163. await disposal
  164. expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
  165. expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed')
  166. })
  167. it('drains cross-realm Promise boundaries before disposal', async () => {
  168. const { service, dispose } = await harness()
  169. const initiator = agent('cross-realm')
  170. const release = Promise.withResolvers<boolean>()
  171. const operation = runInNewContext(
  172. '(async () => { await release; inspect() })',
  173. {
  174. release: release.promise,
  175. inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
  176. },
  177. ) as () => Promise<void>
  178. const pending = service.withInitiator(initiator, operation)
  179. expect(pending).not.toBeInstanceOf(Promise)
  180. let disposed = false
  181. const disposal = dispose().then(() => { disposed = true })
  182. await Promise.resolve()
  183. expect(disposed).toBe(false)
  184. release.resolve(true)
  185. await pending
  186. await disposal
  187. expect(disposed).toBe(true)
  188. })
  189. it('does not self-deadlock when a boundary returns service disposal', async () => {
  190. const { service, dispose } = await harness()
  191. const initiator = agent('service-disposer')
  192. const returned = service.withInitiator(initiator, dispose)
  193. await promptly(returned)
  194. expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
  195. })
  196. it('does not self-deadlock when nested boundaries return ancestor disposal', async () => {
  197. const { ctx, service } = await harness()
  198. const parent = agent('parent-disposer')
  199. const child = agent('child-disposer')
  200. let disposal: Promise<void> | undefined
  201. const returned = service.withInitiator(parent, () => service.withInitiator(child, () => {
  202. disposal = ctx.fiber.dispose()
  203. return disposal
  204. }))
  205. expect(returned).toBe(disposal)
  206. await promptly(returned)
  207. expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
  208. })
  209. it('excludes an asynchronous teardown initiator while draining unrelated boundaries', async () => {
  210. const { ctx, service } = await harness()
  211. const initiator = agent('async-disposer')
  212. const unrelated = agent('unrelated')
  213. const release = Promise.withResolvers<boolean>()
  214. const pending = service.withInitiator(unrelated, async () => {
  215. await release.promise
  216. expect(service.requireInitiator()).toBe(unrelated)
  217. })
  218. const returned = service.withInitiator(initiator, async () => {
  219. await Promise.resolve()
  220. await ctx.fiber.dispose()
  221. })
  222. let disposed = false
  223. void returned.then(() => { disposed = true })
  224. await Promise.resolve()
  225. await Promise.resolve()
  226. expect(disposed).toBe(false)
  227. release.resolve(true)
  228. await pending
  229. await promptly(returned)
  230. expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
  231. })
  232. })