diff --git a/agent/robot/api/activities.go b/agent/robot/api/activities.go index d2855dbd..dbf73b61 100644 --- a/agent/robot/api/activities.go +++ b/agent/robot/api/activities.go @@ -16,6 +16,7 @@ type ActivityQuery struct { TeamID string `json:"team_id,omitempty"` // Filter by team ID Limit int `json:"limit,omitempty"` Since *time.Time `json:"since,omitempty"` // Only activities after this time + Type string `json:"type,omitempty"` // Filter by activity type: execution.started, execution.completed, execution.failed, execution.cancelled } // Activity - activity item for feed @@ -53,6 +54,11 @@ func ListActivities(ctx *types.Context, query *ActivityQuery) (*ActivityListResp opts.TeamID = query.TeamID } + // Pass type filter if provided + if query.Type != "" { + opts.Type = store.ActivityType(query.Type) + } + // Query from store storeActivities, err := getExecutionStore().ListActivities(context.Background(), opts) if err != nil { diff --git a/agent/robot/store/execution.go b/agent/robot/store/execution.go index fe94ecbf..3c5c3b9a 100644 --- a/agent/robot/store/execution.go +++ b/agent/robot/store/execution.go @@ -897,9 +897,10 @@ type Activity struct { // ActivityListOptions - options for listing activities type ActivityListOptions struct { - TeamID string `json:"team_id,omitempty"` // Filter by team ID - Since *time.Time `json:"since,omitempty"` // Only activities after this time - Limit int `json:"limit,omitempty"` + TeamID string `json:"team_id,omitempty"` // Filter by team ID + Since *time.Time `json:"since,omitempty"` // Only activities after this time + Limit int `json:"limit,omitempty"` + Type ActivityType `json:"type,omitempty"` // Filter by activity type } // ListActivities derives activities from recent execution status changes @@ -912,13 +913,31 @@ func (s *ExecutionStore) ListActivities(ctx context.Context, opts *ActivityListO // Build where conditions var wheres []model.QueryWhere - // Only completed, failed, or cancelled executions generate activities - // For started activities, we'd need running status - wheres = append(wheres, model.QueryWhere{ - Column: "status", - OP: "in", - Value: []string{"completed", "failed", "cancelled", "running"}, - }) + // Filter by activity type if specified + // Map activity types to execution statuses + if opts != nil && opts.Type != "" { + switch opts.Type { + case ActivityExecutionStarted: + wheres = append(wheres, model.QueryWhere{Column: "status", Value: "running"}) + case ActivityExecutionCompleted: + wheres = append(wheres, model.QueryWhere{Column: "status", Value: "completed"}) + case ActivityExecutionFailed: + wheres = append(wheres, model.QueryWhere{Column: "status", Value: "failed"}) + case ActivityExecutionCancelled: + wheres = append(wheres, model.QueryWhere{Column: "status", Value: "cancelled"}) + default: + // Unknown type, return empty + return []*Activity{}, nil + } + } else { + // Only completed, failed, or cancelled executions generate activities + // For started activities, we'd need running status + wheres = append(wheres, model.QueryWhere{ + Column: "status", + OP: "in", + Value: []string{"completed", "failed", "cancelled", "running"}, + }) + } if opts != nil { if opts.TeamID != "" { diff --git a/agent/robot/store/execution_test.go b/agent/robot/store/execution_test.go index 4d3ae8ca..9fac7d46 100644 --- a/agent/robot/store/execution_test.go +++ b/agent/robot/store/execution_test.go @@ -1221,6 +1221,45 @@ func TestExecutionStoreListActivities(t *testing.T) { assert.Greater(t, typeCount[store.ActivityExecutionFailed], 0, "should have failed activities") }) + t.Run("filters_by_type_completed", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + Type: store.ActivityExecutionCompleted, + }) + require.NoError(t, err) + + // All returned activities should be of type completed + for _, a := range activities { + assert.Equal(t, store.ActivityExecutionCompleted, a.Type, "all activities should be completed type") + } + assert.Greater(t, len(activities), 0, "should have at least one completed activity") + }) + + t.Run("filters_by_type_failed", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + Type: store.ActivityExecutionFailed, + }) + require.NoError(t, err) + + // All returned activities should be of type failed + for _, a := range activities { + assert.Equal(t, store.ActivityExecutionFailed, a.Type, "all activities should be failed type") + } + assert.Greater(t, len(activities), 0, "should have at least one failed activity") + }) + + t.Run("filters_by_type_invalid_returns_empty", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + Type: store.ActivityType("invalid.type"), + }) + require.NoError(t, err) + + // Invalid type should return empty result + assert.Equal(t, 0, len(activities), "invalid type should return empty result") + }) + t.Run("includes_execution_name_in_message", func(t *testing.T) { activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ TeamID: "team_activity_001", diff --git a/openapi/agent/robot/TODO.md b/openapi/agent/robot/TODO.md index eb7aecd8..1361d325 100644 --- a/openapi/agent/robot/TODO.md +++ b/openapi/agent/robot/TODO.md @@ -785,9 +785,14 @@ var uiMessages = map[string]map[string]string{ **OpenAPI Integration Tests:** `openapi/tests/agent/robot_results_activities_test.go` ✅ - [x] `TestListResults` - test with filters, pagination, keyword search - [x] `TestGetResult` - test single result detail -- [x] `TestListActivities` - test activity feed with `since` parameter +- [x] `TestListActivities` - test activity feed with `since` and `type` parameters - [x] `TestResultsPermissions` - test permission checks +**Store Layer Unit Tests:** `agent/robot/store/execution_test.go` ✅ +- [x] `filters_by_type_completed` - test filtering by completed type +- [x] `filters_by_type_failed` - test filtering by failed type +- [x] `filters_by_type_invalid_returns_empty` - test invalid type returns empty + **Permissions:** ✅ - [x] Added to `yaobots/openapi/scopes/agent/robots.yml` - [x] Added to `yaobots/openapi/scopes/alias.yml` @@ -834,9 +839,17 @@ var uiMessages = map[string]map[string]string{ - [x] Updated to use `ResultDetail` type from API - [x] Displays delivery content (summary, body, attachments) -**Activity Feed:** -- [ ] Replace mock data with `listActivities()` API (TODO - not yet integrated in UI) -- [ ] Implement auto-refresh (polling or SSE later) +**Activity Feed:** ✅ +- [x] Replace mock data with `listActivities()` API +- [x] Added `loadActivities()` function to fetch from API +- [x] Periodic refresh (30s polling, same as robots) +- [x] Updated Activity Banner to use API data format +- [x] Updated Activity Modal to use API data format +- [x] Added loading and empty states +- [x] Added `type` filter parameter to API (full stack: store → API → OpenAPI → SDK → UI) +- [x] Filter to show only `execution.completed` via API `type` param (not client-side) +- [x] Reset carousel index on data refresh (show latest activity first) +- [x] Click activity item to open result detail modal (overlays activity list) **Error Handling UI:** ✅ - [x] Error state displays centered in content area (not in toolbar) @@ -848,7 +861,15 @@ var uiMessages = map[string]map[string]string{ - [x] Results display correctly with delivery content - [x] Attachments show properly - [x] Error state displays properly with retry option -- [ ] Activity feed updates in real-time (pending - Activity UI not yet connected) +- [x] Activity feed displays from API (30s polling refresh) +- [x] Activity item click opens result detail + +--- + +### Future Enhancements (Not in current scope) + +- [ ] Activity feed real-time updates via SSE/WebSocket +- [ ] Push notifications for new results --- diff --git a/openapi/agent/robot/activities.go b/openapi/agent/robot/activities.go index 6230417d..991f8233 100644 --- a/openapi/agent/robot/activities.go +++ b/openapi/agent/robot/activities.go @@ -65,6 +65,7 @@ func ListActivities(c *gin.Context) { query := &robotapi.ActivityQuery{ TeamID: teamID, Limit: filter.Limit, + Type: filter.Type, // Pass type filter } // Parse 'since' if provided diff --git a/openapi/agent/robot/types.go b/openapi/agent/robot/types.go index 6c3a35eb..3695b9d2 100644 --- a/openapi/agent/robot/types.go +++ b/openapi/agent/robot/types.go @@ -548,6 +548,7 @@ func NewResultDetailResponse(detail *robotapi.ResultDetail) *ResultDetailRespons type ActivityFilter struct { Limit int `form:"limit"` // max number of activities Since string `form:"since"` // ISO timestamp, only activities after this time + Type string `form:"type"` // activity type filter: execution.started, execution.completed, execution.failed, execution.cancelled } // ActivityResponse - activity item diff --git a/openapi/tests/agent/robot_results_activities_test.go b/openapi/tests/agent/robot_results_activities_test.go index b31b33ac..8fab8167 100644 --- a/openapi/tests/agent/robot_results_activities_test.go +++ b/openapi/tests/agent/robot_results_activities_test.go @@ -301,6 +301,88 @@ func TestListActivities(t *testing.T) { assert.Contains(t, response, "data") }) + t.Run("ListActivitiesWithTypeFilter", func(t *testing.T) { + // Test filtering by type: execution.completed + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=execution.completed", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Contains(t, response, "data") + + // Verify all returned activities are of the specified type + data, ok := response["data"].([]interface{}) + if ok && len(data) > 0 { + for _, item := range data { + activity := item.(map[string]interface{}) + assert.Equal(t, "execution.completed", activity["type"]) + } + } + }) + + t.Run("ListActivitiesWithTypeStarted", func(t *testing.T) { + // Test filtering by type: execution.started + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=execution.started", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Contains(t, response, "data") + + // Verify all returned activities are of the specified type + data, ok := response["data"].([]interface{}) + if ok && len(data) > 0 { + for _, item := range data { + activity := item.(map[string]interface{}) + assert.Equal(t, "execution.started", activity["type"]) + } + } + }) + + t.Run("ListActivitiesWithInvalidType", func(t *testing.T) { + // Test with an invalid/unknown type - should return empty data (not error) + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=invalid.type", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Contains(t, response, "data") + // Invalid type should return empty array + data, ok := response["data"].([]interface{}) + assert.True(t, ok) + assert.Empty(t, data) + }) + t.Run("ListActivitiesWithSince", func(t *testing.T) { // Use a timestamp in the past - URL encode properly since := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)