Generalize the focus controller into a harness hosting swappable modes

Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
  EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
  under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
  ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
  with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
  ignored, stale antidriftd binary removed.

Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:00:49 -04:00
parent 258de2c14b
commit 7d69a1f320
57 changed files with 4069 additions and 627 deletions
+190 -88
View File
@@ -9,26 +9,46 @@ import (
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/session"
"antidrift/internal/settings"
"antidrift/internal/tasks"
"keel/internal/ai"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
"keel/internal/settings"
"keel/internal/tasks"
"github.com/gin-gonic/gin"
)
func newTestServer(t *testing.T) *Server {
// newTestServer builds a harness around a freshly-constructed focus mode and
// returns the server plus the mode so tests can inject per-role stubs (the old
// controller's Set* API lives on focus.Mode) and assert on the typed focus
// state. The mode is registered behind a factory that wires the harness change
// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on
// every focus change. The mode starts Locked; tests drive it through the
// command routes (e.g. POST /mode/command/planning), mirroring the original
// suite which POSTed /planning first.
func newTestServer(t *testing.T) (*Server, *focus.Mode) {
t.Helper()
gin.SetMode(gin.TestMode)
path := filepath.Join(t.TempDir(), "state.json")
ctrl, err := session.New(path)
m, err := focus.New(path)
if err != nil {
t.Fatalf("controller: %v", err)
t.Fatalf("focus.New: %v", err)
}
return NewServer(ctrl)
h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{
"focus": func(svc harness.Services) mode.Mode {
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m
},
})
s := NewServer(h)
if err := h.Start("focus"); err != nil {
t.Fatalf("harness.Start(focus): %v", err)
}
return s, m
}
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
@@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec
return w
}
// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for
// the real-factory parity test without reaching any model.
type fakeBackend struct{}
func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil }
func (fakeBackend) Name() string { return "fake" }
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
if m.State().RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active, got %s", m.State().RuntimeState)
}
}
func TestCommitmentRejectsInvalidInput(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
w := post(t, r, "/commitment", body)
w := post(t, r, "/mode/command/commitment", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestIllegalTransitionReturns409(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
// /complete from Locked is illegal.
w := post(t, r, "/complete", "")
w := post(t, r, "/mode/command/complete", "")
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with
// the focus state nested under "mode" and the kind under "active_mode".
func TestEnvelopeShape(t *testing.T) {
s, _ := newTestServer(t)
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("payload missing active_mode: %s", js)
}
if !strings.Contains(js, `"mode"`) {
t.Fatalf("payload missing nested mode object: %s", js)
}
}
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// A focus update should appear in the serialized state.
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
// A focus update should appear in the serialized state envelope.
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
js := s.stateJSON()
if !strings.Contains(js, `"evidence"`) {
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
}
func TestLockedStateHasNullEvidence(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
// End any active session to return to Locked; a freshly-started focus mode is
// already Locked (Start does not auto-plan in this harness wiring).
if m.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState)
}
js := s.stateJSON()
if !strings.Contains(js, `"evidence":null`) {
t.Fatalf("locked payload should have null evidence: %s", js)
@@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop
}
func TestCoachRouteHappyPath(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
s, m := newTestServer(t)
m.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 {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/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" {
if cv := m.State().Coach; cv != nil && cv.Status == "ready" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
t.Fatalf("coach never reached ready: %+v", m.State().Coach)
}
func TestCoachRouteOutsidePlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetCoach(stubCoach{})
s, m := newTestServer(t)
m.SetCoach(stubCoach{})
r := s.Router()
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCoachRouteInvalidJSON(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/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
s, m := newTestServer(t) // no SetCoach
r := s.Router()
_ = post(t, r, "/planning", "")
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
_ = post(t, r, "/mode/command/planning", "")
if w := post(t, r, "/mode/command/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" {
if cv := m.State().Coach; cv == nil || cv.Status != "error" {
t.Fatalf("want error status, got %+v", cv)
}
}
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest {
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
}
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest {
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
}
}
@@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [
}
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
s, m := newTestServer(t)
m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
// debounce to wait on).
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
if d := m.State().Drift; d != nil && d.Nudge != "" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
}
func TestServesStaticAssets(t *testing.T) {
s := newTestServer(t)
s, _ := newTestServer(t)
r := s.Router()
cases := []struct {
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
return s.list, nil
}
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
s, m := newTestServer(t)
m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
if tv := m.State().Tasks; tv != nil && tv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
}
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
}
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("commitment did not carry allowed classes: %#v", got)
}
// Now Active: overrides succeed.
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK {
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
}
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK {
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
}
}
@@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e
}
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" {
if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.
}
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
s, m := newTestServer(t)
m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
r := s.Router()
_ = post(t, r, "/planning", "")
_ = post(t, r, "/mode/command/planning", "")
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
if rv := m.State().Reflection; rv != nil && rv.Status == "ready" {
break
}
time.Sleep(5 * time.Millisecond)
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
t.Fatalf("review payload missing recap: %s", js)
}
// End -> Locked -> Planning: the carry-forward should surface on planning.
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
// End -> Locked: the harness releases the focus mode and goes idle. Re-start
// the mode (re-adopting the same in-memory focus, carry-forward intact) and
// re-enter Planning; the carry-forward should surface there.
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
js2 := s.stateJSON()
@@ -372,19 +424,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri
}
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
s := newTestServer(t)
s, m := newTestServer(t)
r := s.Router()
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
@@ -397,15 +449,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
}
func TestKnowledgePathSelectionReloads(t *testing.T) {
s := newTestServer(t)
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s, m := newTestServer(t)
m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
settings.Settings{}, func(ss settings.Settings) error {
s.ctrl.SetKnowledgePath(ss.KnowledgePath)
m.SetKnowledgePath(ss.KnowledgePath)
return nil
})
r := s.Router()
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
t.Fatalf("/planning code %d", w.Code)
}
if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK {
@@ -413,10 +465,60 @@ func TestKnowledgePathSelectionReloads(t *testing.T) {
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge)
t.Fatalf("path selection did not reload: %+v", m.State().Knowledge)
}
// TestRealFocusFactoryParity drives the REAL focus.Factory through the harness
// (Start -> commitment -> complete -> end), proving the generalized routes work
// end-to-end against a focus mode the harness itself constructs from Services —
// not a pre-built mode injected by the test. focus.Factory auto-enters Planning
// on Start, so no explicit planning command is needed.
func TestRealFocusFactoryParity(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := harness.Services{
AI: ai.NewService(fakeBackend{}),
Clock: time.Now,
Dir: t.TempDir(),
}
h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory})
s := NewServer(h)
r := s.Router()
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
}
// Start auto-plans; the envelope should report focus active and Planning.
js := s.stateJSON()
if !strings.Contains(js, `"active_mode":"focus"`) {
t.Fatalf("envelope missing active_mode focus: %s", js)
}
if !strings.Contains(js, `"runtime_state":"planning"`) {
t.Fatalf("expected planning after start: %s", js)
}
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) {
t.Fatalf("expected active after commitment: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
t.Fatalf("/complete code %d", w.Code)
}
if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) {
t.Fatalf("expected review after complete: %s", s.stateJSON())
}
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
t.Fatalf("/end code %d", w.Code)
}
// End drives focus to Locked; the harness releases it and goes idle.
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after end ActiveMode = %q, want idle", got)
}
}