Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/features/api-server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 `<data-dir>/runs/<pid>.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 `<data-dir>/runs/<pid>.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**
Expand Down
1 change: 1 addition & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
45 changes: 45 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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()

Expand Down
28 changes: 28 additions & 0 deletions pkg/server/session_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading