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 }