Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ scripts/download-stats.sh
# IDE files
.vscode/
.cursor/
.codex
204 changes: 204 additions & 0 deletions agent/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ type ReplayFrame struct {
Event model.Event
}

// Summary is a lightweight description of one persisted session for UI pickers.
type Summary struct {
SessionID string
CreatedAt time.Time
UpdatedAt time.Time
FirstUserInput string
HasDialogue bool
}

// Session owns trajectory persistence for one workspace conversation.
type Session struct {
mu sync.RWMutex
Expand Down Expand Up @@ -169,6 +178,88 @@ func LoadLatest(workDir string) (*Session, error) {
return LoadByID(absWorkDir, candidates[0].id)
}

// ListForWorkDir returns persisted session summaries for the given workdir.
func ListForWorkDir(workDir string) ([]Summary, error) {
absWorkDir, err := normalizeWorkDir(workDir)
if err != nil {
return nil, err
}

key := workDirKey(absWorkDir)
bucket := sessionBucketDir(key)
entries, err := os.ReadDir(bucket)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read session bucket: %w", err)
}

summaries := make([]Summary, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}

path := filepath.Join(bucket, entry.Name(), trajectoryFilename)
loaded, err := loadFromPath(path, false)
if err != nil {
continue
}

summary := loaded.summary()
if !summary.HasDialogue {
continue
}
summaries = append(summaries, summary)
}

sort.Slice(summaries, func(i, j int) bool {
if summaries[i].UpdatedAt.Equal(summaries[j].UpdatedAt) {
return summaries[i].SessionID > summaries[j].SessionID
}
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})

return summaries, nil
}

// CleanupExpired removes persisted sessions whose latest on-disk update is older than maxAge.
func CleanupExpired(maxAge time.Duration) (int, error) {
if maxAge <= 0 {
return 0, fmt.Errorf("max age must be positive")
}

root, err := sessionRootDir()
if err != nil {
return 0, err
}
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("read session root: %w", err)
}

cutoff := time.Now().Add(-maxAge)
removed := 0
for _, bucket := range entries {
if !bucket.IsDir() {
continue
}
bucketPath := filepath.Join(root, bucket.Name())
bucketRemoved, err := cleanupExpiredBucket(bucketPath, cutoff)
removed += bucketRemoved
if err != nil {
return removed, err
}
_ = os.Remove(bucketPath)
}

return removed, nil
}

// LoadByID loads a specific session for the given workdir.
func LoadByID(workDir, sessionID string) (*Session, error) {
absWorkDir, err := normalizeWorkDir(workDir)
Expand Down Expand Up @@ -693,6 +784,68 @@ func (s *Session) Meta() Meta {
return s.meta
}

func (s *Session) summary() Summary {
if s == nil {
return Summary{}
}

s.mu.RLock()
defer s.mu.RUnlock()

updatedAt := s.meta.UpdatedAt
if s.snapshot.UpdatedAt.After(updatedAt) {
updatedAt = s.snapshot.UpdatedAt
}
if info, err := os.Stat(s.path); err == nil && info.ModTime().After(updatedAt) {
updatedAt = info.ModTime()
}
if info, err := os.Stat(s.snapshotPath); err == nil && info.ModTime().After(updatedAt) {
updatedAt = info.ModTime()
}

firstUserInput := ""
hasDialogue := false
for _, record := range s.records {
switch record.Type {
case recordTypeUser:
hasDialogue = true
if firstUserInput == "" {
firstUserInput = sessionPreview(record.Content)
}
case recordTypeAssistant:
hasDialogue = true
}
}
if firstUserInput == "" {
firstUserInput = "(no user input recorded)"
}

return Summary{
SessionID: s.meta.SessionID,
CreatedAt: s.meta.CreatedAt,
UpdatedAt: updatedAt,
FirstUserInput: firstUserInput,
HasDialogue: hasDialogue,
}
}

func sessionPreview(content string) string {
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
for _, line := range lines {
line = strings.Join(strings.Fields(strings.TrimSpace(line)), " ")
if line == "" {
continue
}
const maxPreviewRunes = 96
runes := []rune(line)
if len(runes) <= maxPreviewRunes {
return line
}
return string(runes[:maxPreviewRunes-3]) + "..."
}
return ""
}

// Close closes the underlying file.
func (s *Session) Close() error {
if s == nil {
Expand Down Expand Up @@ -916,6 +1069,57 @@ func normalizeWorkDir(workDir string) (string, error) {
return filepath.Clean(absWorkDir), nil
}

func cleanupExpiredBucket(bucketPath string, cutoff time.Time) (int, error) {
entries, err := os.ReadDir(bucketPath)
if err != nil {
return 0, fmt.Errorf("read session bucket: %w", err)
}

removed := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
sessionDir := filepath.Join(bucketPath, entry.Name())
updatedAt, err := sessionDirUpdatedAt(sessionDir)
if err != nil {
return removed, err
}
if !updatedAt.Before(cutoff) {
continue
}
if err := os.RemoveAll(sessionDir); err != nil {
return removed, fmt.Errorf("remove expired session %s: %w", entry.Name(), err)
}
removed++
}

return removed, nil
}

func sessionDirUpdatedAt(sessionDir string) (time.Time, error) {
info, err := os.Stat(sessionDir)
if err != nil {
return time.Time{}, fmt.Errorf("stat session dir: %w", err)
}

updatedAt := info.ModTime()
entries, err := os.ReadDir(sessionDir)
if err != nil {
return time.Time{}, fmt.Errorf("read session dir: %w", err)
}
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
return time.Time{}, fmt.Errorf("stat session entry: %w", err)
}
if info.ModTime().After(updatedAt) {
updatedAt = info.ModTime()
}
}
return updatedAt, nil
}

