@@ -0,0 +1,176 @@
12+ "github.com/charmbracelet/crush/internal/agent"
13+ "github.com/charmbracelet/crush/internal/app"
14+ "github.com/charmbracelet/crush/internal/backend"
15+ "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/proto"
17+ "github.com/charmbracelet/crush/internal/session"
18+ "github.com/google/uuid"
19+ "github.com/stretchr/testify/require"
22+// stubCoordinator is a minimal agent.Coordinator that only reports
23+// per-session busy state. Every other method returns a zero value so
24+// the type satisfies the interface without dragging in the full
25+// coordinator dependency graph.
26+type stubCoordinator struct {
30+func (s *stubCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
33+func (s *stubCoordinator) Cancel(string) {}
34+func (s *stubCoordinator) CancelAll() {}
35+func (s *stubCoordinator) IsBusy() bool { return false }
36+func (s *stubCoordinator) IsSessionBusy(id string) bool {
39+func (s *stubCoordinator) QueuedPrompts(string) int { return 0 }
40+func (s *stubCoordinator) QueuedPromptsList(string) []string { return nil }
41+func (s *stubCoordinator) ClearQueue(string) {}
42+func (s *stubCoordinator) Summarize(context.Context, string) error {
45+func (s *stubCoordinator) Model() agent.Model { return agent.Model{} }
46+func (s *stubCoordinator) UpdateModels(context.Context) error { return nil }
48+// stubSessions is a minimal session.Service that returns a fixed list
49+// (and supports Get by ID). All other methods return zero values; the
50+// IsBusy tests do not exercise them.
51+type stubSessions struct {
52+ session.Service // embed nil to inherit the unexported broker methods
53+ all []session.Session
56+func (s *stubSessions) List(context.Context) ([]session.Session, error) {
60+func (s *stubSessions) Get(_ context.Context, id string) (session.Session, error) {
61+ for _, sess := range s.all {
66+ return session.Session{}, errors.New("not found")
69+// buildBusyWorkspace returns a controller wired to a backend that owns
70+// a single workspace whose AgentCoordinator reports the named session
72+func buildBusyWorkspace(t *testing.T, sessionID string, busy bool) (*controllerV1, string) {
75+ b := backend.New(context.Background(), nil, nil)
76+ wsID := uuid.New().String()
77+ coord := &stubCoordinator{busy: map[string]bool{sessionID: busy}}
78+ a := &app.App{AgentCoordinator: coord}
79+ a.Sessions = &stubSessions{all: []session.Session{{ID: sessionID, Title: "t"}}}
81+ ws := &backend.Workspace{
86+ backend.InsertWorkspaceForTest(b, ws)
88+ s := &Server{backend: b}
89+ return &controllerV1{backend: b, server: s}, wsID
92+func TestSessionListIncludesIsBusy(t *testing.T) {
95+ c, wsID := buildBusyWorkspace(t, sid, true)
97+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/"+wsID+"/sessions", nil)
98+ req.SetPathValue("id", wsID)
99+ rec := httptest.NewRecorder()
100+ c.handleGetWorkspaceSessions(rec, req)
101+ require.Equal(t, http.StatusOK, rec.Code)
103+ var got []proto.Session
104+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
105+ require.Len(t, got, 1)
106+ require.Equal(t, sid, got[0].ID)
107+ require.True(t, got[0].IsBusy, "expected IsBusy=true for the busy session")
110+func TestSessionListIdleSessionIsNotBusy(t *testing.T) {
112+ const sid = "s-idle"
113+ c, wsID := buildBusyWorkspace(t, sid, false)
115+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/"+wsID+"/sessions", nil)
116+ req.SetPathValue("id", wsID)
117+ rec := httptest.NewRecorder()
118+ c.handleGetWorkspaceSessions(rec, req)
119+ require.Equal(t, http.StatusOK, rec.Code)
121+ var got []proto.Session
122+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
123+ require.Len(t, got, 1)
124+ require.False(t, got[0].IsBusy, "expected IsBusy=false for idle session")
127+func TestSessionGetIncludesIsBusy(t *testing.T) {
129+ const sid = "s-busy"
130+ c, wsID := buildBusyWorkspace(t, sid, true)
132+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/"+wsID+"/sessions/"+sid, nil)
133+ req.SetPathValue("id", wsID)
134+ req.SetPathValue("sid", sid)
135+ rec := httptest.NewRecorder()
136+ c.handleGetWorkspaceSession(rec, req)
137+ require.Equal(t, http.StatusOK, rec.Code)
139+ var got proto.Session
140+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
141+ require.Equal(t, sid, got.ID)
142+ require.True(t, got.IsBusy)
145+// TestIsSessionBusyNilSafe verifies the helper tolerates a missing
146+// workspace, app, or coordinator — phase A handlers rely on this so
147+// they can pass GetWorkspace's result through without an extra guard.
148+func TestIsSessionBusyNilSafe(t *testing.T) {
151+ require.False(t, isSessionBusy(nil, "x"))
152+ require.False(t, isSessionBusy(&backend.Workspace{}, "x"))
153+ require.False(t, isSessionBusy(&backend.Workspace{App: &app.App{}}, "x"))
156+// TestProtoSessionIsBusyBackwardCompat verifies older consumers that
157+// unmarshal proto.Session without knowing about IsBusy still succeed
158+// and ignore the new field harmlessly.
159+func TestProtoSessionIsBusyBackwardCompat(t *testing.T) {
162+ wire := proto.Session{ID: "s1", Title: "t", IsBusy: true}
163+ raw, err := json.Marshal(wire)
164+ require.NoError(t, err)
166+ // Old client shape: same struct minus IsBusy. We model this by
167+ // unmarshaling into a struct that doesn't declare the field.
168+ type oldSession struct {
169+ ID string `json:"id"`
170+ Title string `json:"title"`
173+ require.NoError(t, json.Unmarshal(raw, &old))
174+ require.Equal(t, "s1", old.ID)
175+ require.Equal(t, "t", old.Title)