Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions control-plane/internal/handlers/empty_input_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
package handlers

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/Agent-Field/agentfield/control-plane/internal/services"
"github.com/Agent-Field/agentfield/control-plane/internal/storage"
"github.com/Agent-Field/agentfield/control-plane/pkg/types"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

type reasonerTestStorage struct {
storage.StorageProvider
agent *types.AgentNode
executions []*types.WorkflowExecution
}

func (s *reasonerTestStorage) GetAgent(ctx context.Context, id string) (*types.AgentNode, error) {
if s.agent != nil && s.agent.ID == id {
return s.agent, nil
}
return nil, nil
}

func (s *reasonerTestStorage) StoreWorkflowExecution(ctx context.Context, execution *types.WorkflowExecution) error {
if execution != nil {
copy := *execution
s.executions = append(s.executions, &copy)
}
return nil
}

// TestExecuteHandler_EmptyInput verifies that calling the execute endpoint with
// an empty input object ({"input":{}}) succeeds instead of returning 400.
// Reproduction for https://github.com/Agent-Field/agentfield/issues/196.
func TestExecuteHandler_EmptyInput(t *testing.T) {
gin.SetMode(gin.TestMode)

agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
require.JSONEq(t, `{}`, string(body))

// Agent receives the (empty) input and returns success
var payload map[string]interface{}
require.NoError(t, json.Unmarshal(body, &payload))

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer agentServer.Close()

agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "ping"}},
}

store := newTestExecutionStorage(agent)
payloads := services.NewFilePayloadStore(t.TempDir())

router := gin.New()
router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, ""))

// Empty input object — should be accepted for parameterless skills
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping",
strings.NewReader(`{"input":{}}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()

router.ServeHTTP(resp, req)

require.Equal(t, http.StatusOK, resp.Code, "empty input {} should be accepted, got: %s", resp.Body.String())

var envelope ExecuteResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &envelope))
require.Equal(t, types.ExecutionStatusSucceeded, envelope.Status)
}

// TestExecuteHandler_NilInput verifies that calling the execute endpoint with
// no input field at all ({}) succeeds — the handler should default to empty map.
func TestExecuteHandler_NilInput(t *testing.T) {
gin.SetMode(gin.TestMode)

agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
require.JSONEq(t, `{}`, string(body))

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer agentServer.Close()

agent := &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "ping"}},
}

store := newTestExecutionStorage(agent)
payloads := services.NewFilePayloadStore(t.TempDir())

router := gin.New()
router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, ""))

// No input field at all — should default to empty map
req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping",
strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()

router.ServeHTTP(resp, req)

require.Equal(t, http.StatusOK, resp.Code, "missing input field should be accepted, got: %s", resp.Body.String())
}

// TestExecuteRequest_BindingAcceptsEmptyInput directly tests that the ExecuteRequest
// struct's binding tags accept empty and nil input (unit-level binding test).
func TestExecuteRequest_BindingAcceptsEmptyInput(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
body string
}{
{"empty input object", `{"input":{}}`},
{"no input field", `{}`},
{"input with context", `{"input":{},"context":{"session":"abc"}}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/", strings.NewReader(tt.body))
c.Request.Header.Set("Content-Type", "application/json")

var req ExecuteRequest
err := c.ShouldBindJSON(&req)
require.NoError(t, err, "binding should accept %s", tt.name)
})
}
}

