mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
192 lines
7.0 KiB
TypeScript
192 lines
7.0 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)
|
|
|
|
await expect(catalog.list({ sessionId }, new AbortController().signal))
|
|
.rejects.toMatchObject({
|
|
failure: { code: 'internal', message: expect.stringContaining('skill registry is absent') },
|
|
})
|
|
})
|
|
})
|