on main help sign in
feat(api): expose in progress flag on session responses

REST responses for sessions now include a boolean indicating whether an agent run is currently active for that session. Clients and UI components can use this to surface an in progress hint in session pickers and similar views without having to subscribe to agent events. The flag is computed at read time and is safe to ignore for older consumers. Co-Authored-By: Charm Crush <crush@charm.land>

commite764240d9ceb74a626cccdd404afaaa76e128314 authorChristian Rocha <christian@rocha.is> dateTue 19 May 2026, 23:30 (5 months ago) parent8f9a697
6 files changed+237−3
internal/backend/testing.go+20 internal/proto/session.go+7 internal/server/events.go+13 internal/server/proto.go+14−3 internal/server/sessions_isbusy_test.go+176 internal/workspace/client_workspace.go+7
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 @@
11package proto
22
33// 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.
410type Session struct {
511 ID string `json:"id"`
612 ParentSessionID string `json:"parent_session_id"`
@@ -13,6 +19,7 @@ type Session struct {
1319 Todos []Todo `json:"todos,omitempty"`
1420 CreatedAt int64 `json:"created_at"`
1521 UpdatedAt int64 `json:"updated_at"`
22+ IsBusy bool `json:"is_busy"`
1623}
1724
1825// Todo represents a single todo entry on a session in the proto layer.
internal/server/events.go+13
@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/crush/internal/agent/notify"
99 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1010 "github.com/charmbracelet/crush/internal/app"
11+ "github.com/charmbracelet/crush/internal/backend"
1112 "github.com/charmbracelet/crush/internal/history"
1213 "github.com/charmbracelet/crush/internal/message"
1314 "github.com/charmbracelet/crush/internal/permission"
@@ -143,6 +144,18 @@ func sessionToProto(s session.Session) proto.Session {
143144 }
144145}
145146
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+
146159func todosToProto(todos []session.Todo) []proto.Todo {
147160 if len(todos) == 0 {
148161 return nil
internal/server/proto.go+14−3
@@ -343,9 +343,11 @@ func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http
343343 c.handleError(w, r, err)
344344 return
345345 }
346+ ws, _ := c.backend.GetWorkspace(id)
346347 result := make([]proto.Session, len(sessions))
347348 for i, s := range sessions {
348349 result[i] = sessionToProto(s)
350+ result[i].IsBusy = isSessionBusy(ws, s.ID)
349351 }
350352 jsonEncode(w, result)
351353}
@@ -378,7 +380,10 @@ func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *htt
378380 c.handleError(w, r, err)
379381 return
380382 }
381− jsonEncode(w, sessionToProto(sess))
383+ ws, _ := c.backend.GetWorkspace(id)
384+ out := sessionToProto(sess)
385+ out.IsBusy = isSessionBusy(ws, sess.ID)
386+ jsonEncode(w, out)
382387}
383388
384389// handleGetWorkspaceSession returns a single session.
@@ -400,7 +405,10 @@ func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.
400405 c.handleError(w, r, err)
401406 return
402407 }
403− jsonEncode(w, sessionToProto(sess))
408+ ws, _ := c.backend.GetWorkspace(id)
409+ out := sessionToProto(sess)
410+ out.IsBusy = isSessionBusy(ws, sess.ID)
411+ jsonEncode(w, out)
404412}
405413
406414// handleGetWorkspaceSessionHistory returns the history for a session.
@@ -476,7 +484,10 @@ func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.
476484 c.handleError(w, r, err)
477485 return
478486 }
479− jsonEncode(w, sessionToProto(saved))
487+ ws, _ := c.backend.GetWorkspace(id)
488+ out := sessionToProto(saved)
489+ out.IsBusy = isSessionBusy(ws, saved.ID)
490+ jsonEncode(w, out)
480491}
481492
482493// 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 {
676676 }
677677}
678678
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.
679686func protoToSession(s proto.Session) session.Session {
680687 return session.Session{
681688 ID: s.ID,