// TestExecuteReasonerRequest_BindingAcceptsEmptyInput directly tests that the
// ExecuteReasonerRequest struct's binding tags accept empty and nil input.
func TestExecuteReasonerRequest_BindingAcceptsEmptyInput(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
body string
}{
{"empty input object", `{"input":{}}`},
{"no input field", `{}`},
{"input with context", `{"input":{},"context":{"session":"abc"}}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/", strings.NewReader(tt.body))
c.Request.Header.Set("Content-Type", "application/json")

var req ExecuteReasonerRequest
err := c.ShouldBindJSON(&req)
require.NoError(t, err, "binding should accept %s", tt.name)
})
}
}

// TestExecuteRequest_BindingStillRequiresJSON verifies that completely invalid
// input is still rejected (the fix doesn't break validation entirely).
func TestExecuteRequest_BindingStillRequiresJSON(t *testing.T) {
gin.SetMode(gin.TestMode)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/", strings.NewReader("not-json"))
c.Request.Header.Set("Content-Type", "application/json")

var req ExecuteRequest
err := c.ShouldBindJSON(&req)
require.Error(t, err, "invalid JSON should still be rejected")
}

func TestExecuteReasonerHandler_EmptyAndNilInput(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
body string
}{
{name: "empty input object", body: `{"input":{}}`},
{name: "nil input object", body: `{}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/reasoners/ping", r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
require.JSONEq(t, `{}`, string(body))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer agentServer.Close()

store := &reasonerTestStorage{agent: &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Reasoners: []types.ReasonerDefinition{{ID: "ping"}},
}}

router := gin.New()
router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store))

req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)

require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
require.NotEmpty(t, store.executions)
require.JSONEq(t, `{}`, string(store.executions[0].InputData))
})
}
}

func TestExecuteSkillHandler_EmptyAndNilInput(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
body string
}{
{name: "empty input object", body: `{"input":{}}`},
{name: "nil input object", body: `{}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/skills/list_items", r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
require.JSONEq(t, `{}`, string(body))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer agentServer.Close()

store := &reasonerTestStorage{agent: &types.AgentNode{
ID: "node-1",
BaseURL: agentServer.URL,
Skills: []types.SkillDefinition{{ID: "list_items"}},
}}

router := gin.New()
router.POST("/skills/:skill_id", ExecuteSkillHandler(store))

req := httptest.NewRequest(http.MethodPost, "/skills/node-1.list_items", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)

require.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
require.NotEmpty(t, store.executions)
require.JSONEq(t, `{}`, string(store.executions[0].InputData))
})
}
}
4 changes: 2 additions & 2 deletions control-plane/internal/handlers/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type ExecutionStore interface {

// ExecuteRequest represents an execution request from an agent client.
type ExecuteRequest struct {
Input map[string]interface{} `json:"input" binding:"required"`
Input map[string]interface{} `json:"input"`
Context map[string]interface{} `json:"context,omitempty"`
Webhook *WebhookRequest `json:"webhook,omitempty"`
}
Expand Down Expand Up @@ -895,7 +895,7 @@ func (c *executionController) prepareExecution(ctx context.Context, ginCtx *gin.
if err := ginCtx.ShouldBindJSON(&req); err != nil {
return nil, fmt.Errorf("invalid request body: %w", err)
}
// Allow empty input for skills/reasoners that take no parameters (e.g., ping, get_schema).
// Allow empty input for skills/reasoners that take no parameters (issue #196).
if req.Input == nil {
req.Input = map[string]interface{}{}
}
Expand Down
10 changes: 9 additions & 1 deletion control-plane/internal/handlers/reasoners.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (

// ExecuteReasonerRequest represents a request to execute a reasoner
type ExecuteReasonerRequest struct {
Input map[string]interface{} `json:"input" binding:"required"`
Input map[string]interface{} `json:"input"`
Context map[string]interface{} `json:"context,omitempty"`
}

Expand Down Expand Up @@ -102,6 +102,10 @@ func ExecuteReasonerHandler(storageProvider storage.StorageProvider) gin.Handler
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Allow empty input for reasoners that take no parameters.
if req.Input == nil {
req.Input = map[string]interface{}{}
}

// Find the agent node
targetNode, err := storageProvider.GetAgent(ctx, nodeID)
Expand Down Expand Up @@ -451,6 +455,10 @@ func ExecuteSkillHandler(storageProvider storage.StorageProvider) gin.HandlerFun
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Allow empty input for skills that take no parameters.
if req.Input == nil {
req.Input = map[string]interface{}{}
}

// Find the agent node
targetNode, err := storageProvider.GetAgent(ctx, nodeID)
Expand Down
Loading