M2: POST /coach route wiring the planning coach

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 14:19:12 -04:00
parent 463ac4f23c
commit 38a62e4c68
2 changed files with 74 additions and 0 deletions
+14
View File
@@ -46,6 +46,7 @@ func (s *Server) Router() *gin.Engine {
}) })
r.GET("/events", s.handleEvents) r.GET("/events", s.handleEvents)
r.POST("/planning", s.handlePlanning) r.POST("/planning", s.handlePlanning)
r.POST("/coach", s.handleCoach)
r.POST("/commitment", s.handleCommitment) r.POST("/commitment", s.handleCommitment)
r.POST("/complete", s.handleComplete) r.POST("/complete", s.handleComplete)
r.POST("/end", s.handleEnd) r.POST("/end", s.handleEnd)
@@ -82,6 +83,19 @@ func (s *Server) handlePlanning(c *gin.Context) {
s.respond(c, s.ctrl.EnterPlanning()) 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 { type commitmentRequest struct {
NextAction string `json:"next_action"` NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"` SuccessCondition string `json:"success_condition"`
+60
View File
@@ -1,12 +1,15 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/session" "antidrift/internal/session"
@@ -98,3 +101,60 @@ func TestLockedStateHasNullEvidence(t *testing.T) {
t.Fatalf("locked payload should have null evidence: %s", js) 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)
}
}