session-skills.host.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry from '@deepseek-ai/dsh-agent'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  5. import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
  6. import type {} from '@deepseek-ai/dsh-skill'
  7. import { describe, expect, it, vi } from 'vitest'
  8. import { SessionSkillCatalog } from '../src/skill-catalog.ts'
  9. function observation(
  10. sessionId: SessionId,
  11. options: { readonly cwd?: string; readonly agentPreset?: string } = {},
  12. ): SessionObservation {
  13. const events = Object.freeze([])
  14. const lease = (): SessionObservation => ({
  15. source: 'live',
  16. header: {
  17. version: 0,
  18. id: sessionId,
  19. createdAt: 1,
  20. ...options.cwd === undefined ? {} : { cwd: options.cwd },
  21. },
  22. events,
  23. cursor: -1,
  24. projections: {
  25. asOfSeq: -1,
  26. values: {
  27. ...options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset },
  28. },
  29. },
  30. retain: lease,
  31. [Symbol.dispose]: () => {},
  32. })
  33. return lease()
  34. }
  35. async function context(): Promise<Context> {
  36. const ctx = new Context()
  37. await ctx.plugin(SessionStore)
  38. await ctx.plugin(AgentRegistry)
  39. return ctx
  40. }
  41. describe('SessionSkillCatalog', () => {
  42. it('reads a cold Session catalog without resuming an Agent', async () => {
  43. const ctx = await context()
  44. const sessionId = SessionId('cold-skills')
  45. const observed = observation(sessionId, { cwd: '/cold/project' })
  46. const dispose = vi.spyOn(observed, Symbol.dispose)
  47. const observeSession = vi.fn(() => Promise.resolve(observed))
  48. ctx.provide('sessionQuery', { observeSession } as never)
  49. const resume = vi.spyOn(ctx.agents, 'resume')
  50. const list = vi.fn(() => Promise.resolve([
  51. {
  52. name: 'review',
  53. description: 'Review the current change.',
  54. whenToUse: 'Before publishing.',
  55. invocation: { modelInvocable: true, userInvocable: true },
  56. },
  57. {
  58. name: 'model-only',
  59. description: 'Not shown to the user.',
  60. invocation: { modelInvocable: true, userInvocable: false },
  61. },
  62. ]))
  63. ctx.provide('skills', { list } as never)
  64. const catalog = new SessionSkillCatalog(ctx)
  65. await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({
  66. skills: [{
  67. name: 'review',
  68. description: 'Review the current change.',
  69. whenToUse: 'Before publishing.',
  70. modelInvocable: true,
  71. }],
  72. })
  73. expect(observeSession).toHaveBeenCalledWith(sessionId)
  74. expect(dispose).toHaveBeenCalledOnce()
  75. expect(resume).not.toHaveBeenCalled()
  76. expect(ctx.agents.list()).toEqual([])
  77. expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined })
  78. })
  79. it('uses a live Agent to address a preset-owned registry', async () => {
  80. const ctx = await context()
  81. const sessionId = SessionId('live-skills')
  82. const session = ctx.sessions.create(sessionId, { meta: { cwd: '/live/project' } })
  83. const agent = { id: sessionId, session, status: 'idle', ctx } as Agent
  84. ctx.agents.register(agent)
  85. ctx.provide('sessionQuery', {
  86. observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/live/project' })),
  87. } as never)
  88. const scopedList = vi.fn(() => Promise.resolve([{
  89. name: 'preset-owned',
  90. description: 'Composed for this Agent.',
  91. invocation: { modelInvocable: false, userInvocable: true },
  92. }]))
  93. const standingKeyFor = vi.fn()
  94. ctx.provide('agentPresets', {
  95. serviceFor: () => ({ list: scopedList }),
  96. standingKeyFor,
  97. } as never)
  98. const catalog = new SessionSkillCatalog(ctx)
  99. await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({
  100. skills: [{
  101. name: 'preset-owned',
  102. description: 'Composed for this Agent.',
  103. modelInvocable: false,
  104. }],
  105. })
  106. expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/project', scope: agent })
  107. expect(standingKeyFor).not.toHaveBeenCalled()
  108. })
  109. it('uses the recorded preset standing scope for a cold Session', async () => {
  110. const ctx = await context()
  111. const sessionId = SessionId('standing-skills')
  112. const scope = { agentPreset: 'minimal' }
  113. ctx.provide('sessionQuery', {
  114. observeSession: () => Promise.resolve(observation(sessionId, {
  115. cwd: '/cold/project',
  116. agentPreset: 'minimal',
  117. })),
  118. } as never)
  119. const standingKeyFor = vi.fn(() => Promise.resolve(scope))
  120. ctx.provide('agentPresets', { standingKeyFor } as never)
  121. const list = vi.fn(() => Promise.resolve([]))
  122. ctx.provide('skills', { list } as never)
  123. const catalog = new SessionSkillCatalog(ctx)
  124. await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] })
  125. expect(standingKeyFor).toHaveBeenCalledWith('minimal')
  126. expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope })
  127. expect(ctx.agents.list()).toEqual([])
  128. })
  129. it('falls back to the global registry when the recorded preset is unavailable', async () => {
  130. const ctx = await context()
  131. const sessionId = SessionId('gone-preset')
  132. ctx.provide('sessionQuery', {
  133. observeSession: () => Promise.resolve(observation(sessionId, {
  134. cwd: '/cold/project',
  135. agentPreset: 'gone',
  136. })),
  137. } as never)
  138. ctx.provide('agentPresets', {
  139. standingKeyFor: () => Promise.reject(new Error('unknown preset')),
  140. } as never)
  141. const list = vi.fn(() => Promise.resolve([]))
  142. ctx.provide('skills', { list } as never)
  143. const catalog = new SessionSkillCatalog(ctx)
  144. await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] })
  145. expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined })
  146. })
  147. it.each([
  148. {
  149. error: new SessionQueryError(
  150. 'session "missing-skills" not found',
  151. 'SESSION_QUERY_SESSION_NOT_FOUND',
  152. ),
  153. code: 'session/not-found',
  154. },
  155. { error: new Error('storage offline'), code: 'gateway/internal' },
  156. ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => {
  157. const ctx = await context()
  158. ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never)
  159. const catalog = new SessionSkillCatalog(ctx)
  160. await expect(catalog.list(
  161. { sessionId: SessionId('missing-skills') },
  162. new AbortController().signal,
  163. )).rejects.toMatchObject({ code })
  164. })
  165. it('reports an absent skill registry instead of an empty catalog', async () => {
  166. const ctx = await context()
  167. const sessionId = SessionId('no-skills')
  168. ctx.provide('sessionQuery', {
  169. observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })),
  170. } as never)
  171. const catalog = new SessionSkillCatalog(ctx)
  172. const failed = catalog.list({ sessionId }, new AbortController().signal)
  173. await expect(failed).rejects.toMatchObject({ code: 'gateway/internal' })
  174. await expect(failed).rejects.toThrow('skill registry is absent')
  175. })
  176. it('rejects observations without projections or a project cwd', async () => {
  177. const ctx = await context()
  178. const sessionId = SessionId('incomplete-skills')
  179. const withoutProjections = { ...observation(sessionId, { cwd: '/project' }), projections: undefined }
  180. const observeSession = vi.fn()
  181. .mockResolvedValueOnce(withoutProjections)
  182. .mockResolvedValueOnce(observation(sessionId))
  183. ctx.provide('sessionQuery', { observeSession } as never)
  184. const catalog = new SessionSkillCatalog(ctx)
  185. const unprojected = catalog.list({ sessionId }, new AbortController().signal)
  186. await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' })
  187. await expect(unprojected).rejects.toThrow('projected Session observation')
  188. const cwdless = catalog.list({ sessionId }, new AbortController().signal)
  189. await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' })
  190. await expect(cwdless).rejects.toThrow('has no project cwd')
  191. })
  192. it('classifies a provider listing failure', async () => {
  193. const ctx = await context()
  194. const sessionId = SessionId('failed-skills')
  195. ctx.provide('sessionQuery', {
  196. observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })),
  197. } as never)
  198. ctx.provide('skills', {
  199. list: () => Promise.reject(new Error('catalog offline')),
  200. } as never)
  201. const catalog = new SessionSkillCatalog(ctx)
  202. await expect(catalog.list({ sessionId }, new AbortController().signal))
  203. .rejects.toMatchObject({
  204. code: 'gateway/internal', message: 'skill listing failed: Error: catalog offline',
  205. })
  206. })
  207. })