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
82 changes: 82 additions & 0 deletions internal/ai/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -181,6 +182,87 @@ func (st *SessionStore) PruneOlderThan(cutoff time.Time) int {
return removed
}

// SessionSummary is the lightweight view of a session for a list, without the
// full transcript.
type SessionSummary struct {
ID string `json:"id"`
Scope string `json:"scope"`
Deployment string `json:"deployment,omitempty"`
Status string `json:"status"`
Title string `json:"title"`
CreatedBy SessionActor `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// Title derives a short label from the first visible user turn, so a saved
// session is recognizable in a list. Falls back to the scope.
func (s *Session) Title() string {
for _, m := range s.Messages {
if m.Role != "user" || m.Hidden {
continue
}
text := m.Display
if text == "" {
text = m.Content
}
if text = strings.TrimSpace(text); text == "" {
continue
}
return truncateTitle(text)
}
if s.Deployment != "" {
return s.Deployment + " chat"
}
return "System chat"
}

func truncateTitle(s string) string {
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > 80 {
return string(r[:79]) + "…"
}
return s
}

// Summary is the session without its transcript, for a list view.
func (s *Session) Summary() SessionSummary {
return SessionSummary{
ID: s.ID,
Scope: s.Scope,
Deployment: s.Deployment,
Status: s.Status,
Title: s.Title(),
CreatedBy: s.CreatedBy,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}

// List returns a summary of every stored session, most recently updated first.
func (st *SessionStore) List() ([]SessionSummary, error) {
entries, err := os.ReadDir(st.dir)
if err != nil {
if os.IsNotExist(err) {
return []SessionSummary{}, nil
}
return nil, err
}
summaries := []SessionSummary{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While functional, this implementation has a few performance and efficiency considerations:

  1. Performance: st.Get unmarshals the full session JSON, including potentially large transcripts (hundreds of messages). For a listing view, consider decoding into a 'metadata-only' struct that ignores the messages array to save memory and CPU.
  2. Optimization: Pre-allocate the summaries slice capacity to avoid repeated re-allocations during the loop.
  3. Modern Go: If using Go 1.21+, slices.SortFunc is preferred over sort.Slice for type safety and performance.
Suggested change
summaries := []SessionSummary{}
summaries := make([]SessionSummary, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
// Note: st.Get loads the full session. Optimization to load metadata only is recommended for scale.
sess, err := st.Get(strings.TrimSuffix(e.Name(), ".json"))
if err != nil {
continue
}
summaries = append(summaries, sess.Summary())
}
sort.Slice(summaries, func(i, j int) bool { return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt) })

for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
sess, err := st.Get(strings.TrimSuffix(e.Name(), ".json"))
if err != nil {
continue
}
summaries = append(summaries, sess.Summary())
}
sort.Slice(summaries, func(i, j int) bool { return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt) })
return summaries, nil
}

// DisplayMessages projects the transcript into UI-facing turns,
// dropping the system prompt and pairing tool calls with their results.
type DisplayToolStep struct {
Expand Down
76 changes: 76 additions & 0 deletions internal/ai/session_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package ai

import (
"testing"
"time"
)

func TestSessionTitle(t *testing.T) {
// First visible user turn becomes the title.
s := NewSession(SessionScopeSystem, "", true, SessionActor{ID: "u1"}, "sys")
s.AddUserMessage("api.example.com:443 request with logs...", "why is wordpress unhealthy?", true) // hidden, skipped
s.AddUserMessage("summarize recent security events", "", false)
if got := s.Title(); got != "summarize recent security events" {
t.Errorf("title should come from the first visible user turn, got %q", got)
}

// No user turn yet: fall back to the deployment name.
empty := NewSession(SessionScopeDeployment, "shop", true, SessionActor{ID: "u1"}, "sys")
if got := empty.Title(); got != "shop chat" {
t.Errorf("deployment fallback title, got %q", got)
}

// System scope with no user turn.
sys := NewSession(SessionScopeSystem, "", true, SessionActor{ID: "u1"}, "sys")
if got := sys.Title(); got != "System chat" {
t.Errorf("system fallback title, got %q", got)
}
}

func TestSessionStoreList(t *testing.T) {
st := NewSessionStore(t.TempDir())

older := NewSession(SessionScopeSystem, "", true, SessionActor{ID: "u1", Name: "alice"}, "sys")
older.AddUserMessage("first question", "", false)
if err := st.Save(older); err != nil {
t.Fatal(err)
}

newer := NewSession(SessionScopeDeployment, "shop", true, SessionActor{ID: "u2", Name: "bob"}, "sys")
newer.UpdatedAt = time.Now().UTC().Add(time.Minute)
if err := st.Save(newer); err != nil {
t.Fatal(err)
}

list, err := st.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected 2 summaries, got %d", len(list))
}
if list[0].ID != newer.ID {
t.Errorf("expected the most recently updated session first")
}
if list[0].Title != "shop chat" {
t.Errorf("unexpected title %q", list[0].Title)
}
if list[1].Title != "first question" {
t.Errorf("unexpected title %q", list[1].Title)
}
// The owner must ride along so the handler can filter by actor.
if list[1].CreatedBy.ID != "u1" {
t.Errorf("summary should carry the creating actor, got %q", list[1].CreatedBy.ID)
}
}

func TestSessionStoreListEmpty(t *testing.T) {
st := NewSessionStore(t.TempDir())
list, err := st.List()
if err != nil {
t.Fatalf("empty store should not error: %v", err)
}
if len(list) != 0 {
t.Errorf("expected no sessions, got %d", len(list))
}
}
20 changes: 20 additions & 0 deletions internal/api/ai_session_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,26 @@ func (s *Server) getAISession(c *gin.Context) {
s.sessionResponse(c, sess)
}

// listAISessions returns the caller's saved sessions (all sessions for an admin),
// most recent first, so past conversations can be resumed.
func (s *Server) listAISessions(c *gin.Context) {
summaries, err := s.aiSessions.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
actor := auth.GetActorFromContext(c)
isAdmin := actor != nil && actor.Role == auth.RoleAdmin
mine := sessionActorFrom(c).ID
out := make([]ai.SessionSummary, 0, len(summaries))
for _, sum := range summaries {
if isAdmin || sum.CreatedBy.ID == mine {
out = append(out, sum)
}
}
c.JSON(http.StatusOK, gin.H{"sessions": out})
}

func (s *Server) postAISessionMessage(c *gin.Context) {
if !s.aiRequireEnabled(c) {
return
Expand Down
48 changes: 48 additions & 0 deletions internal/api/ai_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/flatrun/agent/internal/ai"
"github.com/flatrun/agent/internal/auth"
"github.com/flatrun/agent/internal/contextkeys"
"github.com/flatrun/agent/pkg/models"
"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -308,3 +311,48 @@ func TestAIToolUnknownToolReported(t *testing.T) {
t.Errorf("unknown tool not reported: %q", result)
}
}

func TestListAISessionsFiltersByActor(t *testing.T) {
s, _, _ := setupPlanTestServer(t)

own := ai.NewSession(ai.SessionScopeSystem, "", true, ai.SessionActor{ID: "1", Name: "alice"}, "sys")
own.AddUserMessage("mine", "", false)
other := ai.NewSession(ai.SessionScopeSystem, "", true, ai.SessionActor{ID: "2", Name: "bob"}, "sys")
other.AddUserMessage("theirs", "", false)
if err := s.aiSessions.Save(own); err != nil {
t.Fatal(err)
}
if err := s.aiSessions.Save(other); err != nil {
t.Fatal(err)
}

call := func(actor *auth.ActorContext) []map[string]any {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/ai/sessions", nil)
if actor != nil {
c.Set(contextkeys.Actor, actor)
}
s.listAISessions(c)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
var body struct {
Sessions []map[string]any `json:"sessions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Sessions
}

mine := call(&auth.ActorContext{User: &auth.User{ID: 1, Username: "alice"}, Role: auth.RoleViewer})
if len(mine) != 1 || mine[0]["id"] != own.ID {
t.Errorf("a non-admin should see only their own sessions, got %v", mine)
}

all := call(&auth.ActorContext{User: &auth.User{ID: 9, Username: "root"}, Role: auth.RoleAdmin})
if len(all) != 2 {
t.Errorf("an admin should see all sessions, got %d", len(all))
}
}
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ func (s *Server) setupRoutes() {

// Interactive AI sessions (agentic tool loop)
protected.POST("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.createAISession)
protected.GET("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.listAISessions)
protected.GET("/ai/sessions/:id", s.getAISession)
protected.POST("/ai/sessions/:id/messages", s.postAISessionMessage)
protected.POST("/ai/sessions/:id/approve", s.approveAISessionTools)
Expand Down
Loading