diff --git a/internal/web/web.go b/internal/web/web.go index 9529d56..3087bdc 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -46,6 +46,7 @@ func (s *Server) Router() *gin.Engine { }) r.GET("/events", s.handleEvents) r.POST("/planning", s.handlePlanning) + r.POST("/coach", s.handleCoach) r.POST("/commitment", s.handleCommitment) r.POST("/complete", s.handleComplete) r.POST("/end", s.handleEnd) @@ -82,6 +83,19 @@ func (s *Server) handlePlanning(c *gin.Context) { s.respond(c, s.ctrl.EnterPlanning()) } +type coachRequest struct { + Intent string `json:"intent"` +} + +func (s *Server) handleCoach(c *gin.Context) { + var req coachRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + s.respond(c, s.ctrl.RequestCoach(req.Intent)) +} + type commitmentRequest struct { NextAction string `json:"next_action"` SuccessCondition string `json:"success_condition"` diff --git a/internal/web/web_test.go b/internal/web/web_test.go index dc4dd66..b504b5a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,12 +1,15 @@ package web import ( + "context" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" + "time" + "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/session" @@ -98,3 +101,60 @@ func TestLockedStateHasNullEvidence(t *testing.T) { t.Fatalf("locked payload should have null evidence: %s", js) } } + +type stubCoach struct { + prop ai.Proposal + err error +} + +func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { + return s.prop, s.err +} + +func TestCoachRouteHappyPath(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) + r := s.Router() + _ = post(t, r, "/planning", "") + if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { + t.Fatalf("/coach code %d body %s", w.Code, w.Body.String()) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach) +} + +func TestCoachRouteOutsidePlanning(t *testing.T) { + s := newTestServer(t) + s.ctrl.SetCoach(stubCoach{}) + r := s.Router() + if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCoachRouteInvalidJSON(t *testing.T) { + s := newTestServer(t) + r := s.Router() + _ = post(t, r, "/planning", "") + if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCoachRouteUnavailableDegrades(t *testing.T) { + s := newTestServer(t) // no SetCoach + r := s.Router() + _ = post(t, r, "/planning", "") + if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK { + t.Fatalf("unavailable coach should still 200, got %d", w.Code) + } + if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" { + t.Fatalf("want error status, got %+v", cv) + } +}