func workDirKey(absWorkDir string) string {
key := filepath.Clean(absWorkDir)
replacer := strings.NewReplacer(
Expand Down
129 changes: 129 additions & 0 deletions agent/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,135 @@ func TestLoadReplayPathAcceptsTrajectoryJSONFilename(t *testing.T) {
}
}

func TestListForWorkDirReturnsRecentDialogueSummaries(t *testing.T) {
t.Setenv("HOME", t.TempDir())

workDir := t.TempDir()

empty, err := Create(workDir, "system prompt")
if err != nil {
t.Fatalf("create empty session: %v", err)
}
if err := empty.Activate(); err != nil {
t.Fatalf("activate empty session: %v", err)
}
if err := empty.Close(); err != nil {
t.Fatalf("close empty session: %v", err)
}

first, err := Create(workDir, "system prompt")
if err != nil {
t.Fatalf("create first session: %v", err)
}
if err := first.AppendUserInput("first prompt line\nextra detail"); err != nil {
t.Fatalf("append first user input: %v", err)
}
if err := first.AppendAssistant("first reply"); err != nil {
t.Fatalf("append first assistant reply: %v", err)
}
if err := first.Activate(); err != nil {
t.Fatalf("activate first session: %v", err)
}
if err := first.Close(); err != nil {
t.Fatalf("close first session: %v", err)
}

time.Sleep(20 * time.Millisecond)

second, err := Create(workDir, "system prompt")
if err != nil {
t.Fatalf("create second session: %v", err)
}
if err := second.AppendUserInput("second prompt"); err != nil {
t.Fatalf("append second user input: %v", err)
}
if err := second.AppendAssistant("second reply"); err != nil {
t.Fatalf("append second assistant reply: %v", err)
}
if err := second.Activate(); err != nil {
t.Fatalf("activate second session: %v", err)
}
if err := second.Close(); err != nil {
t.Fatalf("close second session: %v", err)
}

summaries, err := ListForWorkDir(workDir)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
if got, want := len(summaries), 2; got != want {
t.Fatalf("summary count = %d, want %d", got, want)
}
if got, want := summaries[0].SessionID, second.ID(); got != want {
t.Fatalf("latest session id = %q, want %q", got, want)
}
if got, want := summaries[0].FirstUserInput, "second prompt"; got != want {
t.Fatalf("latest first user input = %q, want %q", got, want)
}
if got, want := summaries[1].SessionID, first.ID(); got != want {
t.Fatalf("older session id = %q, want %q", got, want)
}
if got, want := summaries[1].FirstUserInput, "first prompt line"; got != want {
t.Fatalf("older first user input = %q, want %q", got, want)
}
}

func TestCleanupExpiredRemovesOnlyStaleSessions(t *testing.T) {
t.Setenv("HOME", t.TempDir())

workDir := t.TempDir()

stale, err := Create(workDir, "system prompt")
if err != nil {
t.Fatalf("create stale session: %v", err)
}
if err := stale.AppendUserInput("stale prompt"); err != nil {
t.Fatalf("append stale user input: %v", err)
}
if err := stale.Activate(); err != nil {
t.Fatalf("activate stale session: %v", err)
}
if err := stale.Close(); err != nil {
t.Fatalf("close stale session: %v", err)
}

staleDir := filepath.Dir(stale.Path())
staleTime := time.Now().Add(-45 * 24 * time.Hour)
for _, path := range []string{staleDir, stale.Path(), snapshotPath(stale.Path())} {
if err := os.Chtimes(path, staleTime, staleTime); err != nil {
t.Fatalf("chtimes stale path %s: %v", path, err)
}
}

fresh, err := Create(workDir, "system prompt")
if err != nil {
t.Fatalf("create fresh session: %v", err)
}
if err := fresh.AppendUserInput("fresh prompt"); err != nil {
t.Fatalf("append fresh user input: %v", err)
}
if err := fresh.Activate(); err != nil {
t.Fatalf("activate fresh session: %v", err)
}
if err := fresh.Close(); err != nil {
t.Fatalf("close fresh session: %v", err)
}

removed, err := CleanupExpired(30 * 24 * time.Hour)
if err != nil {
t.Fatalf("CleanupExpired() error = %v", err)
}
if got, want := removed, 1; got != want {
t.Fatalf("removed session count = %d, want %d", got, want)
}
if _, err := os.Stat(staleDir); !os.IsNotExist(err) {
t.Fatalf("expected stale session dir removed, got %v", err)
}
if _, err := os.Stat(filepath.Dir(fresh.Path())); err != nil {
t.Fatalf("expected fresh session dir kept: %v", err)
}
}

func TestPlaybackTimelineInsertsThinkingBetweenUserAndLLMResponse(t *testing.T) {
t0 := time.Date(2026, time.March, 27, 10, 0, 0, 0, time.UTC)
t1 := t0.Add(2 * time.Second)
Expand Down
Loading