session-skills.host.spec.ts 8.7 KB

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