mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
fix(core): persistent legacy mode for session filtering
Sessions loaded from pre-fix data had their AgentSessionIDs silently cleared by /new and provider switches. The previous fix (PastAgentSessionIDs) only prevented future data loss but didn't handle already-corrupted data. Changes: - Make legacyData unconditional in KnownAgentSessionIDs (no hasPastIDs check that could re-enable filtering prematurely after /new) - Persist legacyData in session snapshot via LegacyData + Version fields - Detect partially migrated data (PastIDTracking=true but untracked sessions exist) during load via snapshot version comparison - Auto-clear legacyData in saveLocked only when ALL sessions have tracking - Add end-to-end test reproducing exact user scenario: legacy data with 15 sessions → /list → /new → message → /list before reply → reply → /list with session name verification - Add unit test for partially migrated data detection Made-with: Cursor
This commit is contained in:
@@ -9699,3 +9699,373 @@ type stubPlatformWithObserve struct {
|
||||
func (s *stubPlatformWithObserve) SendObservation(_ context.Context, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests for /list visibility after /new and provider switches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestCmdList_AllSessionsVisibleAfterRepeatedNew verifies that /list shows ALL
|
||||
// sessions after multiple /new cycles. This is the exact reproduction scenario
|
||||
// reported by users: /new clears the active session's AgentSessionID, causing
|
||||
// filterOwnedSessions to progressively hide older sessions.
|
||||
func TestCmdList_AllSessionsVisibleAfterRepeatedNew(t *testing.T) {
|
||||
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
agentSessions := make([]AgentSessionInfo, 5)
|
||||
for i := range agentSessions {
|
||||
agentSessions[i] = AgentSessionInfo{
|
||||
ID: fmt.Sprintf("codex-thread-%d", i+1),
|
||||
Summary: fmt.Sprintf("Session %d summary", i+1),
|
||||
MessageCount: (i + 1) * 2,
|
||||
ModifiedAt: base.Add(time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
agent := &stubListAgent{sessions: agentSessions}
|
||||
p := &stubPlatformEngine{n: "plain"}
|
||||
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
||||
userKey := "test:user1"
|
||||
|
||||
for i, as := range agentSessions {
|
||||
if i > 0 {
|
||||
old := e.sessions.GetOrCreateActive(userKey)
|
||||
old.SetAgentSessionID("", "")
|
||||
old.ClearHistory()
|
||||
e.sessions.Save()
|
||||
e.sessions.NewSession(userKey, fmt.Sprintf("session-%d", i+1))
|
||||
}
|
||||
s := e.sessions.GetOrCreateActive(userKey)
|
||||
s.SetAgentSessionID(as.ID, "codex")
|
||||
e.sessions.Save()
|
||||
}
|
||||
|
||||
p.sent = nil
|
||||
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
||||
e.cmdList(p, msg, nil)
|
||||
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
for _, as := range agentSessions {
|
||||
if !strings.Contains(p.sent[0], as.Summary) {
|
||||
t.Errorf("/list output missing session %q:\n%s", as.ID, p.sent[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdList_AllSessionsVisibleAfterResetAllSessions simulates a management
|
||||
// API provider switch (resetAllSessions) followed by creating a new session.
|
||||
// All previously tracked sessions must remain visible in /list.
|
||||
func TestCmdList_AllSessionsVisibleAfterResetAllSessions(t *testing.T) {
|
||||
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
agentSessions := make([]AgentSessionInfo, 4)
|
||||
for i := range agentSessions {
|
||||
agentSessions[i] = AgentSessionInfo{
|
||||
ID: fmt.Sprintf("thread-%d", i+1),
|
||||
Summary: fmt.Sprintf("Chat %d", i+1),
|
||||
MessageCount: 5,
|
||||
ModifiedAt: base.Add(time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
agent := &stubListAgent{sessions: agentSessions}
|
||||
p := &stubPlatformEngine{n: "plain"}
|
||||
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
||||
userKey := "test:user1"
|
||||
|
||||
for _, as := range agentSessions[:3] {
|
||||
s := e.sessions.NewSession(userKey, "")
|
||||
s.SetAgentSessionID(as.ID, "codex")
|
||||
}
|
||||
e.sessions.Save()
|
||||
|
||||
e.resetAllSessions()
|
||||
|
||||
newS := e.sessions.NewSession(userKey, "fresh")
|
||||
newS.SetAgentSessionID(agentSessions[3].ID, "codex")
|
||||
e.sessions.Save()
|
||||
|
||||
p.sent = nil
|
||||
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
||||
e.cmdList(p, msg, nil)
|
||||
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
for _, as := range agentSessions {
|
||||
if !strings.Contains(p.sent[0], as.Summary) {
|
||||
t.Errorf("/list output missing session %q after resetAllSessions:\n%s", as.ID, p.sent[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdList_SessionVisibleDuringAgentProcessing simulates the window where
|
||||
// a new session has been created (/new) and a message sent, but the agent
|
||||
// has not yet responded with a session ID. During this window, the active
|
||||
// session has no AgentSessionID. Previously this caused filterOwnedSessions
|
||||
// to either return all sessions (empty known set) or hide sessions (if other
|
||||
// sessions also had cleared IDs). The fix ensures deterministic behavior.
|
||||
func TestCmdList_SessionVisibleDuringAgentProcessing(t *testing.T) {
|
||||
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
agentSessions := []AgentSessionInfo{
|
||||
{ID: "old-thread-1", Summary: "Old session 1", MessageCount: 10, ModifiedAt: base},
|
||||
{ID: "old-thread-2", Summary: "Old session 2", MessageCount: 8, ModifiedAt: base.Add(time.Hour)},
|
||||
{ID: "new-thread-3", Summary: "Processing...", MessageCount: 1, ModifiedAt: base.Add(2 * time.Hour)},
|
||||
}
|
||||
|
||||
agent := &stubListAgent{sessions: agentSessions}
|
||||
p := &stubPlatformEngine{n: "plain"}
|
||||
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
||||
userKey := "test:user1"
|
||||
|
||||
s1 := e.sessions.GetOrCreateActive(userKey)
|
||||
s1.SetAgentSessionID("old-thread-1", "codex")
|
||||
e.sessions.Save()
|
||||
|
||||
s1.SetAgentSessionID("", "")
|
||||
s2 := e.sessions.NewSession(userKey, "session-2")
|
||||
s2.SetAgentSessionID("old-thread-2", "codex")
|
||||
e.sessions.Save()
|
||||
|
||||
s2.SetAgentSessionID("", "")
|
||||
e.sessions.NewSession(userKey, "processing")
|
||||
e.sessions.Save()
|
||||
|
||||
p.sent = nil
|
||||
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
||||
e.cmdList(p, msg, nil)
|
||||
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
reply := p.sent[0]
|
||||
if !strings.Contains(reply, "Old session 1") {
|
||||
t.Errorf("/list missing 'Old session 1' during processing:\n%s", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "Old session 2") {
|
||||
t.Errorf("/list missing 'Old session 2' during processing:\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderListCard_AllSessionsVisibleAfterRepeatedNew is the card-based
|
||||
// variant of the /new regression test.
|
||||
func TestRenderListCard_AllSessionsVisibleAfterRepeatedNew(t *testing.T) {
|
||||
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
agentSessions := make([]AgentSessionInfo, 6)
|
||||
for i := range agentSessions {
|
||||
agentSessions[i] = AgentSessionInfo{
|
||||
ID: fmt.Sprintf("thread-%d", i+1),
|
||||
Summary: fmt.Sprintf("Session %d", i+1),
|
||||
MessageCount: 3,
|
||||
ModifiedAt: base.Add(time.Duration(i) * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
agent := &stubListAgent{sessions: agentSessions}
|
||||
e := NewEngine("test", agent, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
||||
userKey := "test:user1"
|
||||
|
||||
for i, as := range agentSessions {
|
||||
if i > 0 {
|
||||
old := e.sessions.GetOrCreateActive(userKey)
|
||||
old.SetAgentSessionID("", "")
|
||||
old.ClearHistory()
|
||||
e.sessions.NewSession(userKey, fmt.Sprintf("s%d", i+1))
|
||||
}
|
||||
s := e.sessions.GetOrCreateActive(userKey)
|
||||
s.SetAgentSessionID(as.ID, "codex")
|
||||
}
|
||||
e.sessions.Save()
|
||||
|
||||
card, err := e.renderListCard(userKey, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("renderListCard error: %v", err)
|
||||
}
|
||||
|
||||
switchActions := countCardActionValues(card, "act:/switch ")
|
||||
if switchActions != len(agentSessions) {
|
||||
t.Fatalf("card switch actions = %d, want %d (some sessions hidden by filter)",
|
||||
switchActions, len(agentSessions))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdList_ProviderSwitchThenNewDoesNotHideSessions simulates the full
|
||||
// real-world scenario: user has sessions → switches provider → creates new
|
||||
// sessions → all sessions (old and new) must remain visible.
|
||||
func TestCmdList_ProviderSwitchThenNewDoesNotHideSessions(t *testing.T) {
|
||||
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
||||
allAgentSessions := []AgentSessionInfo{
|
||||
{ID: "old-1", Summary: "Before switch 1", MessageCount: 5, ModifiedAt: base},
|
||||
{ID: "old-2", Summary: "Before switch 2", MessageCount: 3, ModifiedAt: base.Add(time.Hour)},
|
||||
{ID: "new-1", Summary: "After switch 1", MessageCount: 2, ModifiedAt: base.Add(2 * time.Hour)},
|
||||
{ID: "new-2", Summary: "After switch 2", MessageCount: 1, ModifiedAt: base.Add(3 * time.Hour)},
|
||||
}
|
||||
agent := &stubListAgent{sessions: allAgentSessions}
|
||||
p := &stubPlatformEngine{n: "plain"}
|
||||
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
||||
userKey := "test:user1"
|
||||
|
||||
for _, as := range allAgentSessions[:2] {
|
||||
s := e.sessions.NewSession(userKey, "")
|
||||
s.SetAgentSessionID(as.ID, "codex")
|
||||
}
|
||||
e.sessions.Save()
|
||||
|
||||
e.resetAllSessions()
|
||||
|
||||
for i, as := range allAgentSessions[2:] {
|
||||
if i > 0 {
|
||||
old := e.sessions.GetOrCreateActive(userKey)
|
||||
old.SetAgentSessionID("", "")
|
||||
}
|
||||
s := e.sessions.NewSession(userKey, "")
|
||||
s.SetAgentSessionID(as.ID, "codex")
|
||||
}
|
||||
e.sessions.Save()
|
||||
|
||||
p.sent = nil
|
||||
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
||||
e.cmdList(p, msg, nil)
|
||||
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
for _, as := range allAgentSessions {
|
||||
if !strings.Contains(p.sent[0], as.Summary) {
|
||||
t.Errorf("/list missing %q after provider switch + new:\n%s", as.Summary, p.sent[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdList_RealWorldLegacyDataFullFlow is a precise reproduction of the
|
||||
// user-reported bug using data shaped exactly like the real qa-release project:
|
||||
// - 15 internal sessions, 14 with lost AgentSessionIDs (old code damage)
|
||||
// - 1 active session (s15) with a valid AgentSessionID
|
||||
// - 37 codex sessions on disk
|
||||
//
|
||||
// Steps (matching user's exact reproduction):
|
||||
// 1. /list → must show all 37 sessions (legacy data, no filtering)
|
||||
// 2. /new "我的新会话" → create named session
|
||||
// 3. send message (agent hasn't replied yet) → /list → must STILL show all sessions
|
||||
// 4. agent replies with SessionID → /list → must show all sessions + new one
|
||||
// 5. session name "我的新会话" must appear in the list
|
||||
func TestCmdList_RealWorldLegacyDataFullFlow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessPath := filepath.Join(dir, "sessions.json")
|
||||
|
||||
// Write legacy session data (no past_id_tracking, simulates pre-fix data)
|
||||
legacyJSON := `{
|
||||
"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":"default", "agent_session_id":"", "history":null, "created_at":"2026-04-18T09:02:57Z", "updated_at":"2026-04-18T09:02:57Z"},
|
||||
"s3": {"id":"s3", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T09:03:07Z", "updated_at":"2026-04-18T09:03:07Z"},
|
||||
"s4": {"id":"s4", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T09:07:15Z", "updated_at":"2026-04-18T09:07:15Z"},
|
||||
"s5": {"id":"s5", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T11:14:14Z", "updated_at":"2026-04-18T11:14:14Z"},
|
||||
"s6": {"id":"s6", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T11:39:15Z", "updated_at":"2026-04-18T11:39:15Z"},
|
||||
"s7": {"id":"s7", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T11:42:27Z", "updated_at":"2026-04-18T11:42:27Z"},
|
||||
"s8": {"id":"s8", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T12:01:02Z", "updated_at":"2026-04-18T12:01:22Z"},
|
||||
"s9": {"id":"s9", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T12:06:31Z", "updated_at":"2026-04-18T12:08:37Z"},
|
||||
"s10": {"id":"s10","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T12:18:55Z", "updated_at":"2026-04-18T12:18:55Z"},
|
||||
"s11": {"id":"s11","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T14:07:03Z", "updated_at":"2026-04-18T14:07:47Z"},
|
||||
"s12": {"id":"s12","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T14:07:59Z", "updated_at":"2026-04-18T14:18:49Z"},
|
||||
"s13": {"id":"s13","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T15:50:39Z", "updated_at":"2026-04-20T21:44:37Z"},
|
||||
"s14": {"id":"s14","name":"今天", "agent_session_id":"", "history":null, "created_at":"2026-04-20T21:44:58Z", "updated_at":"2026-04-20T21:44:58Z"},
|
||||
"s15": {"id":"s15","name":"新的会话", "agent_session_id":"019dab28-1a0f-7f60-87ed-b4fda306ebef", "agent_type":"codex", "history":null, "created_at":"2026-04-20T21:50:14Z", "updated_at":"2026-04-20T21:50:14Z"}
|
||||
},
|
||||
"active_session": {"feishu:chat:user1":"s15"},
|
||||
"user_sessions": {"feishu:chat:user1":["s2","s3","s4","s5","s6","s7","s8","s9","s10","s11","s12","s13","s14","s15"]},
|
||||
"counter": 15
|
||||
}`
|
||||
if err := os.WriteFile(sessPath, []byte(legacyJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 4, 18, 9, 0, 0, 0, time.UTC)
|
||||
agentSessions := make([]AgentSessionInfo, 37)
|
||||
for i := range agentSessions {
|
||||
agentSessions[i] = AgentSessionInfo{
|
||||
ID: fmt.Sprintf("codex-thread-%03d", i+1),
|
||||
Summary: fmt.Sprintf("Codex session %d", i+1),
|
||||
MessageCount: 3,
|
||||
ModifiedAt: base.Add(time.Duration(i) * 30 * time.Minute),
|
||||
}
|
||||
}
|
||||
// s15's actual codex session is at index 36 (most recent)
|
||||
agentSessions[36].ID = "019dab28-1a0f-7f60-87ed-b4fda306ebef"
|
||||
agentSessions[36].Summary = "陈奕迅最有名是那首歌"
|
||||
|
||||
agent := &stubListAgent{sessions: agentSessions}
|
||||
p := &stubPlatformEngine{n: "plain"}
|
||||
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
||||
e.sessions = NewSessionManager(sessPath) // load real data
|
||||
userKey := "feishu:chat:user1"
|
||||
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
||||
|
||||
// ── Step 1: /list on startup ───────────────────────────────
|
||||
p.sent = nil
|
||||
e.cmdList(p, msg, nil)
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("step1: expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
step1Count := strings.Count(p.sent[0], "msgs")
|
||||
if step1Count != 20 {
|
||||
t.Fatalf("step1: /list should show first page (20 sessions), got %d", step1Count)
|
||||
}
|
||||
|
||||
// ── Step 2: /new "我的新会话" ──────────────────────────────
|
||||
e.cmdNew(p, msg, []string{"我的新会话"})
|
||||
|
||||
// ── Step 3: send message, agent not yet replied → /list ────
|
||||
// (agent process started but hasn't returned SessionID yet)
|
||||
p.sent = nil
|
||||
e.cmdList(p, msg, nil)
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("step3: expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
step3Count := strings.Count(p.sent[0], "msgs")
|
||||
if step3Count < 20 {
|
||||
t.Fatalf("step3: /list BEFORE agent reply should still show all sessions (page 1 = 20), got %d\nreply:\n%s",
|
||||
step3Count, p.sent[0])
|
||||
}
|
||||
|
||||
// ── Step 4: agent replies → set SessionID → /list ──────────
|
||||
newSession := e.sessions.GetOrCreateActive(userKey)
|
||||
newThreadID := "codex-thread-new-038"
|
||||
newSession.CompareAndSetAgentSessionID(newThreadID, "codex")
|
||||
// Engine maps the pending name to the new agent session ID
|
||||
pendingName := newSession.GetName()
|
||||
if pendingName != "" && pendingName != "session" && pendingName != "default" {
|
||||
e.sessions.SetSessionName(newThreadID, pendingName)
|
||||
}
|
||||
e.sessions.Save()
|
||||
|
||||
// Agent now reports this new session in ListSessions
|
||||
agent.sessions = append(agent.sessions, AgentSessionInfo{
|
||||
ID: newThreadID,
|
||||
Summary: "我的新消息内容",
|
||||
MessageCount: 2,
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
|
||||
p.sent = nil
|
||||
e.cmdList(p, msg, nil)
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("step4: expected 1 reply, got %d", len(p.sent))
|
||||
}
|
||||
step4Count := strings.Count(p.sent[0], "msgs")
|
||||
if step4Count < 20 {
|
||||
t.Fatalf("step4: /list AFTER agent reply should show all sessions (page 1 = 20), got %d\nreply:\n%s",
|
||||
step4Count, p.sent[0])
|
||||
}
|
||||
|
||||
// ── Step 5: verify session name on page 2 ─────────────────
|
||||
// The newest session is at the end of the list; check page 2.
|
||||
p.sent = nil
|
||||
e.cmdList(p, msg, []string{"2"})
|
||||
if len(p.sent) != 1 {
|
||||
t.Fatalf("step5: expected 1 reply for page 2, got %d", len(p.sent))
|
||||
}
|
||||
// The new session should show "我的新会话" (the name from /new), not the message content
|
||||
if !strings.Contains(p.sent[0], "我的新会话") {
|
||||
t.Errorf("step5: /list page 2 should display session name '我的新会话' but it's missing:\n%s", p.sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
150
core/session.go
150
core/session.go
@@ -16,13 +16,14 @@ const ContinueSession = "__continue__"
|
||||
|
||||
// Session tracks one conversation between a user and the agent.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentSessionID string `json:"agent_session_id"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
History []HistoryEntry `json:"history"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentSessionID string `json:"agent_session_id"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
PastAgentSessionIDs []string `json:"past_agent_session_ids,omitempty"`
|
||||
History []HistoryEntry `json:"history"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
mu sync.Mutex `json:"-"`
|
||||
busy bool `json:"-"`
|
||||
@@ -65,6 +66,21 @@ func (s *Session) AddHistory(role, content string) {
|
||||
})
|
||||
}
|
||||
|
||||
// recordPastAgentSessionID saves the current AgentSessionID to PastAgentSessionIDs
|
||||
// so it remains visible in KnownAgentSessionIDs after the ID is replaced or cleared.
|
||||
// Must be called with s.mu held.
|
||||
func (s *Session) recordPastAgentSessionID() {
|
||||
if s.AgentSessionID == "" || s.AgentSessionID == ContinueSession {
|
||||
return
|
||||
}
|
||||
for _, past := range s.PastAgentSessionIDs {
|
||||
if past == s.AgentSessionID {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.PastAgentSessionIDs = append(s.PastAgentSessionIDs, s.AgentSessionID)
|
||||
}
|
||||
|
||||
// SetAgentInfo atomically sets the agent session ID, agent type, and name.
|
||||
func (s *Session) SetAgentInfo(agentSessionID, agentType, name string) {
|
||||
if agentSessionID == ContinueSession {
|
||||
@@ -72,6 +88,9 @@ func (s *Session) SetAgentInfo(agentSessionID, agentType, name string) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AgentSessionID != agentSessionID {
|
||||
s.recordPastAgentSessionID()
|
||||
}
|
||||
s.AgentSessionID = agentSessionID
|
||||
s.AgentType = agentType
|
||||
s.Name = name
|
||||
@@ -100,12 +119,17 @@ func (s *Session) GetUpdatedAt() time.Time {
|
||||
// SetAgentSessionID atomically sets the agent session ID and agent type.
|
||||
// The ContinueSession sentinel is never persisted — it is only used transiently
|
||||
// when starting an agent (see engine); storing it on disk breaks resume (#255).
|
||||
// When the existing ID is replaced or cleared, it is saved to PastAgentSessionIDs
|
||||
// so filterOwnedSessions continues to recognise the session.
|
||||
func (s *Session) SetAgentSessionID(id, agentType string) {
|
||||
if id == ContinueSession {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AgentSessionID != id {
|
||||
s.recordPastAgentSessionID()
|
||||
}
|
||||
s.AgentSessionID = id
|
||||
s.AgentType = agentType
|
||||
}
|
||||
@@ -160,14 +184,23 @@ type UserMeta struct {
|
||||
ChatName string `json:"chat_name,omitempty"`
|
||||
}
|
||||
|
||||
// snapshotVersion tracks the schema version so we can detect data saved by
|
||||
// older code that didn't persist all migration flags.
|
||||
// - 0 (missing): original format or early PastIDTracking-only format
|
||||
// - 1: full LegacyData persistence
|
||||
const snapshotVersion = 1
|
||||
|
||||
// sessionSnapshot is the JSON-serializable state of the SessionManager.
|
||||
type sessionSnapshot struct {
|
||||
Sessions map[string]*Session `json:"sessions"`
|
||||
ActiveSession map[string]string `json:"active_session"`
|
||||
UserSessions map[string][]string `json:"user_sessions"`
|
||||
Counter int64 `json:"counter"`
|
||||
SessionNames map[string]string `json:"session_names,omitempty"` // agent session ID → custom name
|
||||
UserMeta map[string]*UserMeta `json:"user_meta,omitempty"` // sessionKey → display info
|
||||
Sessions map[string]*Session `json:"sessions"`
|
||||
ActiveSession map[string]string `json:"active_session"`
|
||||
UserSessions map[string][]string `json:"user_sessions"`
|
||||
Counter int64 `json:"counter"`
|
||||
SessionNames map[string]string `json:"session_names,omitempty"` // agent session ID → custom name
|
||||
UserMeta map[string]*UserMeta `json:"user_meta,omitempty"` // sessionKey → display info
|
||||
PastIDTracking bool `json:"past_id_tracking,omitempty"` // true once PastAgentSessionIDs is supported
|
||||
LegacyData bool `json:"legacy_data,omitempty"` // true while pre-fix sessions exist
|
||||
Version int `json:"version,omitempty"` // schema version for migration detection
|
||||
}
|
||||
|
||||
// SessionManager supports multiple named sessions per user with active-session tracking.
|
||||
@@ -181,6 +214,12 @@ type SessionManager struct {
|
||||
userMeta map[string]*UserMeta // sessionKey → display info
|
||||
counter int64
|
||||
storePath string // empty = no persistence
|
||||
|
||||
// legacyData is true when sessions were loaded from a snapshot that
|
||||
// predates PastAgentSessionIDs tracking. In this state, many sessions
|
||||
// may have lost their AgentSessionID through /new or provider switches.
|
||||
// KnownAgentSessionIDs returns nil to disable filterOwnedSessions.
|
||||
legacyData bool
|
||||
}
|
||||
|
||||
func NewSessionManager(storePath string) *SessionManager {
|
||||
@@ -395,17 +434,30 @@ func (sm *SessionManager) AllSessions() []*Session {
|
||||
// KnownAgentSessionIDs returns the set of agent session IDs tracked by cc-connect.
|
||||
// This is used to filter agent.ListSessions() output to only sessions owned by
|
||||
// cc-connect, excluding sessions created by external CLI usage in the same work_dir.
|
||||
// It includes both current and historical agent session IDs so that sessions whose
|
||||
// IDs were cleared (e.g. after /new or provider switch) remain visible.
|
||||
//
|
||||
// Legacy data: when the snapshot was written before PastAgentSessionIDs tracking
|
||||
// existed, many sessions may have silently lost their IDs through /new or provider
|
||||
// switches. Returns nil unconditionally while legacyData is true, disabling
|
||||
// filterOwnedSessions. legacyData is only cleared once every session has at least
|
||||
// one tracked ID (current or past), meaning the data has been fully migrated.
|
||||
func (sm *SessionManager) KnownAgentSessionIDs() map[string]struct{} {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
if sm.legacyData {
|
||||
return nil
|
||||
}
|
||||
ids := make(map[string]struct{})
|
||||
for _, s := range sm.sessions {
|
||||
s.mu.Lock()
|
||||
aid := s.AgentSessionID
|
||||
s.mu.Unlock()
|
||||
if aid != "" {
|
||||
ids[aid] = struct{}{}
|
||||
if s.AgentSessionID != "" {
|
||||
ids[s.AgentSessionID] = struct{}{}
|
||||
}
|
||||
for _, past := range s.PastAgentSessionIDs {
|
||||
ids[past] = struct{}{}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -511,24 +563,43 @@ func (sm *SessionManager) saveLocked() {
|
||||
s.AgentSessionID = ""
|
||||
}
|
||||
snapSessions[id] = &Session{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
AgentSessionID: agentSID,
|
||||
AgentType: s.AgentType,
|
||||
History: append([]HistoryEntry(nil), s.History...),
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
AgentSessionID: agentSID,
|
||||
AgentType: s.AgentType,
|
||||
PastAgentSessionIDs: append([]string(nil), s.PastAgentSessionIDs...),
|
||||
History: append([]HistoryEntry(nil), s.History...),
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Auto-clear legacyData once every session has at least one tracked ID.
|
||||
if sm.legacyData {
|
||||
allTracked := true
|
||||
for _, s := range snapSessions {
|
||||
if s.AgentSessionID == "" && len(s.PastAgentSessionIDs) == 0 {
|
||||
allTracked = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allTracked {
|
||||
sm.legacyData = false
|
||||
slog.Info("session: legacy data migration complete, filtering re-enabled")
|
||||
}
|
||||
}
|
||||
|
||||
snap := sessionSnapshot{
|
||||
Sessions: snapSessions,
|
||||
ActiveSession: sm.activeSession,
|
||||
UserSessions: sm.userSessions,
|
||||
Counter: sm.counter,
|
||||
SessionNames: sm.sessionNames,
|
||||
UserMeta: sm.userMeta,
|
||||
Sessions: snapSessions,
|
||||
ActiveSession: sm.activeSession,
|
||||
UserSessions: sm.userSessions,
|
||||
Counter: sm.counter,
|
||||
SessionNames: sm.sessionNames,
|
||||
UserMeta: sm.userMeta,
|
||||
PastIDTracking: true,
|
||||
LegacyData: sm.legacyData,
|
||||
Version: snapshotVersion,
|
||||
}
|
||||
data, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
@@ -563,6 +634,24 @@ func (sm *SessionManager) load() {
|
||||
sm.sessionNames = snap.SessionNames
|
||||
sm.userMeta = snap.UserMeta
|
||||
sm.counter = snap.Counter
|
||||
if snap.Version >= snapshotVersion {
|
||||
sm.legacyData = snap.LegacyData
|
||||
} else {
|
||||
// Snapshot was written before LegacyData persistence existed.
|
||||
sm.legacyData = !snap.PastIDTracking
|
||||
if !sm.legacyData {
|
||||
// PastIDTracking was set by a prior code version but LegacyData
|
||||
// wasn't persisted. Check for sessions that lost their IDs before
|
||||
// PastAgentSessionIDs tracking was available.
|
||||
for _, s := range sm.sessions {
|
||||
if s.AgentSessionID == "" && len(s.PastAgentSessionIDs) == 0 {
|
||||
sm.legacyData = true
|
||||
slog.Info("session: detected untracked sessions from prior data loss, enabling legacy mode")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sm.sessions == nil {
|
||||
sm.sessions = make(map[string]*Session)
|
||||
@@ -605,6 +694,7 @@ func (sm *SessionManager) InvalidateForAgent(agentType string) {
|
||||
"new_agent", agentType,
|
||||
"old_agent_session_id", s.AgentSessionID,
|
||||
)
|
||||
s.recordPastAgentSessionID()
|
||||
s.AgentSessionID = ""
|
||||
s.AgentType = agentType
|
||||
invalidated++
|
||||
|
||||
@@ -596,3 +596,321 @@ func TestSwitchToAgentSession_ReusesExisting(t *testing.T) {
|
||||
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"}
|
||||
},
|
||||
"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"}
|
||||
},
|
||||
"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"}
|
||||
},
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user