Files
kandev/apps/backend/internal/agent/api/handlers_test.go
T
jcfs 20f5f43fb3 feat: complete acp-go-sdk integration with task state updates
- Use acp-go-sdk for all ACP communication in agentctl
- Mark agent as completed when Prompt() returns successfully
- Update task state to COMPLETED when agent completes
- Update task state to FAILED when agent fails and retries exhausted
- Enhanced E2E test to verify agent actually creates files
- E2E test now validates full flow: task creation -> agent work -> file creation -> task completion
2026-01-10 16:46:35 +00:00

574 lines
15 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/common/logger"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func init() {
gin.SetMode(gin.TestMode)
}
func newTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
return log
}
// MockLifecycleManager implements a mock for lifecycle.Manager
type MockLifecycleManager struct {
LaunchFn func(ctx context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentInstance, error)
StopAgentFn func(ctx context.Context, instanceID string, force bool) error
GetInstanceFn func(instanceID string) (*lifecycle.AgentInstance, bool)
ListInstancesFn func() []*lifecycle.AgentInstance
GetInstanceByTaskIDFn func(taskID string) (*lifecycle.AgentInstance, bool)
}
func (m *MockLifecycleManager) Launch(ctx context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentInstance, error) {
if m.LaunchFn != nil {
return m.LaunchFn(ctx, req)
}
return &lifecycle.AgentInstance{
ID: "mock-instance-id",
TaskID: req.TaskID,
AgentType: req.AgentType,
ContainerID: "mock-container-id",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
}, nil
}
func (m *MockLifecycleManager) StopAgent(ctx context.Context, instanceID string, force bool) error {
if m.StopAgentFn != nil {
return m.StopAgentFn(ctx, instanceID, force)
}
return nil
}
func (m *MockLifecycleManager) GetInstance(instanceID string) (*lifecycle.AgentInstance, bool) {
if m.GetInstanceFn != nil {
return m.GetInstanceFn(instanceID)
}
return nil, false
}
func (m *MockLifecycleManager) ListInstances() []*lifecycle.AgentInstance {
if m.ListInstancesFn != nil {
return m.ListInstancesFn()
}
return []*lifecycle.AgentInstance{}
}
func (m *MockLifecycleManager) GetInstanceByTaskID(taskID string) (*lifecycle.AgentInstance, bool) {
if m.GetInstanceByTaskIDFn != nil {
return m.GetInstanceByTaskIDFn(taskID)
}
return nil, false
}
// MockDockerClient for handler tests
type MockDockerClient struct {
GetContainerLogsFn func(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error)
}
func (m *MockDockerClient) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
if m.GetContainerLogsFn != nil {
return m.GetContainerLogsFn(ctx, containerID, follow, tail)
}
return io.NopCloser(strings.NewReader("test log line")), nil
}
func newTestRegistry() *registry.Registry {
log := newTestLogger()
reg := registry.NewRegistry(log)
reg.LoadDefaults()
return reg
}
// Helper to create a test router with handlers
func setupTestRouter(
mockLM *MockLifecycleManager,
reg *registry.Registry,
mockDocker *MockDockerClient,
) *gin.Engine {
log := newTestLogger()
router := gin.New()
apiV1 := router.Group("/api/v1")
// Create handler with lifecycle manager wrapper
handler := &Handler{
registry: reg,
docker: mockDocker,
acp: nil,
logger: log,
}
// Set up routes manually for testing
agents := apiV1.Group("/agents")
{
agents.GET("", func(c *gin.Context) {
instances := mockLM.ListInstances()
agentsList := make([]AgentInstanceResponse, 0, len(instances))
for _, instance := range instances {
agentsList = append(agentsList, instanceToResponse(instance))
}
c.JSON(http.StatusOK, AgentsListResponse{Agents: agentsList, Total: len(agentsList)})
})
agents.POST("/launch", func(c *gin.Context) {
var req LaunchAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
launchReq := &lifecycle.LaunchRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
Metadata: req.Metadata,
}
instance, err := mockLM.Launch(c.Request.Context(), launchReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, instanceToResponse(instance))
})
agents.GET("/types", handler.ListAgentTypes)
agents.GET("/types/:typeId", handler.GetAgentType)
agents.GET("/:instanceId/status", func(c *gin.Context) {
instanceID := c.Param("instanceId")
instance, found := mockLM.GetInstance(instanceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, instanceToResponse(instance))
})
agents.DELETE("/:instanceId", func(c *gin.Context) {
instanceID := c.Param("instanceId")
var req StopAgentRequest
_ = c.ShouldBindJSON(&req)
err := mockLM.StopAgent(c.Request.Context(), instanceID, req.Force)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "agent stopped successfully"})
})
}
router.GET("/health", handler.HealthCheck)
return router
}
func TestHandler_LaunchAgent(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
reqBody := LaunchAgentRequest{
TaskID: "task-123",
AgentType: "augment-agent",
WorkspacePath: "/path/to/workspace",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/v1/agents/launch", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String())
}
var resp AgentInstanceResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.TaskID != "task-123" {
t.Errorf("expected TaskID 'task-123', got %q", resp.TaskID)
}
}
func TestHandler_LaunchAgent_InvalidRequest(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
// Missing required fields
reqBody := map[string]string{}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/v1/agents/launch", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
func TestHandler_StopAgent(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("DELETE", "/api/v1/agents/instance-123", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_StopAgent_NotFound(t *testing.T) {
mockLM := &MockLifecycleManager{
StopAgentFn: func(ctx context.Context, instanceID string, force bool) error {
return &mockError{msg: "instance not found"}
},
}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("DELETE", "/api/v1/agents/non-existent", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
type mockError struct {
msg string
}
func (e *mockError) Error() string {
return e.msg
}
func TestHandler_GetAgentStatus(t *testing.T) {
now := time.Now()
mockLM := &MockLifecycleManager{
GetInstanceFn: func(instanceID string) (*lifecycle.AgentInstance, bool) {
if instanceID == "instance-123" {
return &lifecycle.AgentInstance{
ID: "instance-123",
TaskID: "task-456",
AgentType: "augment-agent",
ContainerID: "container-789",
Status: v1.AgentStatusRunning,
StartedAt: now,
Progress: 50,
}, true
}
return nil, false
},
}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents/instance-123/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp AgentInstanceResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ID != "instance-123" {
t.Errorf("expected ID 'instance-123', got %q", resp.ID)
}
if resp.Status != "RUNNING" {
t.Errorf("expected status 'RUNNING', got %q", resp.Status)
}
if resp.Progress != 50 {
t.Errorf("expected progress 50, got %d", resp.Progress)
}
}
func TestHandler_GetAgentStatus_NotFound(t *testing.T) {
mockLM := &MockLifecycleManager{
GetInstanceFn: func(instanceID string) (*lifecycle.AgentInstance, bool) {
return nil, false
},
}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents/non-existent/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestHandler_ListAgents(t *testing.T) {
now := time.Now()
mockLM := &MockLifecycleManager{
ListInstancesFn: func() []*lifecycle.AgentInstance {
return []*lifecycle.AgentInstance{
{
ID: "instance-1",
TaskID: "task-1",
AgentType: "augment-agent",
ContainerID: "container-1",
Status: v1.AgentStatusRunning,
StartedAt: now,
},
{
ID: "instance-2",
TaskID: "task-2",
AgentType: "augment-agent",
ContainerID: "container-2",
Status: v1.AgentStatusCompleted,
StartedAt: now,
},
}
},
}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp AgentsListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total != 2 {
t.Errorf("expected total 2, got %d", resp.Total)
}
if len(resp.Agents) != 2 {
t.Errorf("expected 2 agents, got %d", len(resp.Agents))
}
}
func TestHandler_ListAgents_Empty(t *testing.T) {
mockLM := &MockLifecycleManager{
ListInstancesFn: func() []*lifecycle.AgentInstance {
return []*lifecycle.AgentInstance{}
},
}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
var resp AgentsListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Total != 0 {
t.Errorf("expected total 0, got %d", resp.Total)
}
}
func TestHandler_ListAgentTypes(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents/types", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp AgentTypesListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// Should have at least the default agents
if resp.Total == 0 {
t.Error("expected at least one agent type")
}
}
func TestHandler_GetAgentType(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents/types/augment-agent", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp AgentTypeResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ID != "augment-agent" {
t.Errorf("expected ID 'augment-agent', got %q", resp.ID)
}
}
func TestHandler_GetAgentType_NotFound(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/api/v1/agents/types/non-existent", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestHandler_HealthCheck(t *testing.T) {
mockLM := &MockLifecycleManager{}
reg := newTestRegistry()
mockDocker := &MockDockerClient{}
router := setupTestRouter(mockLM, reg, mockDocker)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp HealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Status != "healthy" {
t.Errorf("expected status 'healthy', got %q", resp.Status)
}
}
func TestInstanceToResponse(t *testing.T) {
now := time.Now()
exitCode := 0
finishedAt := now.Add(time.Hour)
instance := &lifecycle.AgentInstance{
ID: "test-id",
TaskID: "task-id",
AgentType: "test-agent",
ContainerID: "container-id",
Status: v1.AgentStatusCompleted,
StartedAt: now,
FinishedAt: &finishedAt,
ExitCode: &exitCode,
ErrorMessage: "",
Progress: 100,
Metadata: map[string]interface{}{"key": "value"},
}
resp := instanceToResponse(instance)
if resp.ID != instance.ID {
t.Errorf("expected ID %q, got %q", instance.ID, resp.ID)
}
if resp.TaskID != instance.TaskID {
t.Errorf("expected TaskID %q, got %q", instance.TaskID, resp.TaskID)
}
if resp.Status != string(instance.Status) {
t.Errorf("expected Status %q, got %q", instance.Status, resp.Status)
}
if resp.Progress != instance.Progress {
t.Errorf("expected Progress %d, got %d", instance.Progress, resp.Progress)
}
if resp.ExitCode == nil || *resp.ExitCode != exitCode {
t.Errorf("expected ExitCode %d, got %v", exitCode, resp.ExitCode)
}
if resp.Metadata["key"] != "value" {
t.Error("expected metadata to be preserved")
}
}