@@ -174,3 +174,152 @@ func TestProtoSessionIsBusyBackwardCompat(t *testing.T) {
174174 require.Equal(t, "s1", old.ID)
175175 require.Equal(t, "t", old.Title)
176176}
177+
178+// buildMultiSessionWorkspace returns a controller wired to a backend
179+// that owns a workspace with the given session IDs. Used to exercise
180+// AttachedClients counts across more than one session.
181+func buildMultiSessionWorkspace(t *testing.T, sessionIDs ...string) (*controllerV1, *backend.Workspace) {
182+ t.Helper()
183+
184+ b := backend.New(context.Background(), nil, nil)
185+ a := &app.App{AgentCoordinator: &stubCoordinator{}}
186+ sessions := make([]session.Session, len(sessionIDs))
187+ for i, sid := range sessionIDs {
188+ sessions[i] = session.Session{ID: sid, Title: sid}
189+ }
190+ a.Sessions = &stubSessions{all: sessions}
191+
192+ ws := &backend.Workspace{
193+ ID: uuid.New().String(),
194+ Path: t.TempDir(),
195+ App: a,
196+ }
197+ backend.InsertWorkspaceForTest(b, ws)
198+ // Synthetic workspaces have an incomplete App; bypass the
199+ // default teardown to avoid panics when the last client detaches.
200+ backend.SetWorkspaceShutdownFnForTest(ws, func() {})
201+
202+ s := &Server{backend: b}
203+ return &controllerV1{backend: b, server: s}, ws
204+}
205+
206+// listSessions invokes handleGetWorkspaceSessions and returns the
207+// decoded response so tests can assert per-session counts.
208+func listSessions(t *testing.T, c *controllerV1, wsID string) []proto.Session {
209+ t.Helper()
210+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/"+wsID+"/sessions", nil)
211+ req.SetPathValue("id", wsID)
212+ rec := httptest.NewRecorder()
213+ c.handleGetWorkspaceSessions(rec, req)
214+ require.Equal(t, http.StatusOK, rec.Code)
215+ var got []proto.Session
216+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
217+ return got
218+}
219+
220+func countsBySessionID(sessions []proto.Session) map[string]int {
221+ out := make(map[string]int, len(sessions))
222+ for _, s := range sessions {
223+ out[s.ID] = s.AttachedClients
224+ }
225+ return out
226+}
227+
228+// TestSessionListIncludesAttachedClients walks two sessions through
229+// the same lifecycle covered by TestAttachedClients_BasicLifecycle in
230+// the backend package, but observed at the handler boundary.
231+func TestSessionListIncludesAttachedClients(t *testing.T) {
232+ t.Parallel()
233+ c, ws := buildMultiSessionWorkspace(t, "S1", "S2")
234+
235+ // No attached clients yet.
236+ counts := countsBySessionID(listSessions(t, c, ws.ID))
237+ require.Equal(t, 0, counts["S1"])
238+ require.Equal(t, 0, counts["S2"])
239+
240+ // Attach A, set to S1: S1=1.
241+ cidA := uuid.New().String()
242+ require.NoError(t, c.backend.AttachClient(ws.ID, cidA))
243+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cidA) })
244+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cidA, "S1"))
245+ counts = countsBySessionID(listSessions(t, c, ws.ID))
246+ require.Equal(t, 1, counts["S1"])
247+ require.Equal(t, 0, counts["S2"])
248+
249+ // Attach B, set to S1: S1=2.
250+ cidB := uuid.New().String()
251+ require.NoError(t, c.backend.AttachClient(ws.ID, cidB))
252+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cidB, "S1"))
253+ counts = countsBySessionID(listSessions(t, c, ws.ID))
254+ require.Equal(t, 2, counts["S1"])
255+ require.Equal(t, 0, counts["S2"])
256+
257+ // B switches to S2: counts redistribute.
258+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cidB, "S2"))
259+ counts = countsBySessionID(listSessions(t, c, ws.ID))
260+ require.Equal(t, 1, counts["S1"])
261+ require.Equal(t, 1, counts["S2"])
262+
263+ // B detaches: S2 drops to 0.
264+ c.backend.DetachClient(ws.ID, cidB)
265+ counts = countsBySessionID(listSessions(t, c, ws.ID))
266+ require.Equal(t, 1, counts["S1"])
267+ require.Equal(t, 0, counts["S2"])
268+}
269+
270+// TestSessionListExcludesHoldOnlyClient verifies that a registered
271+// client without an SSE stream (streams == 0) does not contribute to
272+// AttachedClients, even though it has an entry in the workspace's
273+// clients map.
274+func TestSessionListExcludesHoldOnlyClient(t *testing.T) {
275+ t.Parallel()
276+ c, ws := buildMultiSessionWorkspace(t, "S1")
277+
278+ cid := uuid.New().String()
279+ require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid))
280+ t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) })
281+
282+ counts := countsBySessionID(listSessions(t, c, ws.ID))
283+ require.Equal(t, 0, counts["S1"], "hold-only client must not be counted")
284+}
285+
286+// TestSessionListExcludesUnselectedAttachedClient verifies that a
287+// client with a live SSE stream but no current session
288+// (currentSessionID == "") does not show up under any session's count.
289+func TestSessionListExcludesUnselectedAttachedClient(t *testing.T) {
290+ t.Parallel()
291+ c, ws := buildMultiSessionWorkspace(t, "S1")
292+
293+ cid := uuid.New().String()
294+ require.NoError(t, c.backend.AttachClient(ws.ID, cid))
295+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
296+ // Intentionally do NOT call SetCurrentSession.
297+
298+ counts := countsBySessionID(listSessions(t, c, ws.ID))
299+ require.Equal(t, 0, counts["S1"],
300+ "attached client with no current session must not contribute to S1")
301+}
302+
303+// TestSessionGetIncludesAttachedClients verifies the single-session
304+// handler also populates AttachedClients.
305+func TestSessionGetIncludesAttachedClients(t *testing.T) {
306+ t.Parallel()
307+ c, ws := buildMultiSessionWorkspace(t, "S1")
308+
309+ cid := uuid.New().String()
310+ require.NoError(t, c.backend.AttachClient(ws.ID, cid))
311+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
312+ require.NoError(t, c.backend.SetCurrentSession(ws.ID, cid, "S1"))
313+
314+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
315+ "/v1/workspaces/"+ws.ID+"/sessions/S1", nil)
316+ req.SetPathValue("id", ws.ID)
317+ req.SetPathValue("sid", "S1")
318+ rec := httptest.NewRecorder()
319+ c.handleGetWorkspaceSession(rec, req)
320+ require.Equal(t, http.StatusOK, rec.Code)
321+
322+ var got proto.Session
323+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
324+ require.Equal(t, 1, got.AttachedClients)
325+}