import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-skill' import { describe, expect, it, vi } from 'vitest' import { SessionSkillCatalog } from '../src/skill-catalog.ts' function observation( sessionId: SessionId, options: { readonly cwd?: string; readonly agentPreset?: string } = {}, ): SessionObservation { const events = Object.freeze([]) const lease = (): SessionObservation => ({ source: 'live', header: { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, isSeeded: false, ...options.cwd === undefined ? {} : { cwd: options.cwd }, }, events, inheritedEventCount: SessionLogOffset(0), cursor: -1, projections: { asOfSeq: -1, values: { ...options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }, }, }, retain: lease, [Symbol.dispose]: () => {}, }) return lease() } async function context(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) return ctx } describe('SessionSkillCatalog', () => { it('reads a cold Session catalog without resuming an Agent', async () => { const ctx = await context() const sessionId = SessionId('cold-skills') const observed = observation(sessionId, { cwd: '/cold/project' }) const dispose = vi.spyOn(observed, Symbol.dispose) const observeSession = vi.fn(() => Promise.resolve(observed)) ctx.provide('sessionQuery', { observeSession } as never) const resume = vi.spyOn(ctx.agents, 'resume') const list = vi.fn(() => Promise.resolve([ { name: 'review', description: 'Review the current change.', whenToUse: 'Before publishing.', path: '/cold/project/.agents/skills/review/SKILL.md', invocation: { modelInvocable: true, userInvocable: true }, }, { name: 'model-only', description: 'Not shown to the user.', invocation: { modelInvocable: true, userInvocable: false }, }, ])) ctx.provide('skills', { list } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [{ name: 'review', description: 'Review the current change.', whenToUse: 'Before publishing.', path: '/cold/project/.agents/skills/review/SKILL.md', modelInvocable: true, }], }) expect(observeSession).toHaveBeenCalledWith(sessionId) expect(dispose).toHaveBeenCalledOnce() expect(resume).not.toHaveBeenCalled() expect(ctx.agents.list()).toEqual([]) expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) }) it('uses a live Agent to address a preset-owned registry', async () => { const ctx = await context() const sessionId = SessionId('live-skills') const session = ctx.sessions.create(sessionId, { meta: { cwd: '/live/project' } }) const agent = { id: sessionId, session, status: 'idle', ctx } as Agent ctx.agents.register(agent) ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/live/project' })), } as never) const scopedList = vi.fn(() => Promise.resolve([{ name: 'preset-owned', description: 'Composed for this Agent.', invocation: { modelInvocable: false, userInvocable: true }, }])) const standingKeyFor = vi.fn() ctx.provide('agentPresets', { serviceFor: () => ({ list: scopedList }), standingKeyFor, } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [{ name: 'preset-owned', description: 'Composed for this Agent.', modelInvocable: false, }], }) expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/project', scope: agent }) expect(standingKeyFor).not.toHaveBeenCalled() }) it('uses the recorded preset standing scope for a cold Session', async () => { const ctx = await context() const sessionId = SessionId('standing-skills') const scope = { agentPreset: 'minimal' } ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/cold/project', agentPreset: 'minimal', })), } as never) const standingKeyFor = vi.fn(() => Promise.resolve(scope)) ctx.provide('agentPresets', { standingKeyFor } as never) const list = vi.fn(() => Promise.resolve([])) ctx.provide('skills', { list } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) expect(standingKeyFor).toHaveBeenCalledWith('minimal') expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope }) expect(ctx.agents.list()).toEqual([]) }) it('falls back to the global registry when the recorded preset is unavailable', async () => { const ctx = await context() const sessionId = SessionId('gone-preset') ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/cold/project', agentPreset: 'gone', })), } as never) ctx.provide('agentPresets', { standingKeyFor: () => Promise.reject(new Error('unknown preset')), } as never) const list = vi.fn(() => Promise.resolve([])) ctx.provide('skills', { list } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) }) it.each([ { error: new SessionQueryError( 'session "missing-skills" not found', 'SESSION_QUERY_SESSION_NOT_FOUND', ), code: 'session/not-found', }, { error: new Error('storage offline'), code: 'gateway/internal' }, ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => { const ctx = await context() ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list( { sessionId: SessionId('missing-skills') }, new AbortController().signal, )).rejects.toMatchObject({ code }) }) it('reports an absent skill registry instead of an empty catalog', async () => { const ctx = await context() const sessionId = SessionId('no-skills') ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })), } as never) const catalog = new SessionSkillCatalog(ctx) const failed = catalog.list({ sessionId }, new AbortController().signal) await expect(failed).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(failed).rejects.toThrow('skill registry is absent') }) it('rejects observations without projections or a project cwd', async () => { const ctx = await context() const sessionId = SessionId('incomplete-skills') const withoutProjections = { ...observation(sessionId, { cwd: '/project' }), projections: undefined } const observeSession = vi.fn() .mockResolvedValueOnce(withoutProjections) .mockResolvedValueOnce(observation(sessionId)) ctx.provide('sessionQuery', { observeSession } as never) const catalog = new SessionSkillCatalog(ctx) const unprojected = catalog.list({ sessionId }, new AbortController().signal) await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(unprojected).rejects.toThrow('projected Session observation') const cwdless = catalog.list({ sessionId }, new AbortController().signal) await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(cwdless).rejects.toThrow('has no project cwd') }) it('classifies a provider listing failure', async () => { const ctx = await context() const sessionId = SessionId('failed-skills') ctx.provide('sessionQuery', { observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })), } as never) ctx.provide('skills', { list: () => Promise.reject(new Error('catalog offline')), } as never) const catalog = new SessionSkillCatalog(ctx) await expect(catalog.list({ sessionId }, new AbortController().signal)) .rejects.toMatchObject({ code: 'gateway/internal', message: 'skill listing failed: Error: catalog offline', }) }) })