Files
deepseek-harness/packages/api/session-controller/tests/session-skills.host.spec.ts
T

226 lines
8.6 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } 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: 0,
id: sessionId,
createdAt: 1,
...options.cwd === undefined ? {} : { cwd: options.cwd },
},
events,
cursor: -1,
projections: {
asOfSeq: -1,
values: {
...options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset },
},
},
retain: lease,
[Symbol.dispose]: () => {},
})
return lease()
}
async function context(): Promise<Context> {
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.',
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.',
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: '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({ failure: { 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({ failure: { code: '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({ failure: { code: 'internal' } })
await expect(unprojected).rejects.toThrow('projected Session observation')
const cwdless = catalog.list({ sessionId }, new AbortController().signal)
await expect(cwdless).rejects.toMatchObject({ failure: { code: '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({
failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' },
})
})
})