diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go index f2433f04d85..4da475ec24d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go @@ -18,6 +18,9 @@ import ( const DefaultAgentAPIVersion = "2025-05-15-preview" const DefaultVNextAgentAPIVersion = "2025-11-15-preview" +// ConversationsAPIVersion is the API version used by the Foundry Conversations protocol. +const ConversationsAPIVersion = "v1" + // AgentContext holds the common properties of a hosted agent. type AgentContext struct { ProjectEndpoint string diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index 58a04ec76ae..4f3c4c7e0aa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -363,7 +363,7 @@ func resolveConversationID( agentName string, explicit string, forceNew bool, - endpoint string, + projectEndpoint string, bearerToken string, ) (string, error) { if explicit != "" { @@ -386,7 +386,7 @@ func resolveConversationID( } // Create and persist a new conversation for multi-turn memory. - newConvID, err := createConversation(ctx, endpoint, bearerToken) + newConvID, err := createConversation(ctx, projectEndpoint, agentName, bearerToken) if err != nil { return "", fmt.Errorf("failed to create conversation: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 825d90c7b0e..cd8ddf86a8c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -359,7 +359,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return fmt.Errorf("agent name is required; provide as the first argument or define an azure.ai.agent service in azure.yaml") } - endpoint, err := resolveAgentEndpoint(ctx, "", "") + projectEndpoint, err := resolveAgentEndpoint(ctx, "", "") if err != nil { return err } @@ -417,7 +417,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { name, a.flags.conversation, a.flags.newConversation, - endpoint, + projectEndpoint, token.Token, ) if err != nil { @@ -440,10 +440,10 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { if vnext { url = fmt.Sprintf( "%s/agents/%s/endpoint/protocols/openai/responses?api-version=%s", - endpoint, name, DefaultAgentAPIVersion, + projectEndpoint, name, DefaultAgentAPIVersion, ) } else { - url = fmt.Sprintf("%s/openai/responses?api-version=%s", endpoint, DefaultAgentAPIVersion) + url = fmt.Sprintf("%s/openai/responses?api-version=%s", projectEndpoint, DefaultAgentAPIVersion) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { @@ -911,8 +911,11 @@ func handleInvocationLRO( } // createConversation creates a new Foundry conversation for multi-turn memory. -func createConversation(ctx context.Context, endpoint string, bearerToken string) (string, error) { - url := fmt.Sprintf("%s/openai/conversations?api-version=%s", endpoint, DefaultAgentAPIVersion) +func createConversation(ctx context.Context, projectEndpoint, agentName, bearerToken string) (string, error) { + url := fmt.Sprintf( + "%s/agents/%s/endpoint/protocols/openai/conversations?api-version=%s", + projectEndpoint, agentName, ConversationsAPIVersion, + ) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}"))) if err != nil { return "", err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go index 5b3d78851f6..38695480a99 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go @@ -828,3 +828,127 @@ type pollStep struct { retryAfter string repeat bool } + +func TestCreateConversation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agentName string + statusCode int + body string + wantID string + wantErr bool + errContain string + }{ + { + name: "success returns conversation ID", + agentName: "my-agent", + statusCode: 200, + body: `{"id":"conv-abc123"}`, + wantID: "conv-abc123", + }, + { + name: "HTTP 400 returns error", + agentName: "my-agent", + statusCode: 400, + body: `{"error":"bad request"}`, + wantErr: true, + errContain: "failed with HTTP 400", + }, + { + name: "HTTP 500 returns error", + agentName: "my-agent", + statusCode: 500, + body: `{"error":"internal"}`, + wantErr: true, + errContain: "failed with HTTP 500", + }, + { + name: "response missing id field", + agentName: "my-agent", + statusCode: 200, + body: `{"status":"ok"}`, + wantErr: true, + errContain: "missing 'id' field", + }, + { + name: "response with non-string id", + agentName: "my-agent", + statusCode: 200, + body: `{"id":12345}`, + wantErr: true, + errContain: "missing 'id' field", + }, + { + name: "invalid JSON response", + agentName: "my-agent", + statusCode: 200, + body: `not-json`, + wantErr: true, + errContain: "invalid character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + + // Verify path includes the agent name and conversations endpoint + wantPath := "/agents/" + tt.agentName + + "/endpoint/protocols/openai/conversations" + if r.URL.Path != wantPath { + t.Errorf("path = %s, want %s", r.URL.Path, wantPath) + } + + // Verify api-version query parameter uses the constant + if got := r.URL.Query().Get("api-version"); got != ConversationsAPIVersion { + t.Errorf("api-version = %q, want %q", got, ConversationsAPIVersion) + } + + // Verify auth header + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") + } + + // Verify content type + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want %q", got, "application/json") + } + + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + id, err := createConversation( + t.Context(), srv.URL, tt.agentName, "test-token", + ) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContain != "" && + !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("error = %q, want substring %q", + err.Error(), tt.errContain) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tt.wantID { + t.Errorf("id = %q, want %q", id, tt.wantID) + } + }) + } +}