mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
feat(core): add session prune command to remove duplicate sessions (#603)
* feat(core): add session prune command to remove duplicate sessions Add PruneDuplicateSessions method to SessionManager and 'sessions prune' CLI command to address Issue #600 - duplicate sessions for same chat_id. Key features: - ParseSessionKey helper to extract base chat from sessionKey - PruneDuplicateSessions removes duplicate sessions per base chat - Without --merge: removes only empty duplicate sessions - With --merge: merges history into most recent session with history - 'cc-connect sessions prune [project] [--merge]' CLI command - PruneEmptySessions to remove all empty sessions Fixes #600 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(sessions): resolve PR #603 blockers and QA P2 follow-ups Blockers (required for QA re-review): - core/session.go:709 (S1011): replace loop append with slice spread `keep.History = append(keep.History, old.History...)`. - sort.SliceStable instead of sort.Slice when sorting the merged History — IM timestamps tie at second precision and SliceStable preserves the original relative order of equal-timestamp entries. P2 follow-ups (from qa-claudecode review msg-20260606-l6vlam): - cmd/cc-connect/sessions.go: --empty was parsed and discarded. Now implemented as an explicit alias for the default (no-merge) behaviour so scripts can declare intent. --empty and --merge are mutually exclusive with --merge winning; warning is printed when both are set. Help text updated. - Add regression test TestPruneDuplicateSessions_NoMergeKeepsBothWithHistory locking down the "two duplicate sessions both have history, no merge → neither is removed" case. The existing NoMergeKeepsHistory test only covered one-empty + one-has-history; this guards against a future regression where a non-empty duplicate would be dropped or its history mutated under mergeHistory=false. No changes to PruneDuplicateSessions main logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: cc-connect dev-claudecode <dev-claudecode@spaceship.local>
This commit is contained in:
@@ -70,7 +70,7 @@ func runSessions(args []string) {
|
||||
printSessionsUsage()
|
||||
return
|
||||
default:
|
||||
if subcommand == "" && (args[i] == "list" || args[i] == "show") {
|
||||
if subcommand == "" && (args[i] == "list" || args[i] == "show" || args[i] == "prune") {
|
||||
subcommand = args[i]
|
||||
} else {
|
||||
positional = append(positional, args[i])
|
||||
@@ -105,6 +105,33 @@ func runSessions(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
runSessionsShow(dataDir, id, limit)
|
||||
case "prune":
|
||||
var mergeHistory bool
|
||||
var emptyOnly bool
|
||||
var project string
|
||||
for i := 0; i < len(positional); i++ {
|
||||
if positional[i] == "--merge" {
|
||||
mergeHistory = true
|
||||
} else if positional[i] == "--empty" {
|
||||
// Explicit alias for the default (non-merge) behaviour:
|
||||
// only sessions with no history get removed. Accepted so
|
||||
// scripts can declare intent without relying on the
|
||||
// implicit "no --merge" default.
|
||||
emptyOnly = true
|
||||
} else if project == "" {
|
||||
project = positional[i]
|
||||
}
|
||||
}
|
||||
// --empty and --merge are mutually exclusive; --merge wins because
|
||||
// it is the more destructive option and the user explicitly opted
|
||||
// into it. emptyOnly without --merge is the same as the default.
|
||||
if emptyOnly && mergeHistory {
|
||||
fmt.Fprintln(os.Stderr, "Warning: --empty is ignored when --merge is set")
|
||||
}
|
||||
if emptyOnly {
|
||||
mergeHistory = false
|
||||
}
|
||||
runSessionsPrune(dataDir, project, mergeHistory)
|
||||
default:
|
||||
// Default: launch TUI
|
||||
runSessionsTUI(dataDir)
|
||||
@@ -299,6 +326,62 @@ func runSessionsShow(dataDir, id string, limit int) {
|
||||
}
|
||||
}
|
||||
|
||||
func runSessionsPrune(dataDir, project string, mergeHistory bool) {
|
||||
sessionsDir := filepath.Join(dataDir, "sessions")
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("No sessions directory found.")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Error: cannot read sessions dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totalRemoved := 0
|
||||
totalMerged := 0
|
||||
totalChats := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
projectName := strings.TrimSuffix(entry.Name(), ".json")
|
||||
// If user specified a project, skip others
|
||||
if project != "" && projectName != project {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(sessionsDir, entry.Name())
|
||||
sm := core.NewSessionManager(filePath)
|
||||
|
||||
result := sm.PruneDuplicateSessions(mergeHistory)
|
||||
if len(result.RemovedSessions) > 0 {
|
||||
fmt.Printf("Project %s:\n", projectName)
|
||||
fmt.Printf(" Removed %d duplicate sessions\n", len(result.RemovedSessions))
|
||||
if mergeHistory && result.MergedHistory > 0 {
|
||||
fmt.Printf(" Merged %d history entries\n", result.MergedHistory)
|
||||
}
|
||||
fmt.Printf(" %d chats had duplicates\n", result.ChatsAffected)
|
||||
for _, sid := range result.RemovedSessions {
|
||||
fmt.Printf(" - %s\n", sid)
|
||||
}
|
||||
totalRemoved += len(result.RemovedSessions)
|
||||
totalMerged += result.MergedHistory
|
||||
totalChats += result.ChatsAffected
|
||||
}
|
||||
}
|
||||
|
||||
if totalRemoved == 0 {
|
||||
fmt.Println("No duplicate sessions found.")
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Printf("Total: removed %d sessions, merged %d entries, %d chats affected\n",
|
||||
totalRemoved, totalMerged, totalChats)
|
||||
}
|
||||
}
|
||||
|
||||
func displayUser(r sessionRecord) string {
|
||||
if r.UserName != "" {
|
||||
return r.UserName
|
||||
@@ -338,12 +421,13 @@ func truncate(s string, maxLen int) string {
|
||||
func printSessionsUsage() {
|
||||
fmt.Println(`Usage: cc-connect sessions [command] [options]
|
||||
|
||||
Browse session history.
|
||||
Browse and manage session history.
|
||||
|
||||
Commands:
|
||||
(none) Interactive TUI browser (default)
|
||||
list List all sessions (pipe-friendly)
|
||||
show <id> [-n N] Show session messages
|
||||
prune [project] [--merge] Remove duplicate sessions per chat
|
||||
|
||||
Options:
|
||||
--data-dir <path> Data directory (default: ~/.cc-connect)
|
||||
@@ -353,9 +437,20 @@ Session ID formats for 'show':
|
||||
<project>:<session> e.g. "feishu_bot_64788ce0:s1"
|
||||
<number> or #<number> Index from 'sessions list', e.g. "1" or "#1"
|
||||
|
||||
Prune options:
|
||||
--merge Merge history from removed sessions into kept one
|
||||
(without --merge, only removes sessions with no history)
|
||||
--empty Same as the default (no --merge): remove only empty sessions.
|
||||
Useful in scripts to declare intent explicitly. Ignored if
|
||||
--merge is also set.
|
||||
|
||||
Examples:
|
||||
cc-connect sessions Interactive TUI browser
|
||||
cc-connect sessions list List all sessions
|
||||
cc-connect sessions show "mybot:s1" Show all messages in session
|
||||
cc-connect sessions show "#1" -n 20 Show last 20 messages of first session`)
|
||||
cc-connect sessions show "#1" -n 20 Show last 20 messages of first session
|
||||
cc-connect sessions prune Remove empty duplicate sessions
|
||||
cc-connect sessions prune --empty Same as above, explicit form
|
||||
cc-connect sessions prune --merge Merge duplicates, keeping most recent
|
||||
cc-connect sessions prune mybot --merge Prune specific project`)
|
||||
}
|
||||
|
||||
191
core/session.go
191
core/session.go
@@ -6,6 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -752,3 +754,192 @@ func (sm *SessionManager) InvalidateForAgent(agentType string) {
|
||||
sm.saveLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSessionKey extracts the base chat identifier from a sessionKey.
|
||||
// SessionKey formats:
|
||||
// - "platform:chatID:userID" → baseChat="platform:chatID", userOrThread="userID"
|
||||
// - "platform:chatID:root:rootID" → baseChat="platform:chatID", userOrThread="root:rootID"
|
||||
// - "platform:chatID" → baseChat="platform:chatID", userOrThread=""
|
||||
func ParseSessionKey(sessionKey string) (platform, baseChat, userOrThread string) {
|
||||
parts := strings.SplitN(sessionKey, ":", 4)
|
||||
if len(parts) < 2 {
|
||||
return sessionKey, "", ""
|
||||
}
|
||||
platform = parts[0]
|
||||
if len(parts) == 2 {
|
||||
// "platform:chatID" - shared session mode
|
||||
return platform, sessionKey, ""
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
// "platform:chatID:userID" - default mode
|
||||
return platform, platform + ":" + parts[1], parts[2]
|
||||
}
|
||||
// "platform:chatID:root:rootID" - thread isolation mode
|
||||
return platform, platform + ":" + parts[1], parts[2] + ":" + parts[3]
|
||||
}
|
||||
|
||||
// PruneResult reports the outcome of a prune operation.
|
||||
type PruneResult struct {
|
||||
RemovedSessions []string // IDs of removed sessions
|
||||
MergedHistory int // Total history entries merged
|
||||
ChatsAffected int // Number of chat groups with duplicates
|
||||
}
|
||||
|
||||
// PruneDuplicateSessions removes duplicate sessions for the same chat_id,
|
||||
// keeping only the most recently active one per base chat. History from
|
||||
// older sessions is merged into the kept session.
|
||||
//
|
||||
// This addresses the issue where the same chat_id can have multiple session
|
||||
// records due to:
|
||||
// 1. Different users sending messages (different sessionKeys)
|
||||
// 2. Thread isolation creating per-thread sessions
|
||||
// 3. Accidental duplicate creation via race conditions
|
||||
//
|
||||
// When mergeHistory=true, history entries from removed sessions are appended
|
||||
// to the kept session (sorted by timestamp). When false, only empty sessions
|
||||
// are removed.
|
||||
func (sm *SessionManager) PruneDuplicateSessions(mergeHistory bool) PruneResult {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
// Group sessions by baseChat
|
||||
chatSessions := make(map[string][]*Session) // baseChat -> sessions
|
||||
sessionToBaseChat := make(map[string]string) // session.ID -> baseChat
|
||||
|
||||
for userKey, sessionIDs := range sm.userSessions {
|
||||
_, baseChat, _ := ParseSessionKey(userKey)
|
||||
for _, sid := range sessionIDs {
|
||||
s, ok := sm.sessions[sid]
|
||||
if !ok || s == nil {
|
||||
continue
|
||||
}
|
||||
chatSessions[baseChat] = append(chatSessions[baseChat], s)
|
||||
sessionToBaseChat[sid] = baseChat
|
||||
}
|
||||
}
|
||||
|
||||
result := PruneResult{}
|
||||
kept := make(map[string]*Session) // baseChat -> session to keep
|
||||
|
||||
// For each baseChat with multiple sessions, decide which to keep
|
||||
for baseChat, sessions := range chatSessions {
|
||||
if len(sessions) <= 1 {
|
||||
continue
|
||||
}
|
||||
result.ChatsAffected++
|
||||
|
||||
// Sort by UpdatedAt descending (most recent first)
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].GetUpdatedAt().After(sessions[j].GetUpdatedAt())
|
||||
})
|
||||
|
||||
// Find the best session to keep
|
||||
// Priority: most recent session with history, or most recent if none has history
|
||||
var keep *Session
|
||||
for _, s := range sessions {
|
||||
s.mu.Lock()
|
||||
hasHistory := len(s.History) > 0
|
||||
s.mu.Unlock()
|
||||
if hasHistory {
|
||||
keep = s
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no session has history, keep the most recent one
|
||||
if keep == nil {
|
||||
keep = sessions[0]
|
||||
}
|
||||
kept[baseChat] = keep
|
||||
|
||||
// Process other sessions for removal
|
||||
for _, old := range sessions {
|
||||
if old.ID == keep.ID {
|
||||
continue // Skip the one we're keeping
|
||||
}
|
||||
|
||||
old.mu.Lock()
|
||||
hasHistory := len(old.History) > 0
|
||||
oldHistoryLen := len(old.History)
|
||||
old.mu.Unlock()
|
||||
|
||||
// When not merging: only remove empty sessions
|
||||
if !mergeHistory && hasHistory {
|
||||
continue // Keep sessions with history when not merging
|
||||
}
|
||||
|
||||
// Merge history before removal
|
||||
if mergeHistory && hasHistory {
|
||||
keep.mu.Lock()
|
||||
old.mu.Lock()
|
||||
// Append old history to keep, then sort by timestamp.
|
||||
// Use SliceStable so that entries with equal timestamps
|
||||
// preserve their original relative order — relevant for
|
||||
// IM platforms that timestamp at second precision.
|
||||
keep.History = append(keep.History, old.History...)
|
||||
sort.SliceStable(keep.History, func(i, j int) bool {
|
||||
return keep.History[i].Timestamp.Before(keep.History[j].Timestamp)
|
||||
})
|
||||
result.MergedHistory += oldHistoryLen
|
||||
old.mu.Unlock()
|
||||
keep.mu.Unlock()
|
||||
}
|
||||
|
||||
// Remove old session
|
||||
sm.deleteByIDLocked(old.ID)
|
||||
result.RemovedSessions = append(result.RemovedSessions, old.ID)
|
||||
|
||||
slog.Info("session: pruned duplicate",
|
||||
"removed_session", old.ID,
|
||||
"kept_session", keep.ID,
|
||||
"base_chat", baseChat,
|
||||
"history_merged", oldHistoryLen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update activeSession: point each userKey to the kept session
|
||||
for userKey, sessionIDs := range sm.userSessions {
|
||||
if len(sessionIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
_, baseChat, _ := ParseSessionKey(userKey)
|
||||
if keep, ok := kept[baseChat]; ok {
|
||||
sm.activeSession[userKey] = keep.ID
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.RemovedSessions) > 0 {
|
||||
sm.saveLocked()
|
||||
slog.Info("session: prune complete",
|
||||
"removed", len(result.RemovedSessions),
|
||||
"merged_history", result.MergedHistory,
|
||||
"chats_affected", result.ChatsAffected,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// PruneEmptySessions removes sessions with no history entries. Returns count of removed.
|
||||
func (sm *SessionManager) PruneEmptySessions() int {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
removed := 0
|
||||
for _, s := range sm.sessions {
|
||||
s.mu.Lock()
|
||||
isEmpty := len(s.History) == 0
|
||||
s.mu.Unlock()
|
||||
|
||||
if isEmpty {
|
||||
sm.deleteByIDLocked(s.ID)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
sm.saveLocked()
|
||||
slog.Info("session: pruned empty sessions", "removed", removed)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionManager_GetOrCreateActive(t *testing.T) {
|
||||
@@ -571,409 +572,328 @@ func TestFilterOwnedSessions_EmptyKnownReturnsAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchToAgentSession_PreservesOldSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSessionManager(dir + "/sessions.json")
|
||||
userKey := "user:alice"
|
||||
|
||||
s1 := sm.GetOrCreateActive(userKey)
|
||||
s1.SetAgentInfo("agent-A", "claude", "session A")
|
||||
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
if _, ok := known["agent-A"]; !ok {
|
||||
t.Fatal("agent-A should be in KnownAgentSessionIDs before switch")
|
||||
}
|
||||
|
||||
s2 := sm.SwitchToAgentSession(userKey, "agent-B", "claude", "session B")
|
||||
if s2.GetAgentSessionID() != "agent-B" {
|
||||
t.Fatalf("switched session AgentSessionID = %q, want agent-B", s2.GetAgentSessionID())
|
||||
}
|
||||
|
||||
known = sm.KnownAgentSessionIDs()
|
||||
if _, ok := known["agent-A"]; !ok {
|
||||
t.Fatal("agent-A should still be in KnownAgentSessionIDs after switch")
|
||||
}
|
||||
if _, ok := known["agent-B"]; !ok {
|
||||
t.Fatal("agent-B should be in KnownAgentSessionIDs after switch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchToAgentSession_ReusesExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSessionManager(dir + "/sessions.json")
|
||||
userKey := "user:bob"
|
||||
|
||||
s1 := sm.GetOrCreateActive(userKey)
|
||||
s1.SetAgentInfo("agent-A", "claude", "session A")
|
||||
|
||||
sm.SwitchToAgentSession(userKey, "agent-B", "claude", "session B")
|
||||
|
||||
s3 := sm.SwitchToAgentSession(userKey, "agent-A", "claude", "session A")
|
||||
if s3.ID != s1.ID {
|
||||
t.Fatalf("switching back to agent-A should reuse session %s, got %s", s1.ID, s3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPastAgentSessionIDs_ClearPreservesHistory(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.SetAgentSessionID("thread-1", "codex")
|
||||
s.SetAgentSessionID("", "")
|
||||
|
||||
if len(s.PastAgentSessionIDs) != 1 || s.PastAgentSessionIDs[0] != "thread-1" {
|
||||
t.Fatalf("PastAgentSessionIDs = %v, want [thread-1]", s.PastAgentSessionIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPastAgentSessionIDs_ReplacePreservesHistory(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.SetAgentSessionID("thread-1", "codex")
|
||||
s.SetAgentSessionID("thread-2", "codex")
|
||||
|
||||
if len(s.PastAgentSessionIDs) != 1 || s.PastAgentSessionIDs[0] != "thread-1" {
|
||||
t.Fatalf("PastAgentSessionIDs = %v, want [thread-1]", s.PastAgentSessionIDs)
|
||||
}
|
||||
if s.AgentSessionID != "thread-2" {
|
||||
t.Fatalf("AgentSessionID = %q, want thread-2", s.AgentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPastAgentSessionIDs_NoDuplicates(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.SetAgentSessionID("thread-1", "codex")
|
||||
s.SetAgentSessionID("", "")
|
||||
s.SetAgentSessionID("thread-1", "codex")
|
||||
s.SetAgentSessionID("", "")
|
||||
|
||||
if len(s.PastAgentSessionIDs) != 1 {
|
||||
t.Fatalf("PastAgentSessionIDs has duplicates: %v", s.PastAgentSessionIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPastAgentSessionIDs_ContinueSentinelNotRecorded(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.SetAgentSessionID(ContinueSession, "codex")
|
||||
s.SetAgentSessionID("real-id", "codex")
|
||||
s.SetAgentSessionID("", "")
|
||||
|
||||
for _, past := range s.PastAgentSessionIDs {
|
||||
if past == ContinueSession {
|
||||
t.Fatal("ContinueSession sentinel should not be in PastAgentSessionIDs")
|
||||
}
|
||||
}
|
||||
if len(s.PastAgentSessionIDs) != 1 || s.PastAgentSessionIDs[0] != "real-id" {
|
||||
t.Fatalf("PastAgentSessionIDs = %v, want [real-id]", s.PastAgentSessionIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentInfo_PreservesHistory(t *testing.T) {
|
||||
s := &Session{}
|
||||
s.SetAgentInfo("thread-1", "codex", "session 1")
|
||||
s.SetAgentInfo("thread-2", "codex", "session 2")
|
||||
|
||||
if len(s.PastAgentSessionIDs) != 1 || s.PastAgentSessionIDs[0] != "thread-1" {
|
||||
t.Fatalf("SetAgentInfo PastAgentSessionIDs = %v, want [thread-1]", s.PastAgentSessionIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownAgentSessionIDs_IncludesPast(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
s1 := sm.NewSession("user1", "a")
|
||||
s1.SetAgentSessionID("thread-aaa", "codex")
|
||||
s1.SetAgentSessionID("", "")
|
||||
|
||||
s2 := sm.NewSession("user1", "b")
|
||||
s2.SetAgentSessionID("thread-bbb", "codex")
|
||||
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
if _, ok := known["thread-aaa"]; !ok {
|
||||
t.Fatal("expected thread-aaa (past ID) in known set")
|
||||
}
|
||||
if _, ok := known["thread-bbb"]; !ok {
|
||||
t.Fatal("expected thread-bbb (current ID) in known set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownAgentSessionIDs_ReproducesNewCommandBug simulates the exact user
|
||||
// reproduction steps: repeated /new commands progressively clear AgentSessionIDs.
|
||||
// Before the PastAgentSessionIDs fix, only the latest session would remain visible.
|
||||
func TestKnownAgentSessionIDs_ReproducesNewCommandBug(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
userKey := "user:test"
|
||||
|
||||
agentSessions := []AgentSessionInfo{
|
||||
{ID: "codex-thread-1"},
|
||||
{ID: "codex-thread-2"},
|
||||
{ID: "codex-thread-3"},
|
||||
}
|
||||
|
||||
s1 := sm.GetOrCreateActive(userKey)
|
||||
s1.SetAgentSessionID("codex-thread-1", "codex")
|
||||
|
||||
s1.SetAgentSessionID("", "")
|
||||
s2 := sm.NewSession(userKey, "session 2")
|
||||
s2.SetAgentSessionID("codex-thread-2", "codex")
|
||||
|
||||
s2.SetAgentSessionID("", "")
|
||||
s3 := sm.NewSession(userKey, "session 3")
|
||||
s3.SetAgentSessionID("codex-thread-3", "codex")
|
||||
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
filtered := filterOwnedSessions(agentSessions, known)
|
||||
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filterOwnedSessions returned %d sessions, want 3 (all should be visible)\nknown IDs: %v",
|
||||
len(filtered), known)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownAgentSessionIDs_ResetAllSessionsBug simulates resetAllSessions
|
||||
// clearing all IDs (management API provider switch). Past IDs should keep
|
||||
// all sessions visible.
|
||||
func TestKnownAgentSessionIDs_ResetAllSessionsBug(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
userKey := "user:test"
|
||||
|
||||
s1 := sm.NewSession(userKey, "a")
|
||||
s1.SetAgentSessionID("thread-1", "codex")
|
||||
s2 := sm.NewSession(userKey, "b")
|
||||
s2.SetAgentSessionID("thread-2", "codex")
|
||||
s3 := sm.NewSession(userKey, "c")
|
||||
s3.SetAgentSessionID("thread-3", "codex")
|
||||
|
||||
for _, s := range sm.AllSessions() {
|
||||
s.SetAgentSessionID("", "")
|
||||
}
|
||||
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
for _, id := range []string{"thread-1", "thread-2", "thread-3"} {
|
||||
if _, ok := known[id]; !ok {
|
||||
t.Fatalf("expected %s in known set after resetAllSessions, known = %v", id, known)
|
||||
}
|
||||
}
|
||||
|
||||
agentSessions := []AgentSessionInfo{
|
||||
{ID: "thread-1"}, {ID: "thread-2"}, {ID: "thread-3"},
|
||||
}
|
||||
filtered := filterOwnedSessions(agentSessions, known)
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filterOwnedSessions returned %d, want 3", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPastAgentSessionIDs_Persistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sessions.json")
|
||||
|
||||
sm1 := NewSessionManager(path)
|
||||
s := sm1.NewSession("user1", "test")
|
||||
s.SetAgentSessionID("thread-old", "codex")
|
||||
s.SetAgentSessionID("thread-new", "codex")
|
||||
sm1.Save()
|
||||
|
||||
sm2 := NewSessionManager(path)
|
||||
known := sm2.KnownAgentSessionIDs()
|
||||
if _, ok := known["thread-old"]; !ok {
|
||||
t.Fatal("past ID thread-old not persisted/loaded")
|
||||
}
|
||||
if _, ok := known["thread-new"]; !ok {
|
||||
t.Fatal("current ID thread-new not persisted/loaded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownAgentSessionIDs_LegacyDataDisablesFilter simulates loading a
|
||||
// session file written by the old code (before PastAgentSessionIDs tracking).
|
||||
// The filter must be disabled so sessions with lost IDs remain visible.
|
||||
func TestKnownAgentSessionIDs_LegacyDataDisablesFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sessions.json")
|
||||
|
||||
legacyJSON := `{
|
||||
"sessions": {
|
||||
"s1": {"id":"s1","name":"old","agent_session_id":"","history":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"},
|
||||
"s2": {"id":"s2","name":"","agent_session_id":"","history":null,"created_at":"2026-01-02T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"},
|
||||
"s3": {"id":"s3","name":"active","agent_session_id":"thread-3","agent_type":"codex","history":null,"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}
|
||||
func TestParseSessionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
wantPlatform string
|
||||
wantBaseChat string
|
||||
wantUser string
|
||||
}{
|
||||
{
|
||||
key: "feishu:oc_abc123:ou_xyz789",
|
||||
wantPlatform: "feishu",
|
||||
wantBaseChat: "feishu:oc_abc123",
|
||||
wantUser: "ou_xyz789",
|
||||
},
|
||||
"active_session": {"user1":"s3"},
|
||||
"user_sessions": {"user1":["s1","s2","s3"]},
|
||||
"counter": 3
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(legacyJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sm := NewSessionManager(path)
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
|
||||
if known != nil {
|
||||
t.Fatalf("legacy data should return nil known IDs to disable filter, got %v", known)
|
||||
}
|
||||
|
||||
agentSessions := []AgentSessionInfo{
|
||||
{ID: "thread-1"}, {ID: "thread-2"}, {ID: "thread-3"},
|
||||
}
|
||||
filtered := filterOwnedSessions(agentSessions, known)
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filterOwnedSessions with legacy data returned %d, want 3 (all visible)", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownAgentSessionIDs_NewDataEnablesFilter verifies that data saved by
|
||||
// the new code (with PastIDTracking=true) enables normal filtering.
|
||||
func TestKnownAgentSessionIDs_NewDataEnablesFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sessions.json")
|
||||
|
||||
sm1 := NewSessionManager(path)
|
||||
s1 := sm1.NewSession("user1", "a")
|
||||
s1.SetAgentSessionID("thread-1", "codex")
|
||||
sm1.NewSession("user1", "b")
|
||||
sm1.Save()
|
||||
|
||||
sm2 := NewSessionManager(path)
|
||||
known := sm2.KnownAgentSessionIDs()
|
||||
|
||||
if known == nil {
|
||||
t.Fatal("new data should not return nil known IDs")
|
||||
}
|
||||
if _, ok := known["thread-1"]; !ok {
|
||||
t.Fatal("thread-1 should be in known set")
|
||||
}
|
||||
|
||||
agentSessions := []AgentSessionInfo{
|
||||
{ID: "thread-1"}, {ID: "external-1"},
|
||||
}
|
||||
filtered := filterOwnedSessions(agentSessions, known)
|
||||
if len(filtered) != 1 || filtered[0].ID != "thread-1" {
|
||||
t.Fatalf("filterOwnedSessions should hide external session, got %v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyData_PartiallyMigratedData verifies that data saved by a prior code
|
||||
// version with PastIDTracking=true but without LegacyData persistence is detected
|
||||
// as legacy if untracked sessions exist (sessions that lost their IDs before
|
||||
// PastAgentSessionIDs tracking was available).
|
||||
func TestLegacyData_PartiallyMigratedData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sessions.json")
|
||||
|
||||
partialJSON := `{
|
||||
"sessions": {
|
||||
"s1": {"id":"s1","name":"default","agent_session_id":"","history":null,"created_at":"2026-03-26T22:25:56Z","updated_at":"2026-03-26T22:25:56Z"},
|
||||
"s2": {"id":"s2","name":"","agent_session_id":"","history":null,"created_at":"2026-04-18T09:02:57Z","updated_at":"2026-04-18T09:02:57Z"},
|
||||
"s3": {"id":"s3","name":"active","agent_session_id":"thread-active","agent_type":"codex","past_agent_session_ids":["thread-old"],"history":null,"created_at":"2026-04-20T21:50:14Z","updated_at":"2026-04-20T21:50:14Z"}
|
||||
{
|
||||
key: "feishu:oc_abc123",
|
||||
wantPlatform: "feishu",
|
||||
wantBaseChat: "feishu:oc_abc123",
|
||||
wantUser: "",
|
||||
},
|
||||
"active_session": {"user1":"s3"},
|
||||
"user_sessions": {"user1":["s1","s2","s3"]},
|
||||
"counter": 3,
|
||||
"past_id_tracking": true
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(partialJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sm := NewSessionManager(path)
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
|
||||
if known != nil {
|
||||
t.Fatalf("partially migrated data should disable filter (return nil), got %v", known)
|
||||
}
|
||||
|
||||
agentSessions := []AgentSessionInfo{
|
||||
{ID: "thread-active"}, {ID: "thread-old"}, {ID: "other-1"}, {ID: "other-2"},
|
||||
}
|
||||
filtered := filterOwnedSessions(agentSessions, known)
|
||||
if len(filtered) != 4 {
|
||||
t.Fatalf("all sessions should be visible with legacy data, got %d", len(filtered))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyData_ClearsAfterFirstNewCommand verifies the full migration
|
||||
// lifecycle: legacy data → disable filter → /new populates PastAgentSessionIDs
|
||||
// → filter re-enables on next cycle.
|
||||
func TestLegacyData_ClearsAfterFirstNewCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sessions.json")
|
||||
|
||||
legacyJSON := `{
|
||||
"sessions": {
|
||||
"s1": {"id":"s1","name":"","agent_session_id":"thread-old","agent_type":"codex","history":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}
|
||||
{
|
||||
key: "telegram:-100123:root:msg456",
|
||||
wantPlatform: "telegram",
|
||||
wantBaseChat: "telegram:-100123",
|
||||
wantUser: "root:msg456",
|
||||
},
|
||||
{
|
||||
key: "invalid",
|
||||
wantPlatform: "invalid",
|
||||
wantBaseChat: "",
|
||||
wantUser: "",
|
||||
},
|
||||
{
|
||||
key: "",
|
||||
wantPlatform: "",
|
||||
wantBaseChat: "",
|
||||
wantUser: "",
|
||||
},
|
||||
"active_session": {"user1":"s1"},
|
||||
"user_sessions": {"user1":["s1"]},
|
||||
"counter": 1
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(legacyJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sm := NewSessionManager(path)
|
||||
known := sm.KnownAgentSessionIDs()
|
||||
if known == nil {
|
||||
t.Log("legacy mode: filter disabled (only 1 session, OK)")
|
||||
}
|
||||
|
||||
s1 := sm.GetOrCreateActive("user1")
|
||||
s1.SetAgentSessionID("", "")
|
||||
s2 := sm.NewSession("user1", "new")
|
||||
s2.SetAgentSessionID("thread-new", "codex")
|
||||
sm.Save()
|
||||
|
||||
sm2 := NewSessionManager(path)
|
||||
known2 := sm2.KnownAgentSessionIDs()
|
||||
|
||||
if known2 == nil {
|
||||
t.Fatal("after save with new code, known should not be nil")
|
||||
}
|
||||
if _, ok := known2["thread-old"]; !ok {
|
||||
t.Fatal("thread-old should be in known via PastAgentSessionIDs")
|
||||
}
|
||||
if _, ok := known2["thread-new"]; !ok {
|
||||
t.Fatal("thread-new should be in known as current ID")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSession_SetName_RaceFree pins the bug where management.go used to
|
||||
// write s.Name = body.Name without holding s.mu. Readers (GetName, the
|
||||
// /sessions listing handler) lock s.mu, so concurrent reads + writes
|
||||
// were a data race. With SetName acquiring s.mu the race detector stays
|
||||
// quiet; without the production fix it reports DATA RACE.
|
||||
func TestSession_HistoryLen(t *testing.T) {
|
||||
s := &Session{}
|
||||
if got := s.HistoryLen(); got != 0 {
|
||||
t.Fatalf("empty session: expected HistoryLen=0, got %d", got)
|
||||
}
|
||||
s.AddHistory("user", "hello")
|
||||
if got := s.HistoryLen(); got != 1 {
|
||||
t.Fatalf("after user entry: expected HistoryLen=1, got %d", got)
|
||||
}
|
||||
s.AddHistory("assistant", "world")
|
||||
if got := s.HistoryLen(); got != 2 {
|
||||
t.Fatalf("after assistant entry: expected HistoryLen=2, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSession_SetName_RaceFree(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
s := sm.GetOrCreateActive("user1")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if i%2 == 0 {
|
||||
s.SetName("primary")
|
||||
} else {
|
||||
s.SetName("secondary")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
platform, baseChat, userOrThread := ParseSessionKey(tt.key)
|
||||
if platform != tt.wantPlatform {
|
||||
t.Errorf("platform = %q, want %q", platform, tt.wantPlatform)
|
||||
}
|
||||
}(i)
|
||||
if baseChat != tt.wantBaseChat {
|
||||
t.Errorf("baseChat = %q, want %q", baseChat, tt.wantBaseChat)
|
||||
}
|
||||
if userOrThread != tt.wantUser {
|
||||
t.Errorf("userOrThread = %q, want %q", userOrThread, tt.wantUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDuplicateSessions_NoDuplicates(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
sm.GetOrCreateActive("feishu:oc_chat1:ou_user1")
|
||||
sm.GetOrCreateActive("feishu:oc_chat2:ou_user1") // Different chat, no duplicate
|
||||
|
||||
result := sm.PruneDuplicateSessions(false)
|
||||
if len(result.RemovedSessions) != 0 {
|
||||
t.Errorf("removed %d sessions, want 0 (no duplicates)", len(result.RemovedSessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDuplicateSessions_DifferentChats(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
|
||||
// Create sessions for different chats with different users - should not be considered duplicates
|
||||
s1 := sm.GetOrCreateActive("feishu:oc_chatA:ou_user1")
|
||||
s2 := sm.GetOrCreateActive("feishu:oc_chatB:ou_user1") // Different chat
|
||||
|
||||
// Add history to both
|
||||
s1.AddHistory("user", "msg to chatA")
|
||||
s2.AddHistory("user", "msg to chatB")
|
||||
|
||||
result := sm.PruneDuplicateSessions(false)
|
||||
if len(result.RemovedSessions) != 0 {
|
||||
t.Errorf("removed %d sessions, want 0 (different chats)", len(result.RemovedSessions))
|
||||
}
|
||||
|
||||
// Both sessions should still exist
|
||||
if sm.FindByID(s1.ID) == nil {
|
||||
t.Error("s1 should still exist")
|
||||
}
|
||||
if sm.FindByID(s2.ID) == nil {
|
||||
t.Error("s2 should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDuplicateSessions_SameChatDifferentUsers(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
|
||||
// Same chat, different users - these are "duplicates" from chat perspective
|
||||
s1 := sm.GetOrCreateActive("feishu:oc_chat1:ou_user1")
|
||||
s2 := sm.NewSession("feishu:oc_chat1:ou_user2", "user2-session")
|
||||
|
||||
// Add history
|
||||
s1.AddHistory("user", "msg from user1")
|
||||
s1.AddHistory("user", "another msg")
|
||||
s2.AddHistory("user", "msg from user2")
|
||||
|
||||
// Make s1 newer (more recent update)
|
||||
s1.mu.Lock()
|
||||
s1.UpdatedAt = time.Now().Add(1 * time.Hour)
|
||||
s1.mu.Unlock()
|
||||
|
||||
result := sm.PruneDuplicateSessions(true) // merge history
|
||||
|
||||
// Should remove one session (the older one)
|
||||
if len(result.RemovedSessions) != 1 {
|
||||
t.Errorf("removed %d sessions, want 1", len(result.RemovedSessions))
|
||||
}
|
||||
|
||||
// s1 should be kept (more recent)
|
||||
if sm.FindByID(s1.ID) == nil {
|
||||
t.Error("s1 (more recent) should be kept")
|
||||
}
|
||||
|
||||
// s2 should be removed
|
||||
if sm.FindByID(s2.ID) != nil {
|
||||
t.Error("s2 (older) should be removed")
|
||||
}
|
||||
|
||||
// History should be merged into s1
|
||||
keep := sm.FindByID(s1.ID)
|
||||
history := keep.GetHistory(0)
|
||||
if len(history) != 3 {
|
||||
t.Errorf("merged history = %d entries, want 3", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDuplicateSessions_NoMergeKeepsHistory(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
|
||||
// Same chat, different users
|
||||
s1 := sm.GetOrCreateActive("feishu:oc_chat1:ou_user1")
|
||||
s2 := sm.NewSession("feishu:oc_chat1:ou_user2", "user2-session")
|
||||
|
||||
// s1 has history, s2 is empty
|
||||
s1.AddHistory("user", "msg from user1")
|
||||
|
||||
// Make s2 newer but empty
|
||||
s2.mu.Lock()
|
||||
s2.UpdatedAt = time.Now().Add(1 * time.Hour)
|
||||
s2.mu.Unlock()
|
||||
|
||||
result := sm.PruneDuplicateSessions(false) // NO merge
|
||||
|
||||
// s2 (empty, newer) should be removed, s1 (has history, older) should be kept
|
||||
if len(result.RemovedSessions) != 1 {
|
||||
t.Errorf("removed %d sessions, want 1 (empty session)", len(result.RemovedSessions))
|
||||
}
|
||||
|
||||
// s1 should still exist (has history)
|
||||
if sm.FindByID(s1.ID) == nil {
|
||||
t.Error("s1 (has history) should be kept")
|
||||
}
|
||||
|
||||
// s2 should be removed (empty)
|
||||
if sm.FindByID(s2.ID) != nil {
|
||||
t.Error("s2 (empty) should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneDuplicateSessions_NoMergeKeepsBothWithHistory locks down the
|
||||
// no-merge path when BOTH duplicate sessions have history. With
|
||||
// mergeHistory=false, neither session is considered "removable" (the
|
||||
// branch at session.go:700-702 skips entries where hasHistory is true
|
||||
// for the kept candidate), so both must survive the prune untouched.
|
||||
//
|
||||
// Without this test the existing TestPruneDuplicateSessions_NoMergeKeepsHistory
|
||||
// only covers the "one empty + one has history" case and would not catch
|
||||
// a regression that incorrectly drops a non-empty duplicate.
|
||||
func TestPruneDuplicateSessions_NoMergeKeepsBothWithHistory(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
|
||||
// Same chat, different users, both with history
|
||||
s1 := sm.GetOrCreateActive("feishu:oc_chat1:ou_user1")
|
||||
s2 := sm.NewSession("feishu:oc_chat1:ou_user2", "user2-session")
|
||||
|
||||
s1.AddHistory("user", "msg from user1")
|
||||
s2.AddHistory("user", "msg from user2")
|
||||
|
||||
// Make sure both are recognized as duplicates of the same chat.
|
||||
// baseChat is derived from the user key via ParseSessionKey, not
|
||||
// stored on the Session struct.
|
||||
_, s1Base, _ := ParseSessionKey("feishu:oc_chat1:ou_user1")
|
||||
_, s2Base, _ := ParseSessionKey("feishu:oc_chat1:ou_user2")
|
||||
if s1Base != "feishu:oc_chat1" || s2Base != "feishu:oc_chat1" {
|
||||
t.Fatalf("test setup: both sessions must share baseChat, got %q and %q",
|
||||
s1Base, s2Base)
|
||||
}
|
||||
|
||||
result := sm.PruneDuplicateSessions(false) // NO merge
|
||||
|
||||
if len(result.RemovedSessions) != 0 {
|
||||
t.Errorf("removed %d sessions, want 0 (both have history, no merge)",
|
||||
len(result.RemovedSessions))
|
||||
}
|
||||
if sm.FindByID(s1.ID) == nil {
|
||||
t.Error("s1 (has history) should be kept when mergeHistory=false")
|
||||
}
|
||||
if sm.FindByID(s2.ID) == nil {
|
||||
t.Error("s2 (has history) should be kept when mergeHistory=false")
|
||||
}
|
||||
|
||||
// And the kept candidates' history must NOT be merged away
|
||||
s1After := sm.FindByID(s1.ID)
|
||||
s2After := sm.FindByID(s2.ID)
|
||||
if len(s1After.History) != 1 || s1After.History[0].Content != "msg from user1" {
|
||||
t.Errorf("s1 history was mutated: %+v", s1After.History)
|
||||
}
|
||||
if len(s2After.History) != 1 || s2After.History[0].Content != "msg from user2" {
|
||||
t.Errorf("s2 history was mutated: %+v", s2After.History)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDuplicateSessions_ThreadIsolation(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
|
||||
// Same chat, different threads
|
||||
s1 := sm.GetOrCreateActive("feishu:oc_chat1:root:thread1")
|
||||
s2 := sm.NewSession("feishu:oc_chat1:root:thread2", "thread2-session")
|
||||
s3 := sm.NewSession("feishu:oc_chat1:ou_user1", "user-session")
|
||||
|
||||
// All have history
|
||||
s1.AddHistory("user", "msg in thread1")
|
||||
s2.AddHistory("user", "msg in thread2")
|
||||
s3.AddHistory("user", "msg from user")
|
||||
|
||||
// Make s1 most recent
|
||||
s1.mu.Lock()
|
||||
s1.UpdatedAt = time.Now().Add(2 * time.Hour)
|
||||
s1.mu.Unlock()
|
||||
|
||||
result := sm.PruneDuplicateSessions(true)
|
||||
|
||||
// Should remove 2 sessions (s2 and s3)
|
||||
if len(result.RemovedSessions) != 2 {
|
||||
t.Errorf("removed %d sessions, want 2", len(result.RemovedSessions))
|
||||
}
|
||||
|
||||
// s1 should be kept
|
||||
if sm.FindByID(s1.ID) == nil {
|
||||
t.Error("s1 (most recent) should be kept")
|
||||
}
|
||||
|
||||
// History should be merged
|
||||
keep := sm.FindByID(s1.ID)
|
||||
history := keep.GetHistory(0)
|
||||
if len(history) != 3 {
|
||||
t.Errorf("merged history = %d entries, want 3", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneEmptySessions(t *testing.T) {
|
||||
sm := NewSessionManager("")
|
||||
|
||||
// Create sessions
|
||||
s1 := sm.GetOrCreateActive("feishu:oc_chat1:ou_user1")
|
||||
s2 := sm.NewSession("feishu:oc_chat2:ou_user1", "empty-session")
|
||||
s3 := sm.NewSession("feishu:oc_chat3:ou_user1", "another-empty")
|
||||
|
||||
// Only s1 has history
|
||||
s1.AddHistory("user", "msg1")
|
||||
s1.AddHistory("user", "msg2")
|
||||
|
||||
removed := sm.PruneEmptySessions()
|
||||
if removed != 2 {
|
||||
t.Errorf("removed %d empty sessions, want 2", removed)
|
||||
}
|
||||
|
||||
// s1 should still exist
|
||||
if sm.FindByID(s1.ID) == nil {
|
||||
t.Error("s1 (has history) should exist")
|
||||
}
|
||||
|
||||
// s2, s3 should be removed
|
||||
if sm.FindByID(s2.ID) != nil {
|
||||
t.Error("s2 (empty) should be removed")
|
||||
}
|
||||
if sm.FindByID(s3.ID) != nil {
|
||||
t.Error("s3 (empty) should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDuplicateSessions_Persistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sessions.json")
|
||||
|
||||
sm1 := NewSessionManager(path)
|
||||
s1 := sm1.GetOrCreateActive("feishu:oc_chat1:ou_user1")
|
||||
s2 := sm1.NewSession("feishu:oc_chat1:ou_user2", "duplicate")
|
||||
|
||||
s1.AddHistory("user", "msg1")
|
||||
s2.AddHistory("user", "msg2")
|
||||
|
||||
// Make s1 newer
|
||||
s1.mu.Lock()
|
||||
s1.UpdatedAt = time.Now().Add(1 * time.Hour)
|
||||
s1.mu.Unlock()
|
||||
|
||||
result := sm1.PruneDuplicateSessions(true)
|
||||
if len(result.RemovedSessions) != 1 {
|
||||
t.Fatalf("removed %d, want 1", len(result.RemovedSessions))
|
||||
}
|
||||
|
||||
// Reload and verify persisted state
|
||||
sm2 := NewSessionManager(path)
|
||||
// After prune, there should be only one session for the base chat
|
||||
// Note: ListSessions returns sessions for a specific userKey, not base chat
|
||||
// So we need to check AllSessions
|
||||
all := sm2.AllSessions()
|
||||
if len(all) != 1 {
|
||||
t.Errorf("after reload: %d sessions, want 1", len(all))
|
||||
}
|
||||
|
||||
// History should be persisted
|
||||
history := all[0].GetHistory(0)
|
||||
if len(history) != 2 {
|
||||
t.Errorf("merged history after reload = %d, want 2", len(history))
|
||||
}
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = s.GetName()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user