scoped.spec.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
  4. import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
  5. import SessionStore from '@deepseek-ai/dsh-session'
  6. import type { Session } from '@deepseek-ai/dsh-session'
  7. async function mount(): Promise<Context> {
  8. const ctx = new Context()
  9. await ctx.plugin(SessionStore)
  10. return ctx
  11. }
  12. async function mintScope(ctx: Context, name: string): Promise<Scope> {
  13. let scope!: Scope
  14. // The scoped context resolves services through the MINTING plugin's
  15. // dependency chain — the minter must inject what scope holders will reach.
  16. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
  17. { inject: ['sessions'] }))
  18. return scope
  19. }
  20. /** The key a test scope was minted with. */
  21. function keyOf(scope: Scope): ScopeKey {
  22. return scopeOf(scope.ctx)!
  23. }
  24. describe('session dispatch carriers', () => {
  25. it('a session entered through a scoped context dispatches its events in that scope', async () => {
  26. const ctx = await mount()
  27. const scope = await mintScope(ctx, 'owner')
  28. const otherScope = await mintScope(ctx, 'other')
  29. const heard: string[] = []
  30. ctx.on('session/event', (_session, event) => void heard.push(`global:${event.type}`))
  31. scope.ctx.on('session/event', (_session, event) => void heard.push(`owner:${event.type}`))
  32. otherScope.ctx.on('session/event', (_session, event) => void heard.push(`other:${event.type}`))
  33. scope.ctx.on('session/created', session => void heard.push(`owner-created:${session.id}`))
  34. otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
  35. const session = scope.ctx.sessions.create()
  36. session.append('turn/start', { turn: 1 })
  37. expect(heard).toEqual([
  38. `owner-created:${session.id}`,
  39. 'global:turn/start',
  40. 'owner:turn/start',
  41. ])
  42. })
  43. it('a bare session dispatches subject-less: scoped listeners never hear it', async () => {
  44. const ctx = await mount()
  45. const scope = await mintScope(ctx, 'owner')
  46. const heard: string[] = []
  47. ctx.on('session/event', (_s, event) => void heard.push(`global:${event.type}`))
  48. scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
  49. const bare = ctx.sessions.create()
  50. bare.append('turn/start', { turn: 1 })
  51. expect(heard).toEqual(['global:turn/start'])
  52. })
  53. it('reuses the captured owner carrier for the paired disposal notification', async () => {
  54. const ctx = await mount()
  55. const owner = await mintScope(ctx, 'owner')
  56. const other = await mintScope(ctx, 'other')
  57. const heard: string[] = []
  58. ctx.on('session/disposed', (session) => { heard.push(`global:${session.id}`) })
  59. owner.ctx.on('session/disposed', (session) => { heard.push(`owner:${session.id}`) })
  60. other.ctx.on('session/disposed', (session) => { heard.push(`other:${session.id}`) })
  61. const session = owner.ctx.sessions.prepare()
  62. const detach = owner.ctx.sessions.enter(session)
  63. owner.ctx.sessions.announce(session)
  64. detach()
  65. expect(heard).toEqual([`global:${session.id}`, `owner:${session.id}`])
  66. })
  67. })
  68. describe('sessions.flush()', () => {
  69. it('allows an ordinary flush with no listeners', async () => {
  70. const ctx = await mount()
  71. const session = ctx.sessions.create()
  72. await expect(ctx.sessions.flush(session)).resolves.toBe(false)
  73. })
  74. it('reports a participating listener after it succeeds', async () => {
  75. const ctx = await mount()
  76. const session = ctx.sessions.create()
  77. const flushed: Session[] = []
  78. ctx.on('session/flush', current => void flushed.push(current))
  79. await expect(ctx.sessions.flush(session)).resolves.toBe(true)
  80. expect(flushed).toEqual([session])
  81. })
  82. it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
  83. const ctx = await mount()
  84. const scope = await mintScope(ctx, 'owner')
  85. const flushed: string[] = []
  86. ctx.on('session/flush', async (session: Session) => {
  87. await Promise.resolve()
  88. flushed.push(`global:${session.id}`)
  89. })
  90. scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
  91. const owned = scope.ctx.sessions.create()
  92. const bare = ctx.sessions.create()
  93. await ctx.sessions.flush(owned)
  94. await ctx.sessions.flush(bare)
  95. // Parallel dispatch: listener completion order is unspecified (the global
  96. // listener awaits a microtask) — assert set membership per flush instead.
  97. expect(flushed.slice(0, 2).sort()).toEqual([`global:${owned.id}`, `owner:${owned.id}`])
  98. expect(flushed.slice(2)).toEqual([`global:${bare.id}`])
  99. })
  100. it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
  101. const ctx = await mount()
  102. ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
  103. const session = ctx.sessions.create()
  104. await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
  105. })
  106. it('does not let a synchronous flush failure starve later listeners', async () => {
  107. const ctx = await mount()
  108. const flushed: Session[] = []
  109. ctx.on('session/flush', () => { throw new Error('disk full') })
  110. ctx.on('session/flush', (session) => { flushed.push(session) })
  111. const session = ctx.sessions.create()
  112. await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
  113. expect(flushed).toEqual([session])
  114. })
  115. it('waits for slower flush listeners before reporting another listener failure', async () => {
  116. const ctx = await mount()
  117. const gate = Promise.withResolvers<undefined>()
  118. let slowStarted = false
  119. let settled = false
  120. ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
  121. ctx.on('session/flush', () => {
  122. slowStarted = true
  123. return gate.promise
  124. })
  125. const session = ctx.sessions.create()
  126. const flushing = ctx.sessions.flush(session)
  127. void flushing.finally(() => { settled = true }).catch(() => undefined)
  128. await Promise.resolve()
  129. expect(slowStarted).toBe(true)
  130. expect(settled).toBe(false)
  131. gate.resolve(undefined)
  132. await expect(flushing).rejects.toThrow('disk full')
  133. expect(settled).toBe(true)
  134. })
  135. it('rejects a never-entered session instead of inventing a carrier', async () => {
  136. const ctx = await mount()
  137. const scope = await mintScope(ctx, 'owner')
  138. const flushed: string[] = []
  139. ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
  140. scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
  141. const prepared = ctx.sessions.prepare()
  142. await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
  143. expect(flushed).toEqual([])
  144. })
  145. it('clears a detached carrier and rejects stale flushes', async () => {
  146. const ctx = await mount()
  147. const scope = await mintScope(ctx, 'owner')
  148. const flushed: string[] = []
  149. ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
  150. scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
  151. const session = scope.ctx.sessions.prepare()
  152. const detach = scope.ctx.sessions.enter(session)
  153. await ctx.sessions.flush(session)
  154. expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
  155. detach()
  156. await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
  157. expect(flushed).toHaveLength(2)
  158. })
  159. it('keyOf sanity: distinct scopes carry distinct keys', async () => {
  160. const ctx = await mount()
  161. const a = await mintScope(ctx, 'a')
  162. const b = await mintScope(ctx, 'b')
  163. expect(keyOf(a)).not.toBe(keyOf(b))
  164. })
  165. })