github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/skills_test.go (about)

     1  package agenttoolset
     2  
     3  import (
     4  	"context"
     5  	"net/http"
     6  	"net/http/httptest"
     7  	"os"
     8  	"path/filepath"
     9  	"slices"
    10  	"sync"
    11  	"testing"
    12  
    13  	anthropic "github.com/anthropics/anthropic-sdk-go"
    14  	"github.com/anthropics/anthropic-sdk-go/option"
    15  )
    16  
    17  // SetupSkills must apply the request options it is given (the environment key,
    18  // for self-hosted callers) to its API calls. The skill endpoints are
    19  // environment-scoped: if the per-call options are dropped the request falls
    20  // back to the client's default credentials and fails. This guards the
    21  // regression where SetupSkills ignored its opts and skills were silently never
    22  // downloaded under ANTHROPIC_ENVIRONMENT_KEY.
    23  //
    24  // The caller fetches the session and hands the snapshot in — SetupSkills never
    25  // looks it up — so the skill version get and download are its only requests.
    26  func TestSetupSkills_AppliesRequestOptions(t *testing.T) {
    27  	var mu sync.Mutex
    28  	var skillAuth []string
    29  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    30  		record := func() {
    31  			mu.Lock()
    32  			defer mu.Unlock()
    33  			skillAuth = append(skillAuth, r.Header.Get("Authorization"))
    34  		}
    35  		switch r.URL.Path {
    36  		case "/v1/sessions/sess_x":
    37  			w.Header().Set("Content-Type", "application/json")
    38  			_, _ = w.Write([]byte(`{"agent":{"skills":[{"skill_id":"skill_1","version":"1"}]}}`))
    39  		case "/v1/skills/skill_1/versions/1":
    40  			record()
    41  			w.Header().Set("Content-Type", "application/json")
    42  			_, _ = w.Write([]byte(`{"id":"1","name":"demo"}`))
    43  		case "/v1/skills/skill_1/versions/1/content":
    44  			record()
    45  			_, _ = w.Write(zipBytes(t, map[string]string{"SKILL.md": "hello"}))
    46  		default:
    47  			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
    48  			w.WriteHeader(http.StatusNotFound)
    49  		}
    50  	}))
    51  	defer srv.Close()
    52  
    53  	client := anthropic.NewClient(
    54  		option.WithBaseURL(srv.URL),
    55  		option.WithAPIKey("client-default-key"),
    56  		option.WithMaxRetries(0),
    57  	)
    58  	session, err := client.Beta.Sessions.Get(context.Background(), "sess_x", anthropic.BetaSessionGetParams{})
    59  	if err != nil {
    60  		t.Fatalf("session lookup returned error: %v", err)
    61  	}
    62  
    63  	workdir := t.TempDir()
    64  	env := &AgentToolContext{Workdir: workdir}
    65  	if err := env.SetupSkillsFromSession(context.Background(), client, session,
    66  		option.WithAuthToken("env-key-xyz")); err != nil {
    67  		t.Fatalf("SetupSkills returned error: %v", err)
    68  	}
    69  
    70  	mu.Lock()
    71  	defer mu.Unlock()
    72  	want := []string{"Bearer env-key-xyz", "Bearer env-key-xyz"}
    73  	if !slices.Equal(skillAuth, want) {
    74  		t.Errorf("skill request Authorization = %q, want %q (per-call options were dropped)", skillAuth, want)
    75  	}
    76  	// A silently skipped skill would leave the same empty workdir as a dropped
    77  	// option, so assert the download actually landed.
    78  	if _, err := os.Stat(filepath.Join(workdir, "skills", "demo", "SKILL.md")); err != nil {
    79  		t.Errorf("skill was not extracted: %v", err)
    80  	}
    81  }
    82  
    83  // Cleanup must remove only the skill directories the setup itself downloaded.
    84  // The skills dir is inside the caller's workdir; skills the caller placed
    85  // there by hand are its content, not ours to delete.
    86  func TestCleanup_RemovesOnlyDownloadedSkills(t *testing.T) {
    87  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    88  		switch r.URL.Path {
    89  		case "/v1/skills/skill_1/versions/1":
    90  			w.Header().Set("Content-Type", "application/json")
    91  			_, _ = w.Write([]byte(`{"id":"1","name":"demo"}`))
    92  		case "/v1/skills/skill_1/versions/1/content":
    93  			_, _ = w.Write(zipBytes(t, map[string]string{"SKILL.md": "hello"}))
    94  		default:
    95  			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
    96  			w.WriteHeader(http.StatusNotFound)
    97  		}
    98  	}))
    99  	defer srv.Close()
   100  	client := anthropic.NewClient(option.WithBaseURL(srv.URL), option.WithAPIKey("k"), option.WithMaxRetries(0))
   101  
   102  	workdir := t.TempDir()
   103  	preSeeded := filepath.Join(workdir, "skills", "my-local-skill")
   104  	if err := os.MkdirAll(preSeeded, 0o755); err != nil {
   105  		t.Fatal(err)
   106  	}
   107  	if err := os.WriteFile(filepath.Join(preSeeded, "file.md"), []byte("mine"), 0o644); err != nil {
   108  		t.Fatal(err)
   109  	}
   110  
   111  	var session anthropic.BetaManagedAgentsSession
   112  	if err := session.UnmarshalJSON([]byte(`{"agent":{"skills":[{"skill_id":"skill_1","version":"1"}]}}`)); err != nil {
   113  		t.Fatal(err)
   114  	}
   115  	env := &AgentToolContext{Workdir: workdir}
   116  	if err := env.SetupSkillsFromSession(context.Background(), client, &session); err != nil {
   117  		t.Fatalf("SetupSkillsFromSession returned error: %v", err)
   118  	}
   119  	if _, err := os.Stat(filepath.Join(workdir, "skills", "demo", "SKILL.md")); err != nil {
   120  		t.Fatalf("skill was not extracted: %v", err)
   121  	}
   122  
   123  	if err := env.Cleanup(); err != nil {
   124  		t.Fatalf("Cleanup returned error: %v", err)
   125  	}
   126  	if _, err := os.Stat(filepath.Join(preSeeded, "file.md")); err != nil {
   127  		t.Errorf("Cleanup deleted the caller's own skill: %v", err)
   128  	}
   129  	if _, err := os.Stat(filepath.Join(workdir, "skills", "demo")); !os.IsNotExist(err) {
   130  		t.Errorf("Cleanup left the downloaded skill behind (stat err = %v)", err)
   131  	}
   132  }
   133  
   134  // A session with no skills downloads nothing, so Cleanup has nothing to remove
   135  // — caller-placed content under {Workdir}/skills is untouched.
   136  func TestCleanup_NoDownloadsIsANoOp(t *testing.T) {
   137  	workdir := t.TempDir()
   138  	preSeeded := filepath.Join(workdir, "skills", "my-local-skill")
   139  	if err := os.MkdirAll(preSeeded, 0o755); err != nil {
   140  		t.Fatal(err)
   141  	}
   142  	if err := os.WriteFile(filepath.Join(preSeeded, "file.md"), []byte("mine"), 0o644); err != nil {
   143  		t.Fatal(err)
   144  	}
   145  
   146  	client := anthropic.NewClient(option.WithAPIKey("k"), option.WithMaxRetries(0))
   147  	var session anthropic.BetaManagedAgentsSession
   148  	if err := session.UnmarshalJSON([]byte(`{"agent":{"skills":[]}}`)); err != nil {
   149  		t.Fatal(err)
   150  	}
   151  	env := &AgentToolContext{Workdir: workdir}
   152  	if err := env.SetupSkillsFromSession(context.Background(), client, &session); err != nil {
   153  		t.Fatalf("SetupSkillsFromSession returned error: %v", err)
   154  	}
   155  	if err := env.Cleanup(); err != nil {
   156  		t.Fatalf("Cleanup returned error: %v", err)
   157  	}
   158  	if _, err := os.Stat(filepath.Join(preSeeded, "file.md")); err != nil {
   159  		t.Errorf("Cleanup deleted the caller's own skill: %v", err)
   160  	}
   161  }
   162  
   163  // The configured skill version may be the alias "latest", which only the
   164  // version retrieve endpoint resolves: SetupSkills retrieves by the configured
   165  // version and downloads by the concrete id that came back.
   166  func TestSetupSkills_DownloadsByResolvedVersionID(t *testing.T) {
   167  	var mu sync.Mutex
   168  	var paths []string
   169  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   170  		mu.Lock()
   171  		paths = append(paths, r.URL.Path)
   172  		mu.Unlock()
   173  		switch r.URL.Path {
   174  		case "/v1/skills/skill_1/versions/latest":
   175  			w.Header().Set("Content-Type", "application/json")
   176  			_, _ = w.Write([]byte(`{"id":"skillver_123","name":"demo"}`))
   177  		case "/v1/skills/skill_1/versions/skillver_123/content":
   178  			_, _ = w.Write(zipBytes(t, map[string]string{"SKILL.md": "hello"}))
   179  		default:
   180  			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
   181  			w.WriteHeader(http.StatusNotFound)
   182  		}
   183  	}))
   184  	defer srv.Close()
   185  	client := anthropic.NewClient(option.WithBaseURL(srv.URL), option.WithAPIKey("k"), option.WithMaxRetries(0))
   186  
   187  	var session anthropic.BetaManagedAgentsSession
   188  	if err := session.UnmarshalJSON([]byte(`{"agent":{"skills":[{"skill_id":"skill_1","version":"latest"}]}}`)); err != nil {
   189  		t.Fatal(err)
   190  	}
   191  	workdir := t.TempDir()
   192  	env := &AgentToolContext{Workdir: workdir}
   193  	if err := env.SetupSkillsFromSession(context.Background(), client, &session); err != nil {
   194  		t.Fatalf("SetupSkillsFromSession returned error: %v", err)
   195  	}
   196  	mu.Lock()
   197  	defer mu.Unlock()
   198  	want := []string{"/v1/skills/skill_1/versions/latest", "/v1/skills/skill_1/versions/skillver_123/content"}
   199  	if !slices.Equal(paths, want) {
   200  		t.Errorf("requests = %q, want %q", paths, want)
   201  	}
   202  	if _, err := os.Stat(filepath.Join(workdir, "skills", "demo", "SKILL.md")); err != nil {
   203  		t.Errorf("skill was not extracted: %v", err)
   204  	}
   205  }
   206  
   207  // SetupSkills is the back-compat entry point for callers that hold only a
   208  // session id: it fetches the session, applying the same per-call options as the
   209  // skill requests (the session endpoint is environment-scoped too), and then
   210  // does exactly what SetupSkills does.
   211  func TestSetupSkills_FetchesTheSessionWithTheSameOptions(t *testing.T) {
   212  	var mu sync.Mutex
   213  	var auth []string
   214  	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   215  		mu.Lock()
   216  		auth = append(auth, r.URL.Path+" "+r.Header.Get("Authorization"))
   217  		mu.Unlock()
   218  		switch r.URL.Path {
   219  		case "/v1/sessions/sess_x":
   220  			w.Header().Set("Content-Type", "application/json")
   221  			_, _ = w.Write([]byte(`{"agent":{"skills":[{"skill_id":"skill_1","version":"1"}]}}`))
   222  		case "/v1/skills/skill_1/versions/1":
   223  			w.Header().Set("Content-Type", "application/json")
   224  			_, _ = w.Write([]byte(`{"id":"1","name":"demo"}`))
   225  		case "/v1/skills/skill_1/versions/1/content":
   226  			_, _ = w.Write(zipBytes(t, map[string]string{"SKILL.md": "hello"}))
   227  		default:
   228  			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
   229  			w.WriteHeader(http.StatusNotFound)
   230  		}
   231  	}))
   232  	defer srv.Close()
   233  
   234  	client := anthropic.NewClient(
   235  		option.WithBaseURL(srv.URL),
   236  		option.WithAPIKey("client-default-key"),
   237  		option.WithMaxRetries(0),
   238  	)
   239  	workdir := t.TempDir()
   240  	env := &AgentToolContext{Workdir: workdir}
   241  	if err := env.SetupSkills(context.Background(), client, "sess_x",
   242  		option.WithAuthToken("env-key-xyz")); err != nil {
   243  		t.Fatalf("SetupSkills returned error: %v", err)
   244  	}
   245  
   246  	mu.Lock()
   247  	defer mu.Unlock()
   248  	// The extra session fetch this entry point costs, then the same two skill
   249  	// requests SetupSkills makes — all three carrying the caller's credential.
   250  	want := []string{
   251  		"/v1/sessions/sess_x Bearer env-key-xyz",
   252  		"/v1/skills/skill_1/versions/1 Bearer env-key-xyz",
   253  		"/v1/skills/skill_1/versions/1/content Bearer env-key-xyz",
   254  	}
   255  	if !slices.Equal(auth, want) {
   256  		t.Errorf("requests = %q, want %q", auth, want)
   257  	}
   258  	if _, err := os.Stat(filepath.Join(workdir, "skills", "demo", "SKILL.md")); err != nil {
   259  		t.Errorf("skill was not extracted: %v", err)
   260  	}
   261  }