From 5ddb4409b923ea3e0c69ad870b273cf8d1912b11 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 11 Sep 2026 19:08:41 +0200 Subject: [PATCH] feat: add GET /api/sessions?active=true for lightweight attached-session listing Returns only runtimes currently attached to this server with working_dir and streaming status, no session-history read. Assisted-By: Claude --- docs/features/api-server/index.md | 4 +-- pkg/api/types.go | 1 + pkg/server/server.go | 4 +++ pkg/server/server_test.go | 45 +++++++++++++++++++++++++++++++ pkg/server/session_manager.go | 28 +++++++++++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/docs/features/api-server/index.md b/docs/features/api-server/index.md index 294826c24b..0e980ab188 100644 --- a/docs/features/api-server/index.md +++ b/docs/features/api-server/index.md @@ -59,7 +59,7 @@ For an agent loaded from a remote HTTP(S) configuration source, endpoints that n | Method | Path | Description | | -------- | ----------------------------------- | ------------------------------------------------------- | -| `GET` | `/api/sessions` | List all sessions | +| `GET` | `/api/sessions` | List all sessions. Pass `?active=true` to return only runtimes attached to this server, with lightweight `working_dir` and `streaming` status and no session-history read. | | `POST` | `/api/sessions` | Create a new session. Accepts an optional `title` field — when set, it is stored and LLM title generation is skipped. | | `GET` | `/api/sessions/:id` | Get a session by ID (messages, tokens, permissions) | | `GET` | `/api/sessions/:id/status` | Lightweight runtime state (streaming, title, agent, tokens). Requires an attached runtime. | @@ -273,7 +273,7 @@ $ curl -X POST http://127.0.0.1:8080/api/sessions/$SID/followup \ > [!NOTE] > **Discovering a run** > -> Each run started with `--listen` writes a discovery record to `/runs/.json` containing its address and session id, so a supervising process can find a live run by session id, pid, or address. +> Each run started with `--listen` writes a discovery record to `/runs/.json` containing its address and initial session id, so a supervising process can find a live run by session id, pid, or address. TUI tabs opened later are separate sessions attached to the same control plane; use `GET /api/sessions?active=true` on that address to enumerate them and read their `streaming` state without loading session history. > [!WARNING] > **This control plane has a fixed 1 MiB request-body cap and no built-in authentication** diff --git a/pkg/api/types.go b/pkg/api/types.go index e0fbbfb5e5..21078ad421 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -139,6 +139,7 @@ type SessionsResponse struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` WorkingDir string `json:"working_dir,omitempty"` + Streaming bool `json:"streaming,omitempty"` } // SessionResponse represents a detailed session diff --git a/pkg/server/server.go b/pkg/server/server.go index 83de533a4d..37a08d69ef 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -257,6 +257,10 @@ func agentSourceHTTPError(operation string, err error) error { } func (s *Server) getSessions(c echo.Context) error { + if c.QueryParam("active") == "true" { + return c.JSON(http.StatusOK, s.sm.GetActiveSessions()) + } + sessions, err := s.sm.GetSessions(c.Request().Context()) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get sessions: %v", err)) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index a1c2096a8d..80be4ff22c 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -167,6 +168,50 @@ func TestServer_WithMaxRequestBytesZeroFallback(t *testing.T) { } } +func TestServer_ListActiveSessionsIsLightweightAndIncludesStreaming(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := session.NewInMemorySessionStore() + historical := session.New(session.WithWorkingDir("/historical")) + require.NoError(t, store.AddSession(ctx, historical)) + + idle := session.New(session.WithWorkingDir("/work")) + running := session.New(session.WithWorkingDir("/work")) + sm := NewSessionManager(ctx, config.Sources{}, store, 0, &config.RuntimeConfig{}) + sm.AttachRuntime(ctx, idle.ID, &fakeRuntime{}, idle) + runningGuard := sm.AttachRuntime(ctx, running.ID, &fakeRuntime{}, running) + runningGuard.Lock() + defer runningGuard.Unlock() + + srv := NewWithManager(sm, "") + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/sessions?active=true", http.NoBody) + rec := httptest.NewRecorder() + srv.e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var sessions []api.SessionsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &sessions)) + require.Len(t, sessions, 2, "stored sessions without an attached runtime are excluded") + assert.ElementsMatch(t, []api.SessionsResponse{ + {ID: idle.ID, CreatedAt: idle.CreatedAt.Format(time.RFC3339), WorkingDir: "/work"}, + {ID: running.ID, CreatedAt: running.CreatedAt.Format(time.RFC3339), WorkingDir: "/work", Streaming: true}, + }, sessions) +} + +func TestServer_ListActiveSessionsEmptyIsArray(t *testing.T) { + t.Parallel() + + sm := NewSessionManager(t.Context(), config.Sources{}, session.NewInMemorySessionStore(), 0, &config.RuntimeConfig{}) + srv := NewWithManager(sm, "") + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/sessions?active=true", http.NoBody) + rec := httptest.NewRecorder() + srv.e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `[]`, rec.Body.String()) +} + func TestServer_ListSessions(t *testing.T) { t.Parallel() diff --git a/pkg/server/session_manager.go b/pkg/server/session_manager.go index 49bd4cc66d..6f218a8037 100644 --- a/pkg/server/session_manager.go +++ b/pkg/server/session_manager.go @@ -481,6 +481,34 @@ func (sm *SessionManager) WaitSessionAttached(ctx context.Context, sessionID str } } +// GetActiveSessions returns lightweight status for runtimes attached to this +// server. Unlike GetSessions, it never reads historical sessions from disk. +func (sm *SessionManager) GetActiveSessions() []api.SessionsResponse { + sessions := []api.SessionsResponse{} + sm.runtimeSessions.Range(func(_ string, rs *activeRuntimes) bool { + if rs.session == nil { + return true + } + streaming := !rs.streaming.TryLock() + if !streaming { + rs.streaming.Unlock() + } + title := rs.session.TitleSnapshot() + inputTokens, outputTokens := rs.session.Usage() + sessions = append(sessions, api.SessionsResponse{ + ID: rs.session.ID, + Title: title, + CreatedAt: rs.session.CreatedAt.Format(time.RFC3339), + InputTokens: inputTokens, + OutputTokens: outputTokens, + WorkingDir: rs.session.WorkingDir, + Streaming: streaming, + }) + return true + }) + return sessions +} + // GetSessionStatus returns a lightweight snapshot of the session's current // runtime state. Designed for late-joining SSE consumers that need to know // the session's state without waiting for the next event transition.