on main help sign in
feat(api): expose in progress flag on session responses
e764240Christian Rocha
internal/backend/testing.gonew file+20
@@ -0,0 +1,20 @@
1+package backend
2+
3+// InsertWorkspaceForTest registers ws with b under its current ID and
4+// path. It is intended for tests in other packages that need to drive
5+// HTTP handlers against a synthetic workspace without booting a real
6+// app.App. Production code should go through CreateWorkspace.
7+func InsertWorkspaceForTest(b *Backend, ws *Workspace) {
8+ if ws.resolvedPath == "" {
9+ ws.resolvedPath = ws.Path
10+ }
11+ if ws.clients == nil {
12+ ws.clients = make(map[string]*clientState)
13+ }
14+ b.mu.Lock()
15+ defer b.mu.Unlock()
16+ b.workspaces.Set(ws.ID, ws)
17+ if ws.resolvedPath != "" {
18+ b.pathIndex[ws.resolvedPath] = ws.ID
19+ }
20+}
internal/proto/session.go+7
@@ -1,6 +1,12 @@
1package proto
1package proto
2
2
3// Session represents a session in the proto layer.
3// Session represents a session in the proto layer.
4+//
5+// IsBusy is computed on read (it is not persisted with the session) and
6+// reflects whether an agent run is currently in flight for this session.
7+// It is populated by REST handlers in internal/server/proto.go from the
8+// workspace's AgentCoordinator. The Session SSE event path does not set
9+// it, since SSE consumers can compute presence from other agent signals.
4type Session struct {
10type Session struct {
5 ID string `json:"id"`
11 ID string `json:"id"`
6 ParentSessionID string `json:"parent_session_id"`
12 ParentSessionID string `json:"parent_session_id"`
@@ -13,6 +19,7 @@ type Session struct {
13 Todos []Todo `json:"todos,omitempty"`
19 Todos []Todo `json:"todos,omitempty"`
14 CreatedAt int64 `json:"created_at"`
20 CreatedAt int64 `json:"created_at"`
15 UpdatedAt int64 `json:"updated_at"`
21 UpdatedAt int64 `json:"updated_at"`
22+ IsBusy bool `json:"is_busy"`
16}
23}
17
24
18// Todo represents a single todo entry on a session in the proto layer.
25// Todo represents a single todo entry on a session in the proto layer.
internal/server/events.go+13
@@ -8,6 +8,7 @@ import (
8 "github.com/charmbracelet/crush/internal/agent/notify"
8 "github.com/charmbracelet/crush/internal/agent/notify"
9 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
11+ "github.com/charmbracelet/crush/internal/backend"
11 "github.com/charmbracelet/crush/internal/history"
12 "github.com/charmbracelet/crush/internal/history"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/permission"
14 "github.com/charmbracelet/crush/internal/permission"
@@ -143,6 +144,18 @@ func sessionToProto(s session.Session) proto.Session {
143 }
144 }
144}
145}
145
146
147+// isSessionBusy reports whether the given workspace has an in-flight
148+// agent run for sessionID. It tolerates a nil workspace (treating it as
149+// "not busy") so REST handlers can pass GetWorkspace's result through
150+// unconditionally — the workspace lookup error is already surfaced by
151+// the prior ListSessions/GetSession call when relevant.
152+func isSessionBusy(ws *backend.Workspace, sessionID string) bool {
153+ if ws == nil || ws.App == nil || ws.AgentCoordinator == nil {
154+ return false
155+ }
156+ return ws.AgentCoordinator.IsSessionBusy(sessionID)
157+}
158+
146func todosToProto(todos []session.Todo) []proto.Todo {
159func todosToProto(todos []session.Todo) []proto.Todo {
147 if len(todos) == 0 {
160 if len(todos) == 0 {
148 return nil
161 return nil
internal/server/proto.go+14−3
@@ -343,9 +343,11 @@ func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http
343 c.handleError(w, r, err)
343 c.handleError(w, r, err)
344 return
344 return
345 }
345 }
346+ ws, _ := c.backend.GetWorkspace(id)
346 result := make([]proto.Session, len(sessions))
347 result := make([]proto.Session, len(sessions))
347 for i, s := range sessions {
348 for i, s := range sessions {
348 result[i] = sessionToProto(s)
349 result[i] = sessionToProto(s)
350+ result[i].IsBusy = isSessionBusy(ws, s.ID)
349 }
351 }
350 jsonEncode(w, result)
352 jsonEncode(w, result)
351}
353}
@@ -378,7 +380,10 @@ func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *htt
378 c.handleError(w, r, err)
380 c.handleError(w, r, err)
379 return
381 return
380 }
382 }
383+ ws, _ := c.backend.GetWorkspace(id)
384+ out := sessionToProto(sess)
385+ out.IsBusy = isSessionBusy(ws, sess.ID)
381− jsonEncode(w, sessionToProto(sess))
386+ jsonEncode(w, out)
382}
387}
383
388
384// handleGetWorkspaceSession returns a single session.
389// handleGetWorkspaceSession returns a single session.
@@ -400,7 +405,10 @@ func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.
400 c.handleError(w, r, err)
405 c.handleError(w, r, err)
401 return
406 return
402 }
407 }
408+ ws, _ := c.backend.GetWorkspace(id)
409+ out := sessionToProto(sess)
410+ out.IsBusy = isSessionBusy(ws, sess.ID)
403− jsonEncode(w, sessionToProto(sess))
411+ jsonEncode(w, out)
404}
412}
405
413
406// handleGetWorkspaceSessionHistory returns the history for a session.
414// handleGetWorkspaceSessionHistory returns the history for a session.
@@ -476,7 +484,10 @@ func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.
476 c.handleError(w, r, err)
484 c.handleError(w, r, err)
477 return
485 return
478 }
486 }
487+ ws, _ := c.backend.GetWorkspace(id)
488+ out := sessionToProto(saved)
489+ out.IsBusy = isSessionBusy(ws, saved.ID)
479− jsonEncode(w, sessionToProto(saved))
490+ jsonEncode(w, out)
480}
491}
481
492
482// handleDeleteWorkspaceSession deletes a session.
493// handleDeleteWorkspaceSession deletes a session.
internal/server/sessions_isbusy_test.gonew file+176
@@ -0,0 +1,176 @@
1+package server
2+
3+import (
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.
26+type stubCoordinator struct {
27+ busy map[string]bool
28+}
29+
30+func (s *stubCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
31+ return nil, nil
32+}
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 {
37+ return s.busy[id]
38+}
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 {
43+ return nil
44+}
45+func (s *stubCoordinator) Model() agent.Model { return agent.Model{} }
46+func (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.
51+type stubSessions struct {
52+ session.Service // embed nil to inherit the unexported broker methods
53+ all []session.Session
54+}
55+
56+func (s *stubSessions) List(context.Context) ([]session.Session, error) {
57+ return s.all, nil
58+}
59+
60+func (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.
72+func 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+
92+func 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+
110+func 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+
127+func 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.
148+func 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.
159+func 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+}
internal/workspace/client_workspace.go+7
@@ -676,6 +676,13 @@ func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
676 }
676 }
677}
677}
678
678
679+// protoToSession converts a wire-level proto.Session into the domain
680+// session.Session. Fields that exist only on the wire (computed-on-read
681+// signals like IsBusy, and any future presence counters) are
682+// intentionally dropped here: session.Session models persisted state,
683+// not transient runtime signals. UI features that need those signals
684+// should either extend session.Session or read them from the proto
685+// payload directly before this conversion runs.
679func protoToSession(s proto.Session) session.Session {
686func protoToSession(s proto.Session) session.Session {
680 return session.Session{
687 return session.Session{
681 ID: s.ID,
688 ID: s.ID,