From 9f7d7fbd4ac71306e3ad3164ee204ed16037deff Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 19 Jul 2026 16:02:19 +0100 Subject: [PATCH] feat(ai): List saved assistant sessions so past chats can be resumed Assistant conversations were already persisted per user, but there was no way to enumerate them, so the UI could not show past chats or reopen one. This adds a listing of the caller's saved sessions (all of them for an admin), most recent first, each with a short title taken from its first message. --- internal/ai/session.go | 82 +++++++++++++++++++++++++++++ internal/ai/session_list_test.go | 76 ++++++++++++++++++++++++++ internal/api/ai_session_handlers.go | 20 +++++++ internal/api/ai_session_test.go | 48 +++++++++++++++++ internal/api/server.go | 1 + 5 files changed, 227 insertions(+) create mode 100644 internal/ai/session_list_test.go 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)