2026-07-10 14:19:06 +08:00
import { describe , expect , it } from 'vitest'
import { mkdir , writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
2026-07-28 13:55:59 +08:00
import { createUserMessage , CallId , type Message } from '@deepseek-ai/dsh-llm'
2026-07-11 23:41:37 +08:00
import { createScope , type Scope } from '@deepseek-ai/dsh-scope'
2026-07-27 16:50:56 +08:00
import { Session , SessionId , type SessionEvent } from '@deepseek-ai/dsh-session'
2026-07-10 14:19:06 +08:00
import SystemPrompt , { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
2026-07-21 03:08:35 +08:00
import ToolRegistry , { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
2026-07-29 16:40:40 +08:00
import AgentRegistry , { agentEvents , type Agent } from '@deepseek-ai/dsh-agent'
2026-07-10 14:19:06 +08:00
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
2026-07-19 23:38:54 +08:00
const testToolSignal = new AbortController ( ) . signal
2026-07-10 14:19:06 +08:00
async function tempDir ( name : string ) : Promise < string > {
return await import ( 'node:fs/promises' ) . then ( fs = > fs . mkdtemp ( join ( tmpdir ( ) , ` dsh- ${ name } - ` ) ) )
}
async function writeSkill ( root : string , name : string , description : string , body : string ) : Promise < void > {
const dir = join ( root , name )
await mkdir ( dir , { recursive : true } )
await writeFile ( join ( dir , 'SKILL.md' ) , ` --- \ nname: ${ name } \ ndescription: ${ description } \ n--- \ n \ n ${ body } \ n ` )
}
async function setup ( home : string , config : toolSkill.Config = { } ) : Promise < Context > {
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
2026-07-27 16:50:56 +08:00
await ctx . plugin ( AgentRegistry )
2026-07-10 14:19:06 +08:00
await ctx . plugin ( SkillService )
2026-07-27 16:50:56 +08:00
await ctx . plugin ( SkillLocal , { dshHome : join ( home , '.dsh' ) , agentsHome : join ( home , '.agents' ) , watch : false } )
2026-07-10 14:19:06 +08:00
await ctx . plugin ( toolSkill , config )
return ctx
}
2026-07-11 23:22:57 +08:00
function agentForCwd ( cwd : string ) : Agent {
2026-07-24 21:18:48 +08:00
const id = SessionId ( ` tool-skill- ${ cwd } ` )
const session = new Session ( id , [ ] , { version : 0 , id , createdAt : 0 , cwd } )
return {
ctx : new Context ( ) ,
id ,
options : { } ,
session ,
status : 'idle' ,
2026-07-27 18:05:43 +08:00
acceptsNextStep : false ,
2026-07-28 13:55:59 +08:00
send : ( ) = > { } ,
2026-07-30 00:05:00 +08:00
updateInbox : ( ) = > 'not-found' ,
2026-07-28 13:55:59 +08:00
followup : ( ) = > { } ,
steer : ( ) = > { } ,
2026-07-24 22:38:50 +08:00
inject ( input ) {
session . append ( 'user/message' , input , { surfaceOp : 'append' } )
2026-07-24 21:18:48 +08:00
} ,
cancel() { } ,
whenIdle : ( ) = > Promise . resolve ( ) ,
}
2026-07-10 14:19:06 +08:00
}
2026-07-27 16:50:56 +08:00
function sessionAgent ( session : Session , id = 'tool-skill-agent' ) : Agent {
return {
id : SessionId ( id ) ,
options : { } ,
session ,
status : 'running' ,
2026-07-28 01:10:33 +08:00
acceptsNextStep : false ,
2026-07-27 16:50:56 +08:00
ctx : new Context ( ) ,
2026-07-29 16:40:40 +08:00
send : ( ) = > { } ,
2026-07-30 00:30:38 +08:00
updateInbox : ( ) = > 'not-found' ,
2026-07-29 16:40:40 +08:00
followup : ( ) = > { } ,
steer : ( ) = > { } ,
2026-07-28 01:10:33 +08:00
inject ( input ) {
session . append ( 'user/message' , input , { surfaceOp : 'append' } )
2026-07-27 16:50:56 +08:00
} ,
cancel() { } ,
whenIdle : ( ) = > Promise . resolve ( ) ,
}
}
function openMessageTurn ( session : Session , turn = 1 ) : void {
session . append ( 'turn/start' , { turn , trigger : { kind : 'message' , source : { kind : 'user' } } } )
2026-07-29 16:40:40 +08:00
session . append ( 'user/message' , createUserMessage ( {
2026-07-27 16:50:56 +08:00
content : [ { type : 'text' , text : ` turn ${ turn } ` } ] ,
source : { kind : 'user' } ,
2026-07-29 16:40:40 +08:00
} ) , { surfaceOp : 'append' } )
2026-07-27 16:50:56 +08:00
}
2026-07-28 01:10:33 +08:00
async function fireStep ( ctx : Context , agent : Agent , turn : number , step : number ) : Promise < void > {
await agentEvents ( ctx , agent ) . serial ( 'agent/step' , turn , step , new AbortController ( ) . signal )
2026-07-27 16:50:56 +08:00
}
2026-07-28 01:10:33 +08:00
function catalogMessages ( session : Session ) : Extract < SessionEvent , { type : 'user/message' } > [ ] {
2026-07-27 16:50:56 +08:00
return session . events . filter ( ( event ) : event is Extract < SessionEvent , { type : 'user/message' } > = > event . type === 'user/message'
&& event . data . source . kind === 'plugin'
2026-07-29 16:40:40 +08:00
&& event . data . source . plugin === 'dsh-tool-skill' )
2026-07-27 16:50:56 +08:00
}
2026-07-28 01:10:33 +08:00
function catalogContent ( entries : string [ ] ) : Message [ 'content' ] {
return [ {
type : 'text' ,
text : [ '<system-reminder>' , '<available_skills>' , . . . entries , '</available_skills>' , '</system-reminder>' ] . join ( '\n' ) ,
} ]
}
2026-07-10 14:19:06 +08:00
async function composePrefix ( ctx : Context , cwd : string , signal = new AbortController ( ) . signal ) : Promise < Message [ ] > {
2026-07-11 23:41:37 +08:00
return await composePrefixForAgent ( ctx , agentForCwd ( cwd ) , signal )
}
async function composePrefixForAgent ( ctx : Context , agent : Agent , signal = new AbortController ( ) . signal ) : Promise < Message [ ] > {
2026-07-24 21:18:48 +08:00
await agentEvents ( ctx , agent ) . serial ( 'agent/step' , 1 , 1 , signal )
return agent . session . deriveMessages ( )
2026-07-10 14:19:06 +08:00
}
2026-07-27 16:50:56 +08:00
async function mintAgentScope ( ctx : Context , subject : string | Agent ) : Promise < { agent : Agent ; scope : Scope } > {
const agent = typeof subject === 'string' ? agentForCwd ( subject ) : subject
2026-07-11 23:41:37 +08:00
let scope ! : Scope
await ctx . plugin ( Object . assign ( ( inner : Context ) = > { scope = createScope ( inner , agent ) } , {
inject : [ 'tools' ] ,
} ) )
return { agent , scope }
}
2026-07-10 14:19:06 +08:00
describe ( 'dsh-tool-skill' , ( ) = > {
it ( 'registers the skill tool schema and removes it on dispose' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
2026-07-27 16:50:56 +08:00
await ctx . plugin ( AgentRegistry )
2026-07-10 14:19:06 +08:00
const home = await tempDir ( 'tool-schema' )
await ctx . plugin ( SkillService )
2026-07-27 16:50:56 +08:00
await ctx . plugin ( SkillLocal , { dshHome : join ( home , '.dsh' ) , agentsHome : join ( home , '.agents' ) , watch : false } )
2026-07-10 14:19:06 +08:00
ctx . skills . register ( { name : 'lifecycle-skill' , description : 'Lifecycle' , source : 'runtime' , content : 'body' } )
const fiber = await ctx . plugin ( toolSkill )
expect ( ctx . tools . schemas ( ) . map ( tool = > tool . name ) ) . toEqual ( [ 'skill' ] )
expect ( await composePrefix ( ctx , '/workspace' ) ) . toHaveLength ( 1 )
expect ( ctx . tools . get ( 'skill' ) ? . presentCall ? . ( { name : 'project-skill' } ) ) . toEqual ( {
card : 'generic' ,
title : 'Load skill project-skill' ,
kind : 'read' ,
rawInput : 'project-skill' ,
} )
await fiber . dispose ( )
expect ( ctx . tools . schemas ( ) ) . toEqual ( [ ] )
expect ( await composePrefix ( ctx , '/workspace' ) ) . toEqual ( [ ] )
toolSkill . apply ( ctx )
expect ( ctx . tools . schemas ( ) . map ( tool = > tool . name ) ) . toEqual ( [ 'skill' ] )
} )
2026-07-24 21:18:48 +08:00
it ( 'forwards the step abort signal to skill discovery' , async ( ) = > {
2026-07-10 14:19:06 +08:00
const home = await tempDir ( 'tool-prefix-signal' )
const ctx = await setup ( home )
let seenSignal : AbortSignal | undefined
2026-07-29 02:06:07 +08:00
ctx . skills . registerProvider ( ( ) = > ( {
2026-07-10 14:19:06 +08:00
name : 'signal-probe' ,
async list ( options ) {
seenSignal = options . signal
return [ ]
} ,
async get ( ) {
return undefined
} ,
2026-07-29 02:06:07 +08:00
} ) )
2026-07-10 14:19:06 +08:00
const controller = new AbortController ( )
await composePrefix ( ctx , '/workspace' , controller . signal )
expect ( seenSignal ) . toBe ( controller . signal )
} )
2026-07-24 21:18:48 +08:00
it ( 'injects a stable durable name-and-description catalog at the first step' , async ( ) = > {
2026-07-10 14:19:06 +08:00
const home = await tempDir ( 'tool-catalog' )
const ctx = await setup ( home , { catalogDescriptionMaxLength : 50 } )
ctx . skills . register ( {
name : 'z-skill' ,
description : 'Long description ' . repeat ( 5 ) ,
whenToUse : 'Never render this routing hint.' ,
source : 'secret-source' ,
provider : 'runtime' ,
resourceBase : { kind : 'directory' , path : '/secret/path' } ,
content : 'Secret body.' ,
} )
ctx . skills . register ( {
name : 'a-skill' ,
description : 'Use {{placeholder}} <safely> & carefully.' ,
source : 'runtime' ,
provider : 'runtime' ,
content : 'A body.' ,
} )
2026-07-28 17:22:41 +08:00
ctx . skills . register ( {
name : 'model-only-skill' ,
description : 'Model-only skill.' ,
2026-07-28 17:55:49 +08:00
invocation : { modelInvocable : true , userInvocable : false } ,
2026-07-28 17:22:41 +08:00
source : 'runtime' ,
content : 'Model-only body.' ,
} )
ctx . skills . register ( {
name : 'user-only-skill' ,
description : 'User-only skill.' ,
2026-07-28 17:55:49 +08:00
invocation : { modelInvocable : false , userInvocable : true } ,
2026-07-28 17:22:41 +08:00
source : 'runtime' ,
content : 'User-only body.' ,
} )
2026-07-24 21:18:48 +08:00
ctx . on ( 'agent/step' , ( agent ) = > {
2026-07-28 13:55:59 +08:00
agent . inject ( createUserMessage ( { content : [ { type : 'text' , text : 'later contribution' } ] , source : { kind : 'plugin' , plugin : 'later-contribution' } } ) )
2026-07-24 21:18:48 +08:00
} )
2026-07-10 14:19:06 +08:00
const prefix = await composePrefix ( ctx , '/workspace' )
expect ( prefix ) . toEqual ( [
{
2026-07-28 14:15:23 +08:00
id : expect.any ( String ) as unknown ,
2026-07-10 14:19:06 +08:00
role : 'user' ,
2026-07-28 14:15:23 +08:00
source : { kind : 'plugin' , plugin : 'dsh-tool-skill' } ,
2026-07-10 14:19:06 +08:00
content : [ {
type : 'text' ,
text : [
'<system-reminder>' ,
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:' ,
'' ,
'<available_skills>' ,
'- `a-skill`: Use {{placeholder}} <safely> & carefully.' ,
2026-07-28 17:22:41 +08:00
'- `model-only-skill`: Model-only skill.' ,
2026-07-10 14:19:06 +08:00
'- `z-skill`: Long description Long description Long descript...' ,
'</available_skills>' ,
'' ,
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded." ,
'</system-reminder>' ,
] . join ( '\n' ) ,
} ] ,
} ,
2026-07-28 14:15:23 +08:00
{
id : expect.any ( String ) as unknown ,
role : 'user' ,
content : [ { type : 'text' , text : 'later contribution' } ] ,
source : { kind : 'plugin' , plugin : 'later-contribution' } ,
} ,
2026-07-10 14:19:06 +08:00
] )
const rendered = JSON . stringify ( prefix [ 0 ] )
expect ( rendered ) . not . toContain ( 'whenToUse' )
expect ( rendered ) . not . toContain ( 'secret-source' )
expect ( rendered ) . not . toContain ( '/secret/path' )
expect ( rendered ) . not . toContain ( 'Secret body' )
2026-07-28 17:22:41 +08:00
expect ( rendered ) . not . toContain ( 'user-only-skill' )
2026-07-10 14:19:06 +08:00
expect ( renderPrompt ( await ctx . systemPrompt . assemble ( { agent : agentForCwd ( '/workspace' ) } ) ) ) . not . toContain ( '<available_skills>' )
} )
2026-07-28 17:22:41 +08:00
it ( 'does not inject a catalog when no model-invocable skills are available' , async ( ) = > {
2026-07-10 14:19:06 +08:00
const home = await tempDir ( 'tool-empty-catalog' )
const ctx = await setup ( home )
2026-07-28 17:22:41 +08:00
ctx . skills . register ( {
name : 'user-only-skill' ,
description : 'User-only skill' ,
2026-07-28 17:55:49 +08:00
invocation : { modelInvocable : false , userInvocable : true } ,
2026-07-28 17:22:41 +08:00
source : 'runtime' ,
content : 'User-only body.' ,
} )
2026-07-10 14:19:06 +08:00
2026-07-29 22:03:01 +08:00
const agent = agentForCwd ( '/workspace' )
expect ( await composePrefixForAgent ( ctx , agent ) ) . toEqual ( [ ] )
expect ( await composePrefixForAgent ( ctx , agent ) ) . toEqual ( [ ] )
2026-07-10 14:19:06 +08:00
} )
2026-07-27 16:50:56 +08:00
it ( 'omits an incomplete initial catalog and retries on a later request boundary' , async ( ) = > {
const home = await tempDir ( 'tool-incomplete-prefix' )
const ctx = await setup ( home )
let failing = true
const provider = {
name : 'recovering' ,
async list() {
if ( failing ) throw new Error ( 'temporarily unavailable' )
return [ ]
} ,
async get ( ) {
return undefined
} ,
}
2026-07-29 02:06:07 +08:00
let invalidate = ( ) : void = > { }
ctx . skills . registerProvider ( ( control ) = > {
invalidate = control . invalidate
return provider
} )
2026-07-27 16:50:56 +08:00
const session = new Session ( SessionId ( 'incomplete-prefix' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
2026-07-28 01:10:33 +08:00
await composePrefixForAgent ( ctx , agent )
expect ( catalogMessages ( session ) ) . toEqual ( [ ] )
2026-07-27 16:50:56 +08:00
failing = false
2026-07-29 02:06:07 +08:00
invalidate ( )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
expect ( catalogMessages ( session ) ) . toEqual ( [ ] )
2026-07-27 16:50:56 +08:00
} )
2026-07-28 01:10:33 +08:00
it ( 'records an empty baseline across repeated step observations' , async ( ) = > {
const home = await tempDir ( 'tool-empty-step' )
2026-07-27 16:50:56 +08:00
const ctx = await setup ( home )
2026-07-28 01:10:33 +08:00
const session = new Session ( SessionId ( 'empty-step' ) )
2026-07-27 16:50:56 +08:00
const agent = sessionAgent ( session )
openMessageTurn ( session )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
await fireStep ( ctx , agent , 1 , 2 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
expect ( catalogMessages ( session ) ) . toEqual ( [ ] )
2026-07-27 16:50:56 +08:00
} )
it ( 'injects complete replacement catalogs for additions and an empty tombstone for removals' , async ( ) = > {
const home = await tempDir ( 'tool-dynamic-catalog' )
const ctx = await setup ( home )
const disposeFirst = ctx . skills . register ( {
name : 'first-skill' ,
description : 'First skill' ,
source : 'runtime' ,
content : 'First body.' ,
} )
const session = new Session ( SessionId ( 'dynamic-catalog' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
expect ( JSON . stringify ( await composePrefixForAgent ( ctx , agent ) ) ) . toContain ( 'first-skill' )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
expect ( catalogMessages ( session ) ) . toHaveLength ( 1 )
2026-07-27 16:50:56 +08:00
const disposeSecond = ctx . skills . register ( {
name : 'second-skill' ,
description : 'Second skill' ,
source : 'runtime' ,
content : 'Second body.' ,
} )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 2 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
const addition = catalogMessages ( session ) [ 1 ]
2026-07-27 16:50:56 +08:00
if ( addition ? . type !== 'user/message' ) throw new Error ( 'expected catalog addition' )
expect ( JSON . stringify ( addition . data . content ) ) . toContain ( 'first-skill' )
expect ( JSON . stringify ( addition . data . content ) ) . toContain ( 'second-skill' )
disposeSecond ( )
disposeFirst ( )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 3 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
const removal = catalogMessages ( session ) [ 2 ]
2026-07-27 16:50:56 +08:00
if ( removal ? . type !== 'user/message' ) throw new Error ( 'expected catalog removal' )
expect ( JSON . stringify ( removal . data . content ) ) . toContain ( 'No skills are currently available' )
expect ( JSON . stringify ( removal . data . content ) ) . not . toContain ( 'first-skill' )
expect ( JSON . stringify ( removal . data . content ) ) . not . toContain ( 'second-skill' )
2026-07-29 20:44:06 +08:00
await fireStep ( ctx , agent , 1 , 4 )
expect ( catalogMessages ( session ) ) . toHaveLength ( 3 )
2026-07-27 16:50:56 +08:00
} )
2026-07-28 01:10:33 +08:00
it ( 'resumes from the latest valid visible catalog content' , async ( ) = > {
2026-07-27 16:50:56 +08:00
const home = await tempDir ( 'tool-catalog-resume' )
const ctx = await setup ( home )
ctx . skills . register ( {
name : 'resumed-skill' ,
description : 'Resumed skill' ,
source : 'runtime' ,
content : 'Resumed body.' ,
} )
const session = new Session ( SessionId ( 'catalog-resume' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
2026-07-29 16:40:40 +08:00
session . append ( 'user/message' , createUserMessage ( {
2026-07-28 01:10:33 +08:00
content : catalogContent ( [ '- `old-skill`: Old skill' ] ) ,
2026-07-29 16:40:40 +08:00
source : { kind : 'plugin' , plugin : 'dsh-tool-skill' } ,
} ) , { surfaceOp : 'append' } )
session . append ( 'user/message' , createUserMessage ( {
2026-07-28 01:10:33 +08:00
content : [ { type : 'text' , text : 'missing catalog markers' } ] ,
2026-07-29 16:40:40 +08:00
source : { kind : 'plugin' , plugin : 'dsh-tool-skill' } ,
} ) , { surfaceOp : 'append' } )
session . append ( 'user/message' , createUserMessage ( {
2026-07-28 01:10:33 +08:00
content : [ { type : 'text' , text : '<available_skills>\nmissing closing marker' } ] ,
2026-07-29 16:40:40 +08:00
source : { kind : 'plugin' , plugin : 'dsh-tool-skill' } ,
} ) , { surfaceOp : 'append' } )
session . append ( 'user/message' , createUserMessage ( {
2026-07-28 01:10:33 +08:00
content : [ { type : 'text' , text : 'first block' } , { type : 'text' , text : 'second block' } ] ,
2026-07-29 16:40:40 +08:00
source : { kind : 'plugin' , plugin : 'dsh-tool-skill' } ,
} ) , { surfaceOp : 'append' } )
session . append ( 'user/message' , createUserMessage ( {
2026-07-28 01:10:33 +08:00
content : [ { type : 'reasoning' , text : 'not a user-role catalog block' } ] ,
2026-07-29 16:40:40 +08:00
source : { kind : 'plugin' , plugin : 'dsh-tool-skill' } ,
} ) , { surfaceOp : 'append' } )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
expect ( catalogMessages ( session ) ) . toHaveLength ( 6 )
expect ( JSON . stringify ( catalogMessages ( session ) . at ( - 1 ) ? . data . content ) ) . toContain ( 'resumed-skill' )
2026-07-27 16:50:56 +08:00
} )
2026-07-28 01:10:33 +08:00
it ( 're-establishes the current catalog after compaction hides its durable message' , async ( ) = > {
2026-07-27 16:50:56 +08:00
const home = await tempDir ( 'tool-catalog-compaction' )
const ctx = await setup ( home )
ctx . skills . register ( {
name : 'first-skill' ,
description : 'First skill' ,
source : 'runtime' ,
content : 'First body.' ,
} )
const session = new Session ( SessionId ( 'catalog-compaction' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
expect ( JSON . stringify ( await composePrefixForAgent ( ctx , agent ) ) ) . toContain ( 'first-skill' )
2026-07-28 01:10:33 +08:00
const initial = catalogMessages ( session ) [ 0 ]
if ( initial === undefined ) throw new Error ( 'expected initial catalog' )
2026-07-29 16:40:40 +08:00
session . append ( 'user/message' , createUserMessage ( {
2026-07-27 16:50:56 +08:00
content : [ { type : 'text' , text : 'compacted history' } ] ,
source : { kind : 'plugin' , plugin : 'compact' } ,
2026-07-29 16:40:40 +08:00
} ) , {
2026-07-28 01:10:33 +08:00
surfaceOp : { op : 'replace' , start : initial.seq , end : initial.seq } ,
sourceEventSeqs : [ initial . seq ] ,
2026-07-27 16:50:56 +08:00
} )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
expect ( catalogMessages ( session ) ) . toHaveLength ( 2 )
expect ( JSON . stringify ( catalogMessages ( session ) . at ( - 1 ) ? . data . content ) ) . toContain ( 'first-skill' )
2026-07-27 16:50:56 +08:00
} )
it ( 'keeps body-only edits out of the catalog and loads the latest body on demand' , async ( ) = > {
const home = await tempDir ( 'tool-body-refresh' )
const root = join ( home , '.dsh/skills' )
await writeSkill ( root , 'body-skill' , 'Stable description' , 'First body.' )
const ctx = await setup ( home )
const session = new Session ( SessionId ( 'body-refresh' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
expect ( JSON . stringify ( await composePrefixForAgent ( ctx , agent ) ) ) . toContain ( 'Stable description' )
await writeSkill ( root , 'body-skill' , 'Stable description' , 'Second body.' )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
expect ( catalogMessages ( session ) ) . toHaveLength ( 1 )
2026-07-27 16:50:56 +08:00
const result = await ctx . tools . execute ( {
signal : testToolSignal ,
callId : CallId ( 'body-refresh' ) ,
name : 'skill' ,
arguments : { name : 'body-skill' } ,
agent ,
} )
expect ( result . isError ) . toBe ( false )
expect ( JSON . stringify ( result . content ) ) . toContain ( 'Second body.' )
expect ( JSON . stringify ( result . content ) ) . not . toContain ( 'First body.' )
} )
it ( 'retains the last-good catalog while any provider discovery is incomplete' , async ( ) = > {
const home = await tempDir ( 'tool-incomplete-catalog' )
const ctx = await setup ( home )
const disposeStable = ctx . skills . register ( {
name : 'stable-skill' ,
description : 'Stable skill' ,
source : 'runtime' ,
content : 'Stable body.' ,
} )
const session = new Session ( SessionId ( 'incomplete-catalog' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
expect ( JSON . stringify ( await composePrefixForAgent ( ctx , agent ) ) ) . toContain ( 'stable-skill' )
2026-07-29 02:06:07 +08:00
ctx . skills . registerProvider ( ( ) = > ( {
2026-07-27 16:50:56 +08:00
name : 'failing' ,
async list() {
throw new Error ( 'temporarily unavailable' )
} ,
async get ( ) {
return undefined
} ,
2026-07-29 02:06:07 +08:00
} ) )
2026-07-27 16:50:56 +08:00
disposeStable ( )
2026-07-28 01:10:33 +08:00
await fireStep ( ctx , agent , 1 , 1 )
2026-07-27 16:50:56 +08:00
2026-07-28 01:10:33 +08:00
expect ( catalogMessages ( session ) ) . toHaveLength ( 1 )
2026-07-10 14:19:06 +08:00
} )
2026-07-11 23:41:37 +08:00
it ( 'omits catalog guidance when the calling agent restricts away the shipped skill tool' , async ( ) = > {
const home = await tempDir ( 'tool-restricted-catalog' )
const ctx = await setup ( home )
ctx . skills . register ( { name : 'listed-skill' , description : 'Listed' , source : 'runtime' , content : 'body' } )
2026-07-27 16:50:56 +08:00
const session = new Session ( SessionId ( 'restricted-catalog' ) )
const agent = sessionAgent ( session )
openMessageTurn ( session )
const { scope } = await mintAgentScope ( ctx , agent )
2026-07-11 23:41:37 +08:00
scope . ctx . tools . restrict ( { deny : [ 'skill' ] } )
expect ( ctx . tools . get ( 'skill' , agent ) ) . toBeUndefined ( )
2026-07-28 01:10:33 +08:00
await composePrefixForAgent ( ctx , agent )
expect ( catalogMessages ( session ) ) . toEqual ( [ ] )
await fireStep ( ctx , agent , 1 , 1 )
expect ( catalogMessages ( session ) ) . toEqual ( [ ] )
2026-07-11 23:41:37 +08:00
expect ( await composePrefix ( ctx , '/workspace' ) ) . toHaveLength ( 1 )
await scope . dispose ( )
} )
it ( 'does not attach shipped catalog guidance to a scoped same-name tool shadow' , async ( ) = > {
const home = await tempDir ( 'tool-shadowed-catalog' )
const ctx = await setup ( home )
ctx . skills . register ( { name : 'listed-skill' , description : 'Listed' , source : 'runtime' , content : 'body' } )
const { agent , scope } = await mintAgentScope ( ctx , '/workspace' )
2026-07-21 03:08:35 +08:00
scope . ctx . tools . register ( defineContentToolFixture ( {
2026-07-11 23:41:37 +08:00
name : 'skill' ,
description : 'A scoped tool with unrelated semantics.' ,
parameters : { } ,
execute() {
return Promise . resolve ( [ { type : 'text' , text : 'shadow' } ] )
} ,
} ) )
expect ( ctx . tools . get ( 'skill' , agent ) ) . not . toBe ( ctx . tools . get ( 'skill' ) )
expect ( await composePrefixForAgent ( ctx , agent ) ) . toEqual ( [ ] )
expect ( await composePrefix ( ctx , '/workspace' ) ) . toHaveLength ( 1 )
await scope . dispose ( )
} )
2026-07-10 14:19:06 +08:00
it ( 'validates the catalog description cap' , async ( ) = > {
const home = await tempDir ( 'tool-invalid-catalog-cap' )
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
2026-07-27 16:50:56 +08:00
await ctx . plugin ( AgentRegistry )
2026-07-10 14:19:06 +08:00
await ctx . plugin ( SkillService )
2026-07-27 16:50:56 +08:00
await ctx . plugin ( SkillLocal , { dshHome : join ( home , '.dsh' ) , agentsHome : join ( home , '.agents' ) , watch : false } )
2026-07-10 14:19:06 +08:00
await expect ( ctx . plugin ( toolSkill , { catalogDescriptionMaxLength : 2 } ) ) . rejects . toThrow ( 'greater than or equal to 3' )
} )
it ( 'loads a skill for the calling agent cwd' , async ( ) = > {
const home = await tempDir ( 'tool-load' )
const project = await tempDir ( 'tool-project' )
await mkdir ( join ( project , '.git' ) , { recursive : true } )
await writeSkill ( join ( project , '.dsh/skills' ) , 'project-skill' , 'Project skill' , 'Project instructions.' )
const ctx = await setup ( home )
const result = await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-07-10 14:19:06 +08:00
callId : CallId ( 'c1' ) ,
name : 'skill' ,
arguments : { name : 'project-skill' } ,
agent : { session : { header : { cwd : project } } } as never ,
} )
expect ( result . isError ) . toBe ( false )
2026-07-21 03:08:35 +08:00
if ( result . isError ) throw new Error ( 'expected skill success' )
expect ( result . value ) . toEqual ( {
name : 'project-skill' ,
provider : 'local' ,
resourceBase : { kind : 'directory' , path : join ( project , '.dsh/skills/project-skill' ) } ,
content : 'Project instructions.' ,
} )
2026-07-10 14:19:06 +08:00
const block = result . content [ 0 ]
expect ( block ? . type ) . toBe ( 'text' )
if ( block ? . type !== 'text' ) throw new Error ( 'expected text skill result' )
expect ( block . text ) . toBe ( [
'<skill_content name="project-skill">' ,
'<skill_resources>' ,
` Base directory for this skill: ${ join ( project , '.dsh/skills/project-skill' ) } ` ,
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.' ,
'</skill_resources>' ,
'' ,
'<skill_instructions>' ,
'Project instructions.' ,
'</skill_instructions>' ,
'</skill_content>' ,
] . join ( '\n' ) )
expect ( block . text ) . not . toContain ( '# Skill:' )
} )
it ( 'renders provider-managed resource hints for non-local skills' , async ( ) = > {
const home = await tempDir ( 'tool-resource-hints' )
const ctx = await setup ( home )
ctx . skills . register ( {
name : 'opaque-skill' ,
description : 'Opaque skill' ,
source : 'runtime' ,
provider : 'runtime' ,
resourceBase : { kind : 'opaque' , description : 'runtime memory' } ,
content : 'Opaque instructions.' ,
} )
ctx . skills . register ( {
name : 'url-skill' ,
description : 'URL skill' ,
source : 'runtime' ,
provider : 'runtime' ,
resourceBase : { kind : 'url' , url : 'https://skills.example.test/url-skill' } ,
content : 'URL instructions.' ,
} )
ctx . skills . register ( {
name : 'provider-skill' ,
description : 'Provider skill' ,
source : 'runtime' ,
provider : 'runtime' ,
content : 'Provider instructions.' ,
} )
2026-07-19 23:38:54 +08:00
const opaque = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c2' ) , name : 'skill' , arguments : { name : 'opaque-skill' } } )
const url = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c3' ) , name : 'skill' , arguments : { name : 'url-skill' } } )
const provider = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c4' ) , name : 'skill' , arguments : { name : 'provider-skill' } } )
2026-07-10 14:19:06 +08:00
if ( opaque . content [ 0 ] ? . type !== 'text' || url . content [ 0 ] ? . type !== 'text' || provider . content [ 0 ] ? . type !== 'text' ) {
throw new Error ( 'expected text tool results' )
}
expect ( opaque . content [ 0 ] . text ) . toContain ( '<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>' )
expect ( url . content [ 0 ] . text ) . toContain ( '<skill_resources>\nBase URL for this skill: https://skills.example.test/url-skill\nResolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.\n</skill_resources>' )
expect ( provider . content [ 0 ] . text ) . toContain ( '<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>' )
} )
2026-07-21 03:08:35 +08:00
it ( 'rejects an unknown resource-base kind at the canonical output boundary' , async ( ) = > {
2026-07-10 14:19:06 +08:00
const home = await tempDir ( 'tool-resource-assert-never' )
const ctx = await setup ( home )
ctx . skills . register ( {
name : 'rogue-resource-skill' ,
description : 'Rogue resource skill' ,
source : 'runtime' ,
provider : 'runtime' ,
resourceBase : { kind : 'future' } as never ,
content : 'Rogue instructions.' ,
} )
2026-07-19 23:38:54 +08:00
const result = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c5' ) , name : 'skill' , arguments : { name : 'rogue-resource-skill' } } )
2026-07-10 14:19:06 +08:00
expect ( result . isError ) . toBe ( true )
2026-07-21 03:08:35 +08:00
expect ( result . error ? . info ? . code ) . toBe ( 'INVALID_TOOL_OUTPUT' )
2026-07-10 14:19:06 +08:00
const block = result . content [ 0 ]
if ( block ? . type !== 'text' ) throw new Error ( 'expected text tool result' )
2026-07-21 03:08:35 +08:00
expect ( block . text ) . toContain ( 'value.resourceBase' )
2026-07-10 14:19:06 +08:00
} )
it ( 'returns isError for unknown, invalid, and model-disabled skills' , async ( ) = > {
const home = await tempDir ( 'tool-errors' )
await writeSkill ( join ( home , '.dsh/skills' ) , 'hidden-skill' , 'Hidden skill' , 'Hidden instructions.' )
2026-07-28 17:22:41 +08:00
await writeFile ( join ( home , '.dsh/skills/hidden-skill/SKILL.md' ) , '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n' )
2026-07-10 14:19:06 +08:00
const ctx = await setup ( home )
2026-07-28 17:22:41 +08:00
ctx . skills . register ( {
name : 'model-only-skill' ,
description : 'Model-only skill' ,
2026-07-28 17:55:49 +08:00
invocation : { modelInvocable : true , userInvocable : false } ,
2026-07-28 17:22:41 +08:00
source : 'runtime' ,
content : 'Model-only instructions.' ,
} )
2026-07-10 14:19:06 +08:00
2026-07-19 23:38:54 +08:00
const unknown = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c1' ) , name : 'skill' , arguments : { name : 'missing' } } )
const invalid = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c2' ) , name : 'skill' , arguments : { name : 'Bad_Name' } } )
const disabled = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c3' ) , name : 'skill' , arguments : { name : 'hidden-skill' } } )
2026-07-28 17:22:41 +08:00
const modelOnly = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c4' ) , name : 'skill' , arguments : { name : 'model-only-skill' } } )
2026-07-10 14:19:06 +08:00
expect ( unknown . isError ) . toBe ( true )
expect ( invalid . isError ) . toBe ( true )
expect ( disabled . isError ) . toBe ( true )
2026-07-28 17:22:41 +08:00
expect ( modelOnly . isError ) . toBe ( false )
2026-07-10 14:19:06 +08:00
const unknownBlock = unknown . content [ 0 ]
if ( unknownBlock ? . type !== 'text' ) throw new Error ( 'expected text tool result' )
expect ( unknownBlock . text ) . toContain ( 'skill "missing" is unknown or no longer available' )
} )
2026-07-29 21:48:15 +08:00
it ( 'checks model policy before provider loading and rechecks the loaded definition' , async ( ) = > {
const home = await tempDir ( 'tool-policy-before-load' )
const ctx = await setup ( home )
const getCalls : string [ ] = [ ]
2026-07-29 23:36:49 +08:00
ctx . skills . registerProvider ( ( ) = > ( {
2026-07-29 21:48:15 +08:00
name : 'policy-probe' ,
async list() {
return [
{
name : 'denied-skill' ,
description : 'Denied skill' ,
invocation : { modelInvocable : false , userInvocable : true } ,
provider : 'policy-probe' ,
source : 'test' ,
rank : 1 ,
locator : 'denied-skill' ,
} ,
{
name : 'policy-race-skill' ,
description : 'Policy race skill' ,
invocation : { modelInvocable : true , userInvocable : true } ,
provider : 'policy-probe' ,
source : 'test' ,
rank : 1 ,
locator : 'policy-race-skill' ,
} ,
2026-07-29 22:03:01 +08:00
{
name : 'vanishing-skill' ,
description : 'Vanishing skill' ,
invocation : { modelInvocable : true , userInvocable : true } ,
provider : 'policy-probe' ,
source : 'test' ,
rank : 1 ,
locator : 'vanishing-skill' ,
} ,
2026-07-29 21:48:15 +08:00
]
} ,
async get ( candidate ) {
getCalls . push ( candidate . name )
2026-07-29 22:03:01 +08:00
if ( candidate . name === 'vanishing-skill' ) return undefined
2026-07-29 21:48:15 +08:00
return {
. . . candidate ,
invocation : { modelInvocable : false , userInvocable : true } ,
content : 'Instructions must not be disclosed.' ,
}
} ,
2026-07-29 23:36:49 +08:00
} ) )
2026-07-29 21:48:15 +08:00
const denied = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c6' ) , name : 'skill' , arguments : { name : 'denied-skill' } } )
const raced = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c7' ) , name : 'skill' , arguments : { name : 'policy-race-skill' } } )
2026-07-29 22:03:01 +08:00
const vanished = await ctx . tools . execute ( { signal : testToolSignal , callId : CallId ( 'c8' ) , name : 'skill' , arguments : { name : 'vanishing-skill' } } )
2026-07-29 21:48:15 +08:00
expect ( denied . isError ) . toBe ( true )
expect ( raced . isError ) . toBe ( true )
2026-07-29 22:03:01 +08:00
expect ( vanished . isError ) . toBe ( true )
expect ( getCalls ) . toEqual ( [ 'policy-race-skill' , 'vanishing-skill' ] )
2026-07-29 21:48:15 +08:00
for ( const result of [ denied , raced ] ) {
const block = result . content [ 0 ]
if ( block ? . type !== 'text' ) throw new Error ( 'expected text tool result' )
expect ( block . text ) . toContain ( 'is not available for model invocation' )
expect ( block . text ) . not . toContain ( 'Instructions must not be disclosed.' )
}
2026-07-29 22:03:01 +08:00
const vanishedBlock = vanished . content [ 0 ]
if ( vanishedBlock ? . type !== 'text' ) throw new Error ( 'expected text tool result' )
expect ( vanishedBlock . text ) . toContain ( 'skill "vanishing-skill" is unknown or no longer available' )
2026-07-29 21:48:15 +08:00
} )
2026-07-10 14:19:06 +08:00
} )