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:
cg33
2026-06-07 21:21:57 +08:00
committed by GitHub
parent a82b01aa27
commit cafc802afe
3 changed files with 607 additions and 401 deletions

View File

@@ -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`)
}

View File

@@ -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
}

View File

@@ -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()
}