Skip to content

Commit 7d4d8aa

Browse files
David-KreinerHarness
authored andcommitted
feat: [ML-1197]: Improve reliability of the uber agent (#41)
* feat: [ML-1197]: Fix IDP headers * feat: [ML-1197]: Add unit tests for client * feat: [ML-1197]: make format * feat: [ML-1197]: Fix dashboard unmarshalling error and 422 from ai-devops
1 parent 22c16b1 commit 7d4d8aa

File tree

7 files changed

+265
-9
lines changed

7 files changed

+265
-9
lines changed

client/client.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,13 @@ func unmarshalResponse(resp *http.Response, data interface{}) error {
355355
// If we couldn't parse it as an error response, continue with normal unmarshaling
356356
}
357357

358+
// Special handling for string responses: if data is a string pointer, directly assign the body content
359+
if strPtr, ok := data.(*string); ok {
360+
*strPtr = string(body)
361+
return nil
362+
}
363+
364+
// Otherwise try to unmarshal as JSON
358365
err = json.Unmarshal(body, data)
359366
if err != nil {
360367
return fmt.Errorf("error deserializing response body : %w - original response: %s", err, string(body))

client/client_test.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestUnmarshalResponse_StringPointer tests the string pointer handling in unmarshalResponse function
13+
func TestUnmarshalResponse_StringPointer(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
responseBody string
17+
expectedResult string
18+
}{
19+
{
20+
name: "Plain text response",
21+
responseBody: "This is a plain text response",
22+
expectedResult: "This is a plain text response",
23+
},
24+
{
25+
name: "JSON-like text response",
26+
responseBody: "{\"key\": \"value\"}",
27+
expectedResult: "{\"key\": \"value\"}",
28+
},
29+
{
30+
name: "Empty response",
31+
responseBody: "",
32+
expectedResult: "",
33+
},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
// Create a mock response
39+
resp := &http.Response{
40+
Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
41+
}
42+
43+
// Create a string pointer to unmarshal into
44+
var result string
45+
46+
// Call the function
47+
err := unmarshalResponse(resp, &result)
48+
49+
// Assert no error occurred
50+
if err != nil {
51+
t.Errorf("Expected no error, got %v", err)
52+
}
53+
54+
// Assert the result matches expected
55+
if tt.expectedResult != result {
56+
t.Errorf("Expected result %v, got %v", tt.expectedResult, result)
57+
}
58+
})
59+
}
60+
}
61+
62+
// TestUnmarshalResponse_JSONStruct tests the JSON unmarshaling into struct types
63+
func TestUnmarshalResponse_JSONStruct(t *testing.T) {
64+
// Define a test struct that matches a sample JSON response
65+
type testStruct struct {
66+
Name string `json:"name"`
67+
Value int `json:"value"`
68+
Enabled bool `json:"enabled"`
69+
}
70+
71+
tests := []struct {
72+
name string
73+
responseBody string
74+
expectedResult testStruct
75+
}{
76+
{
77+
name: "Valid JSON struct",
78+
responseBody: `{"name":"test","value":42,"enabled":true}`,
79+
expectedResult: testStruct{
80+
Name: "test",
81+
Value: 42,
82+
Enabled: true,
83+
},
84+
},
85+
{
86+
name: "Partial JSON struct",
87+
responseBody: `{"name":"partial"}`,
88+
expectedResult: testStruct{
89+
Name: "partial",
90+
Value: 0,
91+
Enabled: false,
92+
},
93+
},
94+
{
95+
name: "Complex nested JSON",
96+
responseBody: `{"name":"nested","value":123,"enabled":true}`,
97+
expectedResult: testStruct{
98+
Name: "nested",
99+
Value: 123,
100+
Enabled: true,
101+
},
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
// Create a mock response
108+
resp := &http.Response{
109+
Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
110+
}
111+
112+
// Create a struct to unmarshal into
113+
var result testStruct
114+
115+
// Call the function
116+
err := unmarshalResponse(resp, &result)
117+
118+
// Assert no error occurred
119+
if err != nil {
120+
t.Errorf("Expected no error, got %v", err)
121+
}
122+
123+
// Assert the result matches expected
124+
if tt.expectedResult.Name != result.Name ||
125+
tt.expectedResult.Value != result.Value ||
126+
tt.expectedResult.Enabled != result.Enabled {
127+
t.Errorf("Expected result %+v, got %+v", tt.expectedResult, result)
128+
}
129+
})
130+
}
131+
}
132+
133+
// TestUnmarshalResponse_ErrorResponse tests the error response handling in unmarshalResponse function
134+
func TestUnmarshalResponse_ErrorResponse(t *testing.T) {
135+
tests := []struct {
136+
name string
137+
responseBody string
138+
statusCode int
139+
expectedError string
140+
}{
141+
{
142+
name: "API error response",
143+
responseBody: `{"code":"INVALID_REQUEST","message":"Invalid input parameters"}`,
144+
statusCode: 400,
145+
expectedError: "API error: Invalid input parameters",
146+
},
147+
{
148+
name: "Non-JSON error response",
149+
responseBody: "Internal server error occurred",
150+
statusCode: 500,
151+
// Non-JSON content will cause unmarshal error
152+
expectedError: "error deserializing response body",
153+
},
154+
}
155+
156+
for _, tt := range tests {
157+
t.Run(tt.name, func(t *testing.T) {
158+
// Create a mock response with error status code
159+
resp := &http.Response{
160+
StatusCode: tt.statusCode,
161+
Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
162+
}
163+
164+
// Create a struct to unmarshal into
165+
var result map[string]interface{}
166+
167+
// Call the function
168+
err := unmarshalResponse(resp, &result)
169+
170+
// Assert error occurred and contains expected message
171+
if err == nil {
172+
t.Errorf("Expected error, got nil")
173+
} else if !strings.Contains(err.Error(), tt.expectedError) {
174+
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
175+
}
176+
})
177+
}
178+
}
179+
180+
// TestUnmarshalResponse_ErrorCases tests various error handling scenarios in unmarshalResponse function
181+
func TestUnmarshalResponse_ErrorCases(t *testing.T) {
182+
tests := []struct {
183+
name string
184+
resp *http.Response
185+
expectedError string
186+
}{
187+
{
188+
name: "Nil response",
189+
resp: nil,
190+
expectedError: "http response body is not available",
191+
},
192+
{
193+
name: "Nil body",
194+
resp: &http.Response{
195+
Body: nil,
196+
},
197+
expectedError: "http response body is not available",
198+
},
199+
{
200+
name: "Read body error",
201+
resp: &http.Response{
202+
Body: &errorReader{},
203+
},
204+
expectedError: "error reading response body",
205+
},
206+
{
207+
name: "Invalid JSON",
208+
resp: &http.Response{
209+
Body: io.NopCloser(bytes.NewBufferString("{invalid json:}")),
210+
},
211+
expectedError: "error deserializing response body",
212+
},
213+
}
214+
215+
for _, tt := range tests {
216+
t.Run(tt.name, func(t *testing.T) {
217+
// Create a struct to unmarshal into
218+
var result map[string]interface{}
219+
220+
// Call the function
221+
err := unmarshalResponse(tt.resp, &result)
222+
223+
// Assert error occurred and contains expected message
224+
if err == nil {
225+
t.Errorf("Expected error, got nil")
226+
} else if !strings.Contains(err.Error(), tt.expectedError) {
227+
t.Errorf("Expected error containing '%s', got '%s'", tt.expectedError, err.Error())
228+
}
229+
})
230+
}
231+
}
232+
233+
type errorReader struct{}
234+
235+
func (e *errorReader) Read(p []byte) (n int, err error) {
236+
return 0, errors.New("mock read error")
237+
}
238+
239+
func (e *errorReader) Close() error {
240+
return nil
241+
}

