agent-orchestrator/backend/internal/httpd/controllers/sessions_test.go

175 lines
6.0 KiB
Go

package controllers_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
)
type fakeSessionService struct {
sessions map[domain.SessionID]domain.Session
sent string
}
func newFakeSessionService() *fakeSessionService {
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}}
}
func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) {
var out []domain.Session
for _, s := range f.sessions {
if filter.ProjectID != "" && s.ProjectID != filter.ProjectID {
continue
}
if filter.Active != nil && s.IsTerminated == *filter.Active {
continue
}
if filter.OrchestratorOnly && s.Kind != domain.KindOrchestrator {
continue
}
out = append(out, s)
}
return out, nil
}
func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
f.sessions[s.ID] = s
return s, nil
}
func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) {
return f.sessions[id], nil
}
func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) {
s := f.sessions[id]
s.IsTerminated = false
s.Status = domain.StatusIdle
f.sessions[id] = s
return s, nil
}
func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) {
s := f.sessions[id]
s.IsTerminated = true
s.Status = domain.StatusTerminated
f.sessions[id] = s
return true, nil
}
func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error {
f.sent = message
return nil
}
func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithAPI(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc}))
t.Cleanup(srv.Close)
return srv
}
func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouter(config.Config{}, log, nil))
t.Cleanup(srv.Close)
body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions", "")
assertJSON(t, headers)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?project=ao", "")
if status != http.StatusOK {
t.Fatalf("GET sessions = %d, want 200; body=%s", status, body)
}
var list struct {
Sessions []sessionBody `json:"sessions"`
}
mustJSON(t, body, &list)
if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) {
t.Fatalf("list = %#v", list)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`)
if status != http.StatusCreated {
t.Fatalf("POST session = %d, want 201; body=%s", status, body)
}
var spawned struct {
Session sessionBody `json:"session"`
}
mustJSON(t, body, &spawned)
if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" {
t.Fatalf("spawned = %#v", spawned)
}
body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "")
if status != http.StatusOK {
t.Fatalf("GET session = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/send", "{\"message\":\"con\\u0000tinue\"}")
if status != http.StatusOK || svc.sent != "continue" {
t.Fatalf("send status=%d sent=%q body=%s", status, svc.sent, body)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/kill", "")
if status != http.StatusOK {
t.Fatalf("kill = %d, want 200; body=%s", status, body)
}
var killed struct {
SessionID string `json:"sessionId"`
Freed bool `json:"freed"`
}
mustJSON(t, body, &killed)
if killed.SessionID != "ao-2" || !killed.Freed {
t.Fatalf("kill response = %#v", killed)
}
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/restore", "")
if status != http.StatusOK {
t.Fatalf("restore = %d, want 200; body=%s", status, body)
}
body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`)
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`)
if status != http.StatusCreated {
t.Fatalf("orchestrator = %d, want 201; body=%s", status, body)
}
}
func TestSessionsAPI_SendValidation(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/send", `{"message":""}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED")
}
type sessionBody struct {
ID string `json:"id"`
IssueID string `json:"issueId"`
Harness string `json:"harness"`
Status string `json:"status"`
}