diff --git a/internal/ai/session.go b/internal/ai/session.go index a5596ae..8c7daa9 100644 --- a/internal/ai/session.go +++ b/internal/ai/session.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "sync" "time" @@ -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{} + 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 { diff --git a/internal/ai/session_list_test.go b/internal/ai/session_list_test.go new file mode 100644 index 0000000..e6ee2e4 --- /dev/null +++ b/internal/ai/session_list_test.go @@ -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)) + } +} diff --git a/internal/api/ai_session_handlers.go b/internal/api/ai_session_handlers.go index 1300162..7923b54 100644 --- a/internal/api/ai_session_handlers.go +++ b/internal/api/ai_session_handlers.go @@ -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 diff --git a/internal/api/ai_session_test.go b/internal/api/ai_session_test.go index 084ece7..c831b4f 100644 --- a/internal/api/ai_session_test.go +++ b/internal/api/ai_session_test.go @@ -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" ) @@ -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)) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index ca8681e..c7c0fd0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)