session-skills.host.spec.ts 8.9 KB

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