diff --git a/core/engine.go b/core/engine.go index 8344bd6..c1748e8 100644 --- a/core/engine.go +++ b/core/engine.go @@ -251,6 +251,10 @@ func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath str eventIdleTimeout: defaultEventIdleTimeout, } + if ag != nil { + e.sessions.InvalidateForAgent(ag.Name()) + } + if cp, ok := ag.(CommandProvider); ok { e.commands.SetAgentDirs(cp.CommandDirs()) } @@ -1427,7 +1431,7 @@ func (e *Engine) getOrCreateInteractiveStateWith(sessionKey string, p Platform, } if newID := agentSession.CurrentSessionID(); newID != "" { - session.CompareAndSetAgentSessionID(newID) + session.CompareAndSetAgentSessionID(newID, agent.Name()) } state = &interactiveState{ @@ -1632,7 +1636,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess } } if event.SessionID != "" { - if session.CompareAndSetAgentSessionID(event.SessionID) { + if session.CompareAndSetAgentSessionID(event.SessionID, e.agent.Name()) { pendingName := session.GetName() if pendingName != "" && pendingName != "session" && pendingName != "default" { e.sessions.SetSessionName(event.SessionID, pendingName) @@ -1721,7 +1725,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess case EventResult: if event.SessionID != "" { - session.SetAgentSessionID(event.SessionID) + session.SetAgentSessionID(event.SessionID, e.agent.Name()) } fullResponse := event.Content @@ -2292,7 +2296,7 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { slog.Info("cmdSwitch: cleanup done", "session_key", msg.SessionKey) session := sessions.GetOrCreateActive(msg.SessionKey) - session.SetAgentInfo(matched.ID, matched.Summary) + session.SetAgentInfo(matched.ID, agent.Name(), matched.Summary) session.ClearHistory() sessions.Save() @@ -3538,7 +3542,7 @@ func (e *Engine) cmdModel(p Platform, msg *Message, args []string) { e.cleanupInteractiveState(msg.SessionKey) s := e.sessions.GetOrCreateActive(msg.SessionKey) - s.SetAgentSessionID("") + s.SetAgentSessionID("", "") s.ClearHistory() e.sessions.Save() @@ -3619,7 +3623,7 @@ func (e *Engine) cmdReasoning(p Platform, msg *Message, args []string) { e.cleanupInteractiveState(msg.SessionKey) s := e.sessions.GetOrCreateActive(msg.SessionKey) - s.SetAgentSessionID("") + s.SetAgentSessionID("", "") s.ClearHistory() e.sessions.Save() @@ -4591,7 +4595,7 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { switcher.SetModel(target) e.cleanupInteractiveState(sessionKey) s := e.sessions.GetOrCreateActive(sessionKey) - s.SetAgentSessionID("") + s.SetAgentSessionID("", "") s.ClearHistory() e.sessions.Save() @@ -4613,7 +4617,7 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { switcher.SetReasoningEffort(target) e.cleanupInteractiveState(sessionKey) s := e.sessions.GetOrCreateActive(sessionKey) - s.SetAgentSessionID("") + s.SetAgentSessionID("", "") s.ClearHistory() e.sessions.Save() return @@ -4709,7 +4713,7 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { interactiveKey := e.interactiveKeyForSessionKey(sessionKey) e.cleanupInteractiveState(interactiveKey) session := sessions.GetOrCreateActive(sessionKey) - session.SetAgentInfo(matched.ID, matched.Summary) + session.SetAgentInfo(matched.ID, agent.Name(), matched.Summary) session.ClearHistory() sessions.Save() @@ -7094,7 +7098,7 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, chatID, message s } defer agentSession.Close() - if session.CompareAndSetAgentSessionID(agentSession.CurrentSessionID()) { + if session.CompareAndSetAgentSessionID(agentSession.CurrentSessionID(), e.agent.Name()) { e.sessions.Save() } @@ -7113,13 +7117,13 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, chatID, message s textParts = append(textParts, event.Content) } if event.SessionID != "" { - if session.CompareAndSetAgentSessionID(event.SessionID) { + if session.CompareAndSetAgentSessionID(event.SessionID, e.agent.Name()) { e.sessions.Save() } } case EventResult: if event.SessionID != "" { - session.SetAgentSessionID(event.SessionID) + session.SetAgentSessionID(event.SessionID, e.agent.Name()) e.sessions.Save() } resp := event.Content diff --git a/core/engine_test.go b/core/engine_test.go index e4bb8b8..f394677 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -908,7 +908,7 @@ func TestCmdCurrent_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) { msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"} session := e.sessions.GetOrCreateActive(msg.SessionKey) session.Name = "Focus" - session.SetAgentSessionID("session-123") + session.SetAgentSessionID("session-123", "test") session.History = append(session.History, HistoryEntry{Role: "user", Content: "hello", Timestamp: time.Now()}) e.cmdCurrent(p, msg) @@ -1243,7 +1243,7 @@ func TestDeleteMode_SubmitBlocksActiveSession(t *testing.T) { }}} e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"} - e.sessions.GetOrCreateActive(msg.SessionKey).SetAgentSessionID("session-1") + e.sessions.GetOrCreateActive(msg.SessionKey).SetAgentSessionID("session-1", "test") e.cmdDelete(p, msg, nil) _ = e.handleCardNav("act:/delete-mode toggle session-1", msg.SessionKey) @@ -1267,7 +1267,7 @@ func TestDeleteMode_ActiveSessionMarkedWithArrowAndNotSelectable(t *testing.T) { }}} e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"} - e.sessions.GetOrCreateActive(msg.SessionKey).SetAgentSessionID("session-1") + e.sessions.GetOrCreateActive(msg.SessionKey).SetAgentSessionID("session-1", "test") e.cmdDelete(p, msg, nil) if len(p.repliedCards) != 1 { @@ -1442,7 +1442,7 @@ func TestCmdReasoning_SwitchesEffortAndResetsSession(t *testing.T) { msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"} s := e.sessions.GetOrCreateActive(msg.SessionKey) - s.SetAgentSessionID("existing-session") + s.SetAgentSessionID("existing-session", "test") s.AddHistory("user", "hello") e.cmdReasoning(p, msg, []string{"3"}) @@ -1769,7 +1769,7 @@ func TestRenderListCard_MakesEveryVisibleSessionClickable(t *testing.T) { } e := NewEngine("test", &stubListAgent{sessions: sessions}, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish) - e.sessions.GetOrCreateActive("test:user1").SetAgentSessionID(sessions[5].ID) + e.sessions.GetOrCreateActive("test:user1").SetAgentSessionID(sessions[5].ID, "test") card, err := e.renderListCard("test:user1", 1) if err != nil { diff --git a/core/session.go b/core/session.go index 4017fab..9323520 100644 --- a/core/session.go +++ b/core/session.go @@ -15,6 +15,7 @@ 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"` @@ -50,11 +51,12 @@ func (s *Session) AddHistory(role, content string) { }) } -// SetAgentInfo atomically sets the agent session ID and name. -func (s *Session) SetAgentInfo(agentSessionID, name string) { +// SetAgentInfo atomically sets the agent session ID, agent type, and name. +func (s *Session) SetAgentInfo(agentSessionID, agentType, name string) { s.mu.Lock() defer s.mu.Unlock() s.AgentSessionID = agentSessionID + s.AgentType = agentType s.Name = name } @@ -72,22 +74,24 @@ func (s *Session) GetName() string { return s.Name } -// SetAgentSessionID atomically sets the agent session ID. -func (s *Session) SetAgentSessionID(id string) { +// SetAgentSessionID atomically sets the agent session ID and agent type. +func (s *Session) SetAgentSessionID(id, agentType string) { s.mu.Lock() defer s.mu.Unlock() s.AgentSessionID = id + s.AgentType = agentType } // CompareAndSetAgentSessionID sets the agent session ID only if it is currently empty. // Returns true if the value was set, false if it was already non-empty. -func (s *Session) CompareAndSetAgentSessionID(id string) bool { +func (s *Session) CompareAndSetAgentSessionID(id, agentType string) bool { s.mu.Lock() defer s.mu.Unlock() if s.AgentSessionID != "" { return false } s.AgentSessionID = id + s.AgentType = agentType return true } @@ -305,6 +309,7 @@ func (sm *SessionManager) saveLocked() { ID: s.ID, Name: s.Name, AgentSessionID: s.AgentSessionID, + AgentType: s.AgentType, History: append([]HistoryEntry(nil), s.History...), CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, @@ -367,3 +372,32 @@ func (sm *SessionManager) load() { slog.Info("session: loaded from disk", "path", sm.storePath, "sessions", len(sm.sessions)) } + +// InvalidateForAgent clears AgentSessionID on all sessions whose AgentType +// does not match the current agent. This handles the case where the user +// switches agent types (e.g. opencode → pi) and stale session IDs from the +// old agent would cause errors. +func (sm *SessionManager) InvalidateForAgent(agentType string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + invalidated := 0 + for _, s := range sm.sessions { + s.mu.Lock() + if s.AgentSessionID != "" && s.AgentType != "" && s.AgentType != agentType { + slog.Info("session: invalidating stale agent session", + "session", s.ID, + "old_agent", s.AgentType, + "new_agent", agentType, + "old_agent_session_id", s.AgentSessionID, + ) + s.AgentSessionID = "" + s.AgentType = agentType + invalidated++ + } + s.mu.Unlock() + } + if invalidated > 0 { + sm.saveLocked() + } +} diff --git a/core/session_test.go b/core/session_test.go index bbd36c7..70b4868 100644 --- a/core/session_test.go +++ b/core/session_test.go @@ -198,7 +198,7 @@ func TestSession_GetAgentSessionID(t *testing.T) { if got := s.GetAgentSessionID(); got != "" { t.Errorf("initial GetAgentSessionID = %q, want empty", got) } - s.SetAgentSessionID("sess-1") + s.SetAgentSessionID("sess-1", "test") if got := s.GetAgentSessionID(); got != "sess-1" { t.Errorf("GetAgentSessionID = %q, want %q", got, "sess-1") } @@ -211,6 +211,53 @@ func TestSession_GetName(t *testing.T) { } } +func TestSessionManager_InvalidateForAgent(t *testing.T) { + sm := NewSessionManager("") + + // Create sessions with different agent types + s1 := sm.NewSession("user1", "sess1") + s1.SetAgentSessionID("old-id-1", "opencode") + + s2 := sm.NewSession("user2", "sess2") + s2.SetAgentSessionID("old-id-2", "claudecode") + + s3 := sm.NewSession("user3", "sess3") + s3.SetAgentSessionID("old-id-3", "") // pre-migration, no agent type + + s4 := sm.NewSession("user4", "sess4") // no agent session ID at all + + sm.InvalidateForAgent("claudecode") + + // s1: opencode → should be invalidated + if got := s1.GetAgentSessionID(); got != "" { + t.Errorf("s1 (opencode) AgentSessionID = %q, want empty (should be invalidated)", got) + } + if s1.AgentType != "claudecode" { + t.Errorf("s1 AgentType = %q, want %q after invalidation", s1.AgentType, "claudecode") + } + + // s2: claudecode → should be untouched + if got := s2.GetAgentSessionID(); got != "old-id-2" { + t.Errorf("s2 (claudecode) AgentSessionID = %q, want %q (should be preserved)", got, "old-id-2") + } + if s2.AgentType != "claudecode" { + t.Errorf("s2 AgentType = %q, want %q", s2.AgentType, "claudecode") + } + + // s3: empty agent type → should be untouched (backward compat) + if got := s3.GetAgentSessionID(); got != "old-id-3" { + t.Errorf("s3 (empty type) AgentSessionID = %q, want %q (migration-safe)", got, "old-id-3") + } + if s3.AgentType != "" { + t.Errorf("s3 AgentType = %q, want empty (pre-migration should be untouched)", s3.AgentType) + } + + // s4: no agent session ID → should be untouched + if got := s4.GetAgentSessionID(); got != "" { + t.Errorf("s4 (no session ID) AgentSessionID = %q, want empty", got) + } +} + func TestSession_ConcurrentGetSet(t *testing.T) { s := &Session{} var wg sync.WaitGroup @@ -218,7 +265,7 @@ func TestSession_ConcurrentGetSet(t *testing.T) { wg.Add(2) go func() { defer wg.Done() - s.SetAgentSessionID("id") + s.SetAgentSessionID("id", "test") }() go func() { defer wg.Done()