1
0

directory.client.spec.ts 12 KB

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