From fa178a1fd2c92c79542aa57421bd0305681ea145 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Fri, 5 Jun 2026 21:46:45 -0400 Subject: [PATCH] feat(web): ambient line in state envelope + /ambient/snooze Co-Authored-By: Claude Opus 4.8 --- internal/web/web.go | 34 +++++++++++++++++++++++++++++++++- internal/web/web_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/internal/web/web.go b/internal/web/web.go index 71e4b2a..8b76b0b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -14,6 +14,7 @@ import ( "time" "keel/internal/harness" + "keel/internal/mode" "keel/internal/mode/focus/statemachine" "keel/internal/settings" @@ -28,6 +29,9 @@ type Server struct { h *harness.Harness bcast *Broadcaster + ambientLine func() string + ambientSnooze func(time.Duration) + mu sync.Mutex timer *time.Timer @@ -77,14 +81,42 @@ func (s *Server) Router() *gin.Engine { r.GET("/settings", s.handleGetSettings) r.POST("/settings", s.handlePostSettings) r.GET("/fs/browse", s.handleBrowse) + r.POST("/ambient/snooze", s.handleAmbientSnooze) return r } func (s *Server) stateJSON() string { - data, _ := json.Marshal(s.h.State()) + line := "" + if s.ambientLine != nil { + line = s.ambientLine() + } + payload := struct { + mode.Envelope + Ambient string `json:"ambient"` + }{Envelope: s.h.State(), Ambient: line} + data, _ := json.Marshal(payload) return string(data) } +// SetAmbient injects the ambient sentinel's line accessor and snooze action, +// wired by main. Called once at startup before serving. +func (s *Server) SetAmbient(line func() string, snooze func(time.Duration)) { + s.ambientLine = line + s.ambientSnooze = snooze +} + +// Broadcast pushes the current state envelope to SSE subscribers. Exposed so the +// ambient sentinel's change signal can refresh the UI alongside harness changes. +func (s *Server) Broadcast() { s.broadcast() } + +// handleAmbientSnooze mutes the ambient coach for an hour. +func (s *Server) handleAmbientSnooze(c *gin.Context) { + if s.ambientSnooze != nil { + s.ambientSnooze(time.Hour) + } + c.Status(http.StatusNoContent) +} + func (s *Server) broadcast() { s.bcast.Publish(s.stateJSON()) } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 8fe012b..a3e00e8 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -417,6 +417,34 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) { } } +func TestStateJSONIncludesAmbient(t *testing.T) { + h := harness.New(harness.Services{}, map[string]harness.Factory{}) + s := NewServer(h) + s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {}) + js := s.stateJSON() + if !strings.Contains(js, `"ambient":"deep in YouTube"`) { + t.Fatalf("state JSON missing ambient field: %s", js) + } +} + +func TestAmbientSnoozeRoute(t *testing.T) { + h := harness.New(harness.Services{}, map[string]harness.Factory{}) + s := NewServer(h) + var snoozed time.Duration + s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil) + s.Router().ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("snooze status = %d, want 204", w.Code) + } + if snoozed != time.Hour { + t.Fatalf("snooze duration = %v, want 1h", snoozed) + } +} + type stubJudge struct{ verdict ai.Verdict } func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {