diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go new file mode 100644 index 00000000000..701e96b6e18 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type deleteFlags struct { + name string + force bool + output string + noPrompt bool +} + +func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &deleteFlags{} + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "delete [name]", + Short: "Delete a hosted agent.", + Long: `Delete a hosted agent and all of its versions. + +If the agent has active sessions, deletion will fail unless --force is passed. +Use --force to terminate active sessions and delete the agent. + +The agent name is resolved from the azd environment when omitted.`, + Example: ` # Delete agent (auto-resolves name from azure.yaml) + azd ai agent delete + + # Delete a specific agent by name + azd ai agent delete my-agent + + # Force-delete even if active sessions exist + azd ai agent delete my-agent --force`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + flags.name = args[0] + } + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + ctx := azdext.WithAccessToken(cmd.Context()) + + action := &DeleteAction{flags: flags} + return action.Run(ctx) + }, + } + + cmd.Flags().BoolVar( + &flags.force, "force", false, + "Force deletion even if the agent has active sessions", + ) + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "none"}, + Default: "none", + }) + + return cmd +} + +// DeleteAction implements the agent delete command. +type DeleteAction struct { + flags *deleteFlags +} + +func (a *DeleteAction) Run(ctx context.Context) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + info, err := resolveAgentServiceFromProject(ctx, azdClient, a.flags.name, a.flags.noPrompt) + if err != nil { + return err + } + + agentName := info.AgentName + if agentName == "" { + agentName = a.flags.name + } + if agentName == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentName, + "agent name is required but could not be resolved", + "provide the agent name as a positional argument or "+ + "deploy the agent with 'azd up' first", + ) + } + + // Confirmation prompt (skip in --no-prompt mode) + if !a.flags.noPrompt { + message := fmt.Sprintf("Delete agent %q and all its versions?", agentName) + if a.flags.force { + message = fmt.Sprintf( + "Force-delete agent %q? This will terminate all active sessions.", + agentName, + ) + } + defaultValue := false + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: message, + DefaultValue: &defaultValue, + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("delete cancelled") + } + return fmt.Errorf("prompting for confirmation: %w", promptErr) + } + if resp.Value == nil || !*resp.Value { + return exterrors.Cancelled("delete cancelled by user") + } + } + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return err + } + + client := agent_api.NewAgentClient(endpoint, credential) + + result, err := client.DeleteAgent(ctx, agentName, DefaultAgentAPIVersion, a.flags.force) + if err != nil { + return classifyDeleteError(err, agentName) + } + + // Best-effort: clear AGENT_{KEY}_NAME and AGENT_{KEY}_VERSION env vars + a.cleanupEnvVars(ctx, azdClient, info.ServiceName) + + switch a.flags.output { + case "json": + data, jsonErr := json.MarshalIndent(result, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + fmt.Printf("Agent %q deleted.\n", agentName) + } + + return nil +} + +// cleanupEnvVars removes AGENT_{KEY}_NAME, AGENT_{KEY}_VERSION, and +// AGENT_{KEY}_ENDPOINT from the azd environment after a successful delete. +// The SDK has no DeleteValue API, so we set values to empty string as a workaround. +func (a *DeleteAction) cleanupEnvVars(ctx context.Context, azdClient *azdext.AzdClient, serviceName string) { + if serviceName == "" { + return + } + + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return + } + envName := envResp.Environment.Name + + serviceKey := toServiceKey(serviceName) + keys := []string{ + fmt.Sprintf("AGENT_%s_NAME", serviceKey), + fmt.Sprintf("AGENT_%s_VERSION", serviceKey), + fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), + } + + for _, key := range keys { + if _, err := azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: key, + Value: "", + }); err != nil { + log.Printf("delete: failed to clear env var %s: %v", key, err) + } + } +} + +// classifyDeleteError maps Azure API errors from the delete operation into +// user-friendly typed errors. Exported for testing. +func classifyDeleteError(err error, agentName string) error { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + switch respErr.StatusCode { + case http.StatusNotFound: + return exterrors.Validation( + exterrors.CodeAgentNotFound, + fmt.Sprintf("agent %q not found", agentName), + "use 'azd ai agent show' to verify the agent exists", + ) + case http.StatusConflict: + return exterrors.Validation( + exterrors.CodeAgentHasActiveSessions, + fmt.Sprintf( + "agent %q has active sessions and cannot be deleted", + agentName, + ), + "pass --force to terminate active sessions and delete the agent, "+ + "or delete sessions first with 'azd ai agent sessions delete'", + ) + } + } + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteAgent) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go new file mode 100644 index 00000000000..d75341b4f2e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "net/http" + "testing" + + "azureaiagent/internal/exterrors" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteCommand_AcceptsPositionalArg(t *testing.T) { + cmd := newDeleteCommand(nil) + err := cmd.Args(cmd, []string{"my-agent"}) + assert.NoError(t, err) +} + +func TestDeleteCommand_AcceptsNoArgs(t *testing.T) { + cmd := newDeleteCommand(nil) + err := cmd.Args(cmd, []string{}) + assert.NoError(t, err) +} + +func TestDeleteCommand_RejectsMultipleArgs(t *testing.T) { + cmd := newDeleteCommand(nil) + err := cmd.Args(cmd, []string{"svc1", "svc2"}) + assert.Error(t, err) +} + +func TestDeleteCommand_ForceFlag(t *testing.T) { + cmd := newDeleteCommand(nil) + flag := cmd.Flags().Lookup("force") + if flag == nil { + t.Fatal("expected --force flag to be registered") + } + if flag.DefValue != "false" { + t.Fatalf("expected --force default false, got %q", flag.DefValue) + } +} + +func TestDeleteCommand_OutputFlagAnnotation(t *testing.T) { + cmd := newDeleteCommand(nil) + // RegisterFlagOptions stores allowed values in annotations + require.NotNil(t, cmd.Annotations) +} + +// --------------------------------------------------------------------------- +// Error classification tests — calls the real classifyDeleteError from delete.go +// --------------------------------------------------------------------------- + +func TestDeleteAgent_404_ProducesValidationError(t *testing.T) { + azErr := &azcore.ResponseError{ + StatusCode: http.StatusNotFound, + ErrorCode: "not_found", + } + + result := classifyDeleteError(azErr, "my-agent") + require.Error(t, result) + + var localErr *azdext.LocalError + require.True( + t, errors.As(result, &localErr), + "404 should produce a LocalError, got: %T", result, + ) + assert.Equal(t, exterrors.CodeAgentNotFound, localErr.Code) + assert.Contains(t, localErr.Message, "my-agent") + assert.Contains(t, localErr.Message, "not found") +} + +func TestDeleteAgent_409_ProducesValidationError(t *testing.T) { + azErr := &azcore.ResponseError{ + StatusCode: http.StatusConflict, + ErrorCode: "conflict", + } + + result := classifyDeleteError(azErr, "my-agent") + require.Error(t, result) + + var localErr *azdext.LocalError + require.True( + t, errors.As(result, &localErr), + "409 should produce a LocalError, got: %T", result, + ) + assert.Equal(t, exterrors.CodeAgentHasActiveSessions, localErr.Code) + assert.Contains(t, localErr.Message, "active sessions") + assert.Contains(t, localErr.Suggestion, "--force") +} + +func TestDeleteAgent_500_ProducesServiceError(t *testing.T) { + azErr := &azcore.ResponseError{ + StatusCode: http.StatusInternalServerError, + ErrorCode: "internal_error", + } + + result := classifyDeleteError(azErr, "my-agent") + require.Error(t, result) + + var svcErr *azdext.ServiceError + require.True( + t, errors.As(result, &svcErr), + "500 should produce a ServiceError, got: %T", result, + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index 8a94263bf51..48e2e6e352a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -57,6 +57,7 @@ func NewRootCommand() *cobra.Command { return rootCmd })) rootCmd.AddCommand(newShowCommand(extCtx)) + rootCmd.AddCommand(newDeleteCommand(extCtx)) rootCmd.AddCommand(newEndpointCommand(extCtx)) rootCmd.AddCommand(newMonitorCommand(extCtx)) rootCmd.AddCommand(newFilesCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 449dff025fd..87b7e0c1219 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -101,6 +101,12 @@ const ( CodeSessionNotFound = "session_not_found" ) +// Error codes for agent delete errors. +const ( + CodeAgentNotFound = "agent_not_found" + CodeAgentHasActiveSessions = "agent_has_active_sessions" +) + // Error codes for file operation errors. const ( CodeFileNotFound = "file_not_found" @@ -155,6 +161,7 @@ const ( OpContainerPackage = "container_package" OpContainerPublish = "container_publish" OpCreateAgent = "create_agent" + OpDeleteAgent = "delete_agent" OpStartContainer = "start_container" OpGetContainerOperation = "get_container_operation" OpCreateSession = "create_session" diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 8d966d63fdf..d1f8f00153d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -257,9 +257,10 @@ func (c *AgentClient) PatchAgent( return &agent, nil } -// DeleteAgent deletes an agent -func (c *AgentClient) DeleteAgent(ctx context.Context, agentName, apiVersion string) (*DeleteAgentResponse, error) { - url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion) +// DeleteAgent deletes an agent. When force is true, the agent is deleted even +// if it has active sessions. +func (c *AgentClient) DeleteAgent(ctx context.Context, agentName, apiVersion string, force bool) (*DeleteAgentResponse, error) { + url := fmt.Sprintf("%s/agents/%s?api-version=%s&force=%t", c.endpoint, agentName, apiVersion, force) req, err := runtime.NewRequest(ctx, http.MethodDelete, url) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index 4506f9d5b1b..2f08e208dea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -560,6 +560,45 @@ func TestGetAgentSessionLogStream_ApplyIsolationHeaders(t *testing.T) { requireIsolationHeaders(t, request, "user-1", "chat-1", "") } +func TestDeleteAgent_ForceTrue(t *testing.T) { + body := `{"object": "agent", "name": "my-agent", "deleted": true}` + client, transport := newCaptureClient(http.StatusOK, body) + + result, err := client.DeleteAgent(t.Context(), "my-agent", "v1", true) + require.NoError(t, err) + require.True(t, result.Deleted) + require.Equal(t, "my-agent", result.Name) + + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Equal(t, http.MethodDelete, req.Method) + require.Contains(t, req.URL.String(), "/agents/my-agent") + require.Contains(t, req.URL.RawQuery, "force=true") +} + +func TestDeleteAgent_ForceFalse(t *testing.T) { + body := `{"object": "agent", "name": "my-agent", "deleted": true}` + client, transport := newCaptureClient(http.StatusOK, body) + + result, err := client.DeleteAgent(t.Context(), "my-agent", "v1", false) + require.NoError(t, err) + require.True(t, result.Deleted) + + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Contains(t, req.URL.RawQuery, "force=false") +} + +func TestDeleteAgent_NotFound(t *testing.T) { + client := newTestClient( + "https://test.example.com/api/projects/proj", + &fakeBodyTransport{statusCode: http.StatusNotFound, body: `{"error": "not found"}`}, + ) + + _, err := client.DeleteAgent(t.Context(), "no-agent", "v1", true) + require.Error(t, err) +} + func TestPatchAgent_Success(t *testing.T) { body := `{ "object": "agent",