From e5128c324038aaa2f255253bc0149f18c8ec0046 Mon Sep 17 00:00:00 2001 From: cg33 Date: Sun, 7 Jun 2026 15:20:40 +0800 Subject: [PATCH] fix(core): handle reset_on_idle_mins in v1.3.3-beta.4 (#1238) In v1.3.3-beta.4, `reset_on_idle_mins` could be configured correctly but the idle session rotation never fired. The root cause is that `Session.Unlock()` bumps `UpdatedAt` on every heartbeat execution and unsolicited agent response, so the idle check in `maybeAutoResetSessionOnIdle` always saw a fresh timestamp and never rotated the session. Add `Session.LastUserActivity`, a separate timestamp that is only set via `TouchUserActivity()` when the engine processes a real incoming user message (after the idle-reset check). The idle check now prefers `LastUserActivity` and falls back to `UpdatedAt` for sessions created before this field was introduced. The previous behaviour is preserved on disk because the new field is `omitempty`. A new regression test (`TestHandleMessage_AutoResetOnIdle_FiresWhenHeartbeatBumpedUpdatedAt`) stubs a stale `LastUserActivity`, simulates a heartbeat by calling `Unlock()`, and verifies that the next user message still triggers idle rotation. Fixes #1221 Co-authored-by: Claude --- core/engine_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/core/engine_test.go b/core/engine_test.go index 44b254557..e3f7b9316 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -3212,6 +3212,7 @@ func TestHandleMessage_AutoResetOnIdle_RotatesToNewSession(t *testing.T) { old.SetAgentSessionID("old-session", "stub") staleAt := time.Now().Add(-2 * time.Hour) old.mu.Lock() + old.LastUserActivity = staleAt old.UpdatedAt = staleAt old.mu.Unlock() @@ -3287,6 +3288,7 @@ func TestHandleMessage_AutoResetOnIdle_DoesNotRotateFreshSession(t *testing.T) { session.SetAgentSessionID("existing-session", "stub") recentAt := time.Now().Add(-5 * time.Minute) session.mu.Lock() + session.LastUserActivity = recentAt session.UpdatedAt = recentAt session.mu.Unlock() @@ -3325,6 +3327,71 @@ func TestHandleMessage_AutoResetOnIdle_DoesNotRotateFreshSession(t *testing.T) { } } +func TestHandleMessage_AutoResetOnIdle_FiresWhenHeartbeatBumpedUpdatedAt(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + agentSession := newResultAgentSession("fresh reply") + agent := &resultAgent{session: agentSession} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + e.SetResetOnIdle(60 * time.Minute) + + key := "test:user1" + old := e.sessions.GetOrCreateActive(key) + old.AddHistory("user", "stale context") + old.SetAgentSessionID("old-session", "stub") + + // Last user message was a long time ago — well past the idle threshold. + staleAt := time.Now().Add(-2 * time.Hour) + old.mu.Lock() + old.LastUserActivity = staleAt + old.mu.Unlock() + + // Simulate a heartbeat (or unsolicited agent response) finishing right + // before this test's user message: Unlock() bumps UpdatedAt to now, but + // LastUserActivity is intentionally NOT touched by those code paths. + old.Unlock() + + if !old.GetUpdatedAt().After(staleAt) { + t.Fatalf("expected Unlock to bump UpdatedAt, got %v vs %v", old.GetUpdatedAt(), staleAt) + } + if !old.GetLastUserActivity().Equal(staleAt) { + t.Fatalf("expected LastUserActivity to remain at %v, got %v", staleAt, old.GetLastUserActivity()) + } + + msg := &Message{ + SessionKey: key, + Platform: "test", + UserID: "u1", + UserName: "user", + Content: "hello after idle", + ReplyCtx: "ctx", + } + e.handleMessage(p, msg) + + deadline := time.After(2 * time.Second) + for { + active := e.sessions.GetOrCreateActive(key) + sent := p.getSent() + if active.ID != old.ID && len(active.GetHistory(0)) >= 2 && len(sent) >= 2 { + break + } + select { + case <-deadline: + t.Fatalf("timed out waiting for idle auto-reset despite heartbeat-bumped UpdatedAt, sent=%v active=%s old=%s", sent, active.ID, old.ID) + default: + time.Sleep(10 * time.Millisecond) + } + } + + active := e.sessions.GetOrCreateActive(key) + if active.ID == old.ID { + t.Fatal("expected a new active session after idle auto-reset") + } + sent := p.getSent() + if !strings.Contains(sent[0], "Session auto-reset") { + t.Fatalf("first reply = %q, want auto-reset notice", sent[0]) + } +} + func TestHandleMessage_AutoResetOnIdle_DoesNotTriggerForSlashCommand(t *testing.T) { p := &stubPlatformEngine{n: "test"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) @@ -3336,6 +3403,7 @@ func TestHandleMessage_AutoResetOnIdle_DoesNotTriggerForSlashCommand(t *testing. session.SetAgentSessionID("old-session", "stub") staleAt := time.Now().Add(-2 * time.Hour) session.mu.Lock() + session.LastUserActivity = staleAt session.UpdatedAt = staleAt session.mu.Unlock()