1package server
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "charm.land/fantasy"
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"
20)
21
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.
26type stubCoordinator struct {
27 busy map[string]bool
28}
29
30func (s *stubCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
31 return nil, nil
32}
33func (s *stubCoordinator) Cancel(string) {}
34func (s *stubCoordinator) CancelAll() {}
35func (s *stubCoordinator) IsBusy() bool { return false }
36func (s *stubCoordinator) IsSessionBusy(id string) bool {
37 return s.busy[id]
38}
39func (s *stubCoordinator) QueuedPrompts(string) int { return 0 }
40func (s *stubCoordinator) QueuedPromptsList(string) []string { return nil }
41func (s *stubCoordinator) ClearQueue(string) {}
42func (s *stubCoordinator) Summarize(context.Context, string) error {
43 return nil
44}
45func (s *stubCoordinator) Model() agent.Model { return agent.Model{} }
46func (s *stubCoordinator) UpdateModels(context.Context) error { return nil }
47
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.
51type stubSessions struct {
52 session.Service // embed nil to inherit the unexported broker methods
53 all []session.Session
54}
55
56func (s *stubSessions) List(context.Context) ([]session.Session, error) {
57 return s.all, nil
58}
59
60func (s *stubSessions) Get(_ context.Context, id string) (session.Session, error) {
61 for _, sess := range s.all {
62 if sess.ID == id {
63 return sess, nil
64 }
65 }
66 return session.Session{}, errors.New("not found")
67}
68
69// buildBusyWorkspace returns a controller wired to a backend that owns
70// a single workspace whose AgentCoordinator reports the named session
71// as busy.
72func buildBusyWorkspace(t *testing.T, sessionID string, busy bool) (*controllerV1, string) {
73 t.Helper()
74
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"}}}
80
81 ws := &backend.Workspace{
82 ID: wsID,
83 Path: t.TempDir(),
84 App: a,
85 }
86 backend.InsertWorkspaceForTest(b, ws)
87
88 s := &Server{backend: b}
89 return &controllerV1{backend: b, server: s}, wsID
90}
91
92func TestSessionListIncludesIsBusy(t *testing.T) {
93 t.Parallel()
94 const sid = "s-busy"
95 c, wsID := buildBusyWorkspace(t, sid, true)
96
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)
102
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")
108}
109
110func TestSessionListIdleSessionIsNotBusy(t *testing.T) {
111 t.Parallel()
112 const sid = "s-idle"
113 c, wsID := buildBusyWorkspace(t, sid, false)
114
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)
120
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")
125}
126
127func TestSessionGetIncludesIsBusy(t *testing.T) {
128 t.Parallel()
129 const sid = "s-busy"
130 c, wsID := buildBusyWorkspace(t, sid, true)
131
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)
138
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)
143}
144
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.
148func TestIsSessionBusyNilSafe(t *testing.T) {
149 t.Parallel()
150
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"))
154}
155
156// TestProtoSessionIsBusyBackwardCompat verifies older consumers that
157// unmarshal proto.Session without knowing about IsBusy still succeed
158// and ignore the new field harmlessly.
159func TestProtoSessionIsBusyBackwardCompat(t *testing.T) {
160 t.Parallel()
161
162 wire := proto.Session{ID: "s1", Title: "t", IsBusy: true}
163 raw, err := json.Marshal(wire)
164 require.NoError(t, err)
165
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"`
171 }
172 var old oldSession
173 require.NoError(t, json.Unmarshal(raw, &old))
174 require.Equal(t, "s1", old.ID)
175 require.Equal(t, "t", old.Title)
176}