client/dto/genai.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package dto
22

3+
type ContextType string
4+
5+
const (
6+
ContextTypeOther ContextType = "other"
7+
)
8+
39
type Capability struct {
410
Type string `json:"type"`
511
Version string `json:"version"`
612
}
713

814
type ContextItem struct {
9-
Type string `json:"type"`
10-
Payload any `json:"payload"`
15+
Type ContextType `json:"type"`
16+
Payload any `json:"payload"`
1117
}
1218

1319
type HarnessContext struct {

client/idp.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (i *IDPService) GetEntity(ctx context.Context, scope dto.Scope, kind string
4545

4646
response := new(dto.EntityResponse)
4747

48-
err := i.Client.Get(ctx, path, params, map[string]string{}, response)
48+
err := i.Client.Get(ctx, path, params, headers, response)
4949
if err != nil {
5050
return nil, err
5151
}
@@ -109,7 +109,7 @@ func (i *IDPService) ListEntities(ctx context.Context, scope dto.Scope, getEntit
109109

110110
response := make([]dto.EntityResponse, 0)
111111

112-
err := i.Client.Get(ctx, path, params, map[string]string{}, &response)
112+
err := i.Client.Get(ctx, path, params, headers, &response)
113113
if err != nil {
114114
return nil, err
115115
}

pkg/harness/genai.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ func AIDevOpsAgentTool(config *config.Config, client *client.GenaiService) (tool
3838
"properties": map[string]any{
3939
"type": map[string]any{
4040
"type": "string",
41-
"description": "The type of context item",
41+
"description": "The type of context item (other)",
42+
"enum": []string{string(dto.ContextTypeOther)},
4243
},
4344
"payload": map[string]any{
44-
"description": "The payload for this context item",
45+
"type": []string{"object", "array", "string", "number", "boolean"},
46+
"description": "The payload for this context item, accepts any valid JSON value. Example: {\"stage_type\": \"Custom\"}",
4547
},
4648
},
4749
"required": []string{"type", "payload"},
@@ -112,7 +114,7 @@ func AIDevOpsAgentTool(config *config.Config, client *client.GenaiService) (tool
112114
ctxType, _ := ctxMap["type"].(string)
113115
ctxPayload := ctxMap["payload"]
114116
contextItems = append(contextItems, dto.ContextItem{
115-
Type: ctxType,
117+
Type: dto.ContextType(ctxType),
116118
Payload: ctxPayload,
117119
})
118120
}

pkg/harness/pipelines.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414

1515
func GetPipelineTool(config *config.Config, client *client.PipelineService) (tool mcp.Tool, handler server.ToolHandlerFunc) {
1616
return mcp.NewTool("get_pipeline",
17-
mcp.WithDescription("Get details of a specific pipeline in a Harness repository."),
17+
mcp.WithDescription("Get details of a specific pipeline in a Harness repository. Use list_pipelines (if available) first to find the correct pipeline_id if you're unsure of the exact ID."),
1818
mcp.WithString("pipeline_id",
1919
mcp.Required(),
2020
mcp.Description("The ID of the pipeline"),

pkg/harness/tools.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const aiServiceIdentity = "aifoundation"
2525
var defaultJWTLifetime = 1 * time.Hour
2626

2727
// Default timeout for GenAI service
28-
const defaultGenaiTimeout = 60 * time.Second
28+
const defaultGenaiTimeout = 300 * time.Second
2929

3030
// InitToolsets initializes and returns the toolset groups
3131
func InitToolsets(config *config.Config) (*toolsets.ToolsetGroup, error) {

0 commit comments

Comments
 (0)