directory.spec.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /**
  2. * CommandDirectory unit tests over the session-key axis: per-key status
  3. * transitions and epoch guard, key isolation across sessions, soft
  4. * invalidation (invalidateAll), the reconnect hard reset (resetConnected:
  5. * every entry drops its snapshot and prewarms), the warm hook's cold/failed
  6. * gate, and the per-key ensureReady strong-wait policy.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
  10. import type { CommandDescriptor } from '../src/client/directory.ts'
  11. import { CommandDirectory } from '../src/client/directory.ts'
  12. const sid = (k: string): SessionId => k as SessionId
  13. const S1 = sid('s1')
  14. const S2 = sid('s2')
  15. function deferred<T>() {
  16. let resolve!: (value: T) => void
  17. let reject!: (reason?: unknown) => void
  18. const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
  19. return { promise, resolve, reject }
  20. }
  21. const CMDS: CommandDescriptor[] = [
  22. { name: 'plan', description: 'plan mode' },
  23. { name: 'goal', description: 'set goal', input: { hint: 'goal text' } },
  24. ]
  25. const S2_CMDS: CommandDescriptor[] = [
  26. ...CMDS,
  27. { name: 'attach', description: 'attach a file', input: { hint: 'path' } },
  28. ]
  29. /** Directory over per-key pull queues: each fetch appends a hand-settled deferred. */
  30. function bench() {
  31. const pulls = new Map<SessionId, Array<ReturnType<typeof deferred<readonly CommandDescriptor[]>>>>()
  32. const calls: SessionId[] = []
  33. const dir = new CommandDirectory((key) => {
  34. calls.push(key)
  35. const d = deferred<readonly CommandDescriptor[]>()
  36. const queue = pulls.get(key) ?? []
  37. queue.push(d)
  38. pulls.set(key, queue)
  39. return d.promise
  40. })
  41. const pull = (key: SessionId, i: number) => {
  42. const d = pulls.get(key)?.[i]
  43. if (d === undefined) throw new Error(`no pull #${i} for ${key}`)
  44. return d
  45. }
  46. return { dir, pull, calls, countOf: (key: SessionId) => pulls.get(key)?.length ?? 0 }
  47. }
  48. describe('status and resolve (per key)', () => {
  49. it('starts cold and resolves nothing', () => {
  50. const { dir } = bench()
  51. expect(dir.status(S1)).toBe('cold')
  52. expect(dir.resolve(S1, 'plan')).toBeUndefined()
  53. })
  54. it('serves exact-name lookups once ready, undefined for unknown names', async () => {
  55. const { dir, pull } = bench()
  56. const refreshed = dir.refresh(S1)
  57. expect(dir.status(S1)).toBe('pending')
  58. pull(S1, 0).resolve(CMDS)
  59. await refreshed
  60. expect(dir.status(S1)).toBe('ready')
  61. expect(dir.resolve(S1, 'goal')).toEqual(CMDS[1])
  62. expect(dir.resolve(S1, 'nope')).toBeUndefined()
  63. })
  64. it('drops the snapshot and records failure on a failed pull', async () => {
  65. const { dir, pull } = bench()
  66. const refreshed = dir.refresh(S1)
  67. pull(S1, 0).reject(new Error('boom'))
  68. await refreshed
  69. expect(dir.status(S1)).toBe('failed')
  70. expect(dir.resolve(S1, 'plan')).toBeUndefined()
  71. })
  72. it('keys are isolated: one session catalog landing leaves another cold', async () => {
  73. const { dir, pull } = bench()
  74. const refreshed = dir.refresh(S1)
  75. pull(S1, 0).resolve(CMDS)
  76. await refreshed
  77. expect(dir.status(S2)).toBe('cold')
  78. expect(dir.resolve(S2, 'plan')).toBeUndefined()
  79. const other = dir.refresh(S2)
  80. pull(S2, 0).resolve(S2_CMDS)
  81. await other
  82. expect(dir.resolve(S2, 'attach')).toBeDefined()
  83. expect(dir.resolve(S1, 'attach')).toBeUndefined()
  84. })
  85. })
  86. describe('epoch guard (per key)', () => {
  87. it('a superseded pull cannot overwrite the newer one (old resolves after new)', async () => {
  88. const { dir, pull } = bench()
  89. const first = dir.refresh(S1)
  90. const second = dir.refresh(S1)
  91. pull(S1, 1).resolve(CMDS)
  92. await second
  93. expect(dir.resolve(S1, 'plan')).toBeDefined()
  94. pull(S1, 0).resolve([{ name: 'stale', description: 'old world' }])
  95. await first
  96. expect(dir.resolve(S1, 'stale')).toBeUndefined()
  97. expect(dir.resolve(S1, 'plan')).toBeDefined()
  98. })
  99. it('a superseded failure cannot demote the newer success', async () => {
  100. const { dir, pull } = bench()
  101. const first = dir.refresh(S1)
  102. const second = dir.refresh(S1)
  103. pull(S1, 1).resolve(CMDS)
  104. await second
  105. pull(S1, 0).reject(new Error('late failure'))
  106. await first
  107. expect(dir.status(S1)).toBe('ready')
  108. expect(dir.resolve(S1, 'plan')).toBeDefined()
  109. })
  110. it('epochs are per key: one session supersede leaves another session epoch alone', async () => {
  111. const { dir, pull } = bench()
  112. const one = dir.refresh(S1)
  113. void dir.refresh(S2)
  114. void dir.refresh(S2) // supersedes the s2 pull only
  115. pull(S1, 0).resolve(CMDS)
  116. await one
  117. expect(dir.status(S1)).toBe('ready')
  118. })
  119. })
  120. describe('invalidateAll (commands-changed soft)', () => {
  121. it('repulls every touched key in the background while ready snapshots keep serving', async () => {
  122. const { dir, pull, countOf } = bench()
  123. const a = dir.refresh(S1)
  124. const b = dir.refresh(S2)
  125. pull(S1, 0).resolve(CMDS)
  126. pull(S2, 0).resolve(S2_CMDS)
  127. await Promise.all([a, b])
  128. dir.invalidateAll()
  129. expect(countOf(S1)).toBe(2)
  130. expect(countOf(S2)).toBe(2)
  131. expect(dir.status(S1)).toBe('ready')
  132. expect(dir.resolve(S2, 'attach')).toBeDefined()
  133. pull(S1, 1).resolve([{ name: 'fresh', description: 'new world' }])
  134. await Promise.resolve()
  135. await Promise.resolve()
  136. expect(dir.resolve(S1, 'fresh')).toBeDefined()
  137. expect(dir.resolve(S1, 'plan')).toBeUndefined()
  138. })
  139. it('an untouched directory invalidates to nothing (no keys, no pulls)', () => {
  140. const { dir, calls } = bench()
  141. dir.invalidateAll()
  142. expect(calls).toEqual([])
  143. })
  144. })
  145. describe('resetConnected (reconnect hard)', () => {
  146. it('every entry drops its snapshot immediately and prewarms', async () => {
  147. const { dir, pull, countOf } = bench()
  148. const a = dir.refresh(S1)
  149. const b = dir.refresh(S2)
  150. pull(S1, 0).resolve(CMDS)
  151. pull(S2, 0).resolve(S2_CMDS)
  152. await Promise.all([a, b])
  153. dir.resetConnected()
  154. // Hard: the agent world may have changed shape across the generation.
  155. expect(dir.status(S1)).toBe('pending')
  156. expect(dir.resolve(S1, 'plan')).toBeUndefined()
  157. expect(dir.status(S2)).toBe('pending')
  158. expect(dir.resolve(S2, 'attach')).toBeUndefined()
  159. expect(countOf(S1)).toBe(2)
  160. expect(countOf(S2)).toBe(2)
  161. pull(S1, 1).resolve(CMDS)
  162. pull(S2, 1).resolve(S2_CMDS)
  163. await Promise.resolve()
  164. await Promise.resolve()
  165. expect(dir.status(S1)).toBe('ready')
  166. expect(dir.resolve(S2, 'attach')).toBeDefined()
  167. })
  168. })
  169. describe('warm', () => {
  170. it('launches a pull from cold, again after failure, and never over pending/ready', async () => {
  171. const { dir, pull, countOf } = bench()
  172. dir.warm(S1)
  173. expect(countOf(S1)).toBe(1)
  174. dir.warm(S1) // pending → no second pull
  175. expect(countOf(S1)).toBe(1)
  176. pull(S1, 0).reject(new Error('boom'))
  177. await Promise.resolve()
  178. await Promise.resolve()
  179. expect(dir.status(S1)).toBe('failed')
  180. dir.warm(S1) // failed → retry
  181. expect(countOf(S1)).toBe(2)
  182. pull(S1, 1).resolve(CMDS)
  183. await Promise.resolve()
  184. await Promise.resolve()
  185. dir.warm(S1) // ready → no-op
  186. expect(countOf(S1)).toBe(2)
  187. })
  188. it('warms keys independently', () => {
  189. const { dir, countOf } = bench()
  190. dir.warm(S2)
  191. expect(countOf(S2)).toBe(1)
  192. expect(countOf(S1)).toBe(0)
  193. })
  194. })
  195. describe('ensureReady (per key)', () => {
  196. const signal = () => new AbortController().signal
  197. it('returns the hot snapshot at once when ready', async () => {
  198. const { dir, pull, countOf } = bench()
  199. const warm = dir.refresh(S1)
  200. pull(S1, 0).resolve(CMDS)
  201. await warm
  202. await expect(dir.ensureReady(S1, signal())).resolves.toEqual(CMDS)
  203. expect(countOf(S1)).toBe(1)
  204. })
  205. it('launches a pull from cold and resolves on arrival, without touching other keys', async () => {
  206. const { dir, pull, countOf } = bench()
  207. const wait = dir.ensureReady(S2, signal())
  208. expect(dir.status(S2)).toBe('pending')
  209. pull(S2, 0).resolve(S2_CMDS)
  210. await expect(wait).resolves.toEqual(S2_CMDS)
  211. expect(countOf(S1)).toBe(0)
  212. })
  213. it('joins a flying pull instead of starting a second one', async () => {
  214. const { dir, pull, countOf } = bench()
  215. void dir.refresh(S1)
  216. const wait = dir.ensureReady(S1, signal())
  217. expect(countOf(S1)).toBe(1)
  218. pull(S1, 0).resolve(CMDS)
  219. await expect(wait).resolves.toEqual(CMDS)
  220. })
  221. it('rejects when the awaited pull fails (no silent downgrade)', async () => {
  222. const { dir, pull } = bench()
  223. const wait = dir.ensureReady(S1, signal())
  224. pull(S1, 0).reject(new Error('warmup boom'))
  225. await expect(wait).rejects.toThrow('command directory warmup failed: warmup boom')
  226. })
  227. it('retries from failed state with a fresh pull', async () => {
  228. const { dir, pull } = bench()
  229. const first = dir.ensureReady(S1, signal())
  230. pull(S1, 0).reject(new Error('boom'))
  231. await expect(first).rejects.toThrow()
  232. const second = dir.ensureReady(S1, signal())
  233. pull(S1, 1).resolve(CMDS)
  234. await expect(second).resolves.toEqual(CMDS)
  235. })
  236. it('rejects on abort while waiting', async () => {
  237. const { dir } = bench()
  238. const ac = new AbortController()
  239. const wait = dir.ensureReady(S1, ac.signal)
  240. ac.abort(new Error('attempt superseded'))
  241. await expect(wait).rejects.toThrow('attempt superseded')
  242. })
  243. it('rejects immediately on an already-aborted signal', async () => {
  244. const { dir, pull } = bench()
  245. const warm = dir.refresh(S1)
  246. pull(S1, 0).reject(new Error('irrelevant'))
  247. await warm
  248. const ac = new AbortController()
  249. ac.abort() // bare abort: the DOMException reason is itself an Error and travels as-is
  250. await expect(dir.ensureReady(S1, ac.signal)).rejects.toThrow(/aborted/)
  251. })
  252. it('keeps waiting across a superseded pull and settles on the winner', async () => {
  253. const { dir, pull } = bench()
  254. const wait = dir.ensureReady(S1, signal())
  255. void dir.refresh(S1) // supersedes pull #0 with pull #1
  256. pull(S1, 0).resolve([{ name: 'stale', description: 'loser' }])
  257. pull(S1, 1).resolve(CMDS)
  258. await expect(wait).resolves.toEqual(CMDS)
  259. })
  260. })