scope.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import { describe, expect, expectTypeOf, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  4. import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
  5. declare module 'cordis' {
  6. interface Events {
  7. /**
  8. * Test-only event for exercising scope-filtered dispatch.
  9. * @param value - opaque payload recorded by listeners.
  10. * @mode emit
  11. */
  12. 'scope-test/ping'(value: string): void
  13. /**
  14. * Test-only waterfall for exercising carrier `this` shape.
  15. * @param value - seed value listeners may wrap.
  16. * @mode waterfall
  17. */
  18. 'scope-test/echo'(value: string, next: () => string): string
  19. }
  20. }
  21. /** Mount a host plugin and mint a scope inside it, returning both. */
  22. async function mintScope(ctx: Context, key: object): Promise<Scope> {
  23. let scope!: Scope
  24. await ctx.plugin((inner: Context) => {
  25. scope = createScope(inner, key)
  26. })
  27. return scope
  28. }
  29. describe('createScope', () => {
  30. it('rejects a primitive key at runtime (identity-compared keys must be objects)', () => {
  31. const ctx = new Context()
  32. // Typed through `unknown` so the ScopeKey type cannot argue the assertion
  33. // away: this test exercises exactly the callers the typechecker misses.
  34. const badKeys: unknown[] = ['k', null]
  35. for (const bad of badKeys) {
  36. expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be an object/)
  37. }
  38. })
  39. it('tags the scoped context, readable through derivations (nearest tag wins)', async () => {
  40. const ctx = new Context()
  41. const key = { name: 'a' }
  42. const inner = { name: 'a.inner' }
  43. const scope = await mintScope(ctx, key)
  44. expect(scopeOf(scope.ctx)).toBe(key)
  45. // An extend of the scoped context inherits the tag through the prototype chain.
  46. expect(scopeOf(scope.ctx.extend({}))).toBe(key)
  47. // A plain context carries no tag.
  48. expect(scopeOf(ctx)).toBeUndefined()
  49. // A fiber mounted UNDER the scoped context reads as that scope…
  50. let mountedCtx!: Context
  51. await scope.ctx.plugin((c: Context) => { mountedCtx = c })
  52. expect(scopeOf(mountedCtx)).toBe(key)
  53. // …and a nested scope shadows the outer tag (nearest wins).
  54. const nested = createScope(scope.ctx, inner)
  55. expect(scopeOf(nested.ctx)).toBe(inner)
  56. })
  57. it('is usable synchronously: registrations land before the fiber activates', async () => {
  58. const ctx = new Context()
  59. const events: string[] = []
  60. await ctx.plugin((inner: Context) => {
  61. const scope = createScope(inner, { name: 'sync' })
  62. // Same tick as createScope — no await between mint and use.
  63. scope.ctx.effect(() => () => void events.push('effect-disposed'))
  64. scope.ctx.on('scope-test/ping', value => void events.push(`heard:${value}`))
  65. events.push('registered')
  66. })
  67. ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody')
  68. expect(events).toEqual(['registered'])
  69. })
  70. it('dispose() unwinds registrations, is idempotent, and inerts the context', async () => {
  71. const ctx = new Context()
  72. const scope = await mintScope(ctx, { name: 'd' })
  73. const order: string[] = []
  74. scope.ctx.effect(() => () => void order.push('a'))
  75. scope.ctx.effect(() => () => void order.push('b'))
  76. await scope.dispose()
  77. expect(order).toEqual(['b', 'a']) // LIFO within the scope fiber
  78. // Repeat dispose: the underlying cordis disposer returns undefined; the
  79. // wrapper still resolves.
  80. await expect(scope.dispose()).resolves.toBeUndefined()
  81. // Registration through a disposed scope throws INACTIVE_EFFECT.
  82. expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
  83. })
  84. it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
  85. const ctx = new Context()
  86. const order: string[] = []
  87. let composite!: () => Promise<void> | void
  88. await ctx.plugin((inner: Context) => {
  89. composite = inner.effect(function* () {
  90. yield () => void order.push('outermost') // disposed LAST
  91. const scope = createScope(inner, { name: 'nested' })
  92. scope.ctx.effect(() => () => void order.push('scope-registration'))
  93. yield scope.rawDispose // disposed SECOND — nested by identity
  94. yield () => void order.push('innermost') // disposed FIRST
  95. })
  96. })
  97. await composite()
  98. // The scope disposed exactly at its yield position (between the two
  99. // neighbours), not as a concurrent sibling of the composite.
  100. expect(order).toEqual(['innermost', 'scope-registration', 'outermost'])
  101. })
  102. })
  103. describe('scopeTarget dispatch filtering', () => {
  104. it('scoped listeners hear only their key; untagged listeners hear everything', async () => {
  105. const ctx = new Context()
  106. const keyA = { name: 'A' }
  107. const keyB = { name: 'B' }
  108. const scopeA = await mintScope(ctx, keyA)
  109. const scopeB = await mintScope(ctx, keyB)
  110. const heard: string[] = []
  111. ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
  112. scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
  113. scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
  114. ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'to-A')
  115. ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'to-B')
  116. ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'to-nobody')
  117. expect(heard).toEqual([
  118. 'global:to-A', 'A:to-A',
  119. 'global:to-B', 'B:to-B',
  120. 'global:to-nobody',
  121. ])
  122. })
  123. it('{ global: true } listeners bypass scope filtering entirely', async () => {
  124. const ctx = new Context()
  125. const keyA = { name: 'A' }
  126. const scopeA = await mintScope(ctx, keyA)
  127. const heard: string[] = []
  128. scopeA.ctx.on('scope-test/ping', value => void heard.push(`escape:${value}`), { global: true })
  129. ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
  130. ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody')
  131. expect(heard).toEqual(['escape:foreign', 'escape:nobody'])
  132. })
  133. it("composes the base's own Context.filter (a rejecting base filter wins)", async () => {
  134. const ctx = new Context()
  135. const keyA = { name: 'A' }
  136. const scopeA = await mintScope(ctx, keyA)
  137. const heard: string[] = []
  138. ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
  139. scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
  140. // A base whose own filter rejects every listener context: nothing fires,
  141. // scoped or not — the scope predicate never overrides the base's veto.
  142. const vetoBase = { [Context.filter]: () => false }
  143. ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed')
  144. expect(heard).toEqual([])
  145. // A base whose filter accepts delegates to the scope predicate.
  146. const openBase = { [Context.filter]: () => true }
  147. ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
  148. expect(heard).toEqual(['global:open', 'A:open'])
  149. })
  150. it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => {
  151. const ctx = new Context()
  152. const base = { label: 'the-base' }
  153. let seenLabel: string | undefined
  154. ctx.on('scope-test/echo', function (this: { label: string }, value, next) {
  155. seenLabel = this.label
  156. return `${next()}+${value}`
  157. })
  158. const result = ctx.waterfall(scopeTarget(base, undefined), 'scope-test/echo', 'v', () => 'seed')
  159. expect(result).toBe('seed+v')
  160. expect(seenLabel).toBe('the-base')
  161. })
  162. it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => {
  163. // The ds-review-bot regression: cordis hands the carrier to listeners as
  164. // `this` (typed Scoped<Agent>), so subject method calls through it are a
  165. // supported shape. A proxy that delegates with the PROXY as receiver
  166. // (cordis withProps) throws TypeError on any native #private the method
  167. // or getter touches; the carrier must delegate with the BASE as receiver
  168. // and bind retrieved methods to it.
  169. class Subject {
  170. #count = 0
  171. bump(): number { return ++this.#count }
  172. get count(): number { return this.#count }
  173. }
  174. const subject = new Subject()
  175. const carrier = scopeTarget(subject, subject)
  176. expect(carrier.bump()).toBe(1) // method call: bound to the base
  177. expect(subject.count).toBe(1) // ...and it mutated the REAL object
  178. expect(carrier.count).toBe(1) // getter: runs with the base as receiver
  179. // The get trap returns the method already bound to the base;
  180. // detachability IS the assertion.
  181. // eslint-disable-next-line @typescript-eslint/unbound-method
  182. const detached = carrier.bump
  183. expect(detached()).toBe(2)
  184. })
  185. it('delegates sets to the base and leaves frozen own function props unbound (proxy invariant)', () => {
  186. const frozenFn = (): string => 'frozen'
  187. const base: { mutable: number; pinned: () => string; toString: () => string } = {
  188. mutable: 0,
  189. pinned: frozenFn,
  190. toString: () => 'base-str',
  191. }
  192. Object.defineProperty(base, 'pinned', { value: frozenFn, writable: false, configurable: false })
  193. const carrier = scopeTarget(base, undefined)
  194. carrier.mutable = 7
  195. expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay
  196. // A non-configurable, non-writable own data prop must be reported
  197. // unchanged (binding it would violate the proxy get invariant).
  198. expect(carrier.pinned).toBe(frozenFn)
  199. // The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps
  200. // it from shadowing the subject's own prototype-surface members.
  201. expect(String(carrier)).toBe('base-str')
  202. })
  203. })
  204. describe('carrier marks', () => {
  205. it('isScopeCarrier / carrierKeyOf distinguish carriers, keys, and bare subjects', () => {
  206. const base = { name: 'base' }
  207. const key = { name: 'key' }
  208. const keyed = scopeTarget(base, key)
  209. const subjectless = scopeTarget(base, undefined)
  210. expect(isScopeCarrier(keyed)).toBe(true)
  211. expect(carrierKeyOf(keyed)).toBe(key)
  212. expect(isScopeCarrier(subjectless)).toBe(true)
  213. expect(carrierKeyOf(subjectless)).toBeUndefined()
  214. expect(isScopeCarrier(base)).toBe(false)
  215. expect(carrierKeyOf(base)).toBeUndefined()
  216. expect(isScopeCarrier(null)).toBe(false)
  217. expect(isScopeCarrier('x')).toBe(false)
  218. })
  219. it('brands the carrier type (compile-time)', () => {
  220. const base = { name: 'base' }
  221. const carrier = scopeTarget(base, undefined)
  222. expectTypeOf(carrier).toExtend<Scoped<{ name: string }>>()
  223. // A bare subject is NOT assignable where a carrier is demanded.
  224. expectTypeOf(base).not.toExtend<Scoped<{ name: string }>>()
  225. })
  226. })
  227. describe('scopeHost', () => {
  228. it('mints scopes that reach the injected services; dispose unwinds them all', async () => {
  229. const ctx = new Context()
  230. ctx.provide('answers', { value: 42 })
  231. const host = await scopeHost(ctx, ['answers'])
  232. const scope = host.mint({ name: 'a' })
  233. expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42)
  234. const order: string[] = []
  235. scope.ctx.effect(() => () => void order.push('scoped-disposed'))
  236. await host.dispose()
  237. expect(order).toEqual(['scoped-disposed'])
  238. expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
  239. })
  240. it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
  241. const ctx = new Context()
  242. await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
  243. .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
  244. })
  245. it('names a single absent service in the singular', async () => {
  246. const ctx = new Context()
  247. await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')
  248. })
  249. })