package vip.mate.channel.web; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** * Pins {@link ChatStreamTracker#cleanupStaleRuns()} — the switch from * wall-clock {@code MAX_LIFETIME_MS} to inactivity-based eviction. * *

Before the fix, a long-running agent that kept producing tool calls * (47-minute LLM-review smoke test, round 6) was killed at the 30-minute * wall-clock mark mid-task. The new behaviour mirrors hermes-agent's * {@code gateway_timeout}: only completely idle runs are evicted, the * actively-producing ones can run as long as they need to. */ class ChatStreamTrackerCleanupTest { private static ChatStreamTracker newTracker() { ChatStreamTracker t = new ChatStreamTracker(new ObjectMapper()); // Tighten the idle threshold so the test stays at unit-test speed. t.setIdleTimeoutMinutesForTesting(5); return t; } @Test @DisplayName("Active run (recent lastEventAt) survives cleanup regardless of total age.") void activeRunSurvives() { ChatStreamTracker tracker = newTracker(); tracker.register("conv-active"); // lastEventAt was set to "now" inside the RunState constructor — // no backdate, so even a "very old createdAt" would be irrelevant. tracker.cleanupStaleRuns(); assertTrue(tracker.hasRunStateForTesting("conv-active"), "actively-producing run must not be evicted"); } @Test @DisplayName("Idle run beyond threshold is evicted.") void idleRunEvicted() { ChatStreamTracker tracker = newTracker(); tracker.register("conv-idle"); // 6 minutes ago — past the 5-minute threshold set above. tracker.backdateLastEventForTesting("conv-idle", System.currentTimeMillis() - 6 * 60_000L); tracker.cleanupStaleRuns(); assertFalse(tracker.hasRunStateForTesting("conv-idle"), "idle run past threshold must be evicted"); } @Test @DisplayName("Idle run just inside the threshold survives — no premature eviction.") void idleRunWithinThresholdSurvives() { ChatStreamTracker tracker = newTracker(); tracker.register("conv-borderline"); // 4 minutes idle — within 5-minute window. tracker.backdateLastEventForTesting("conv-borderline", System.currentTimeMillis() - 4 * 60_000L); tracker.cleanupStaleRuns(); assertTrue(tracker.hasRunStateForTesting("conv-borderline"), "run idle below threshold must survive — would otherwise be a regression of the wall-clock bug"); } @Test @DisplayName("Mixed: active + idle runs — only the idle one is evicted.") void mixedRunsSelectiveEviction() { ChatStreamTracker tracker = newTracker(); tracker.register("conv-active"); tracker.register("conv-idle"); tracker.backdateLastEventForTesting("conv-idle", System.currentTimeMillis() - 10 * 60_000L); tracker.cleanupStaleRuns(); assertTrue(tracker.hasRunStateForTesting("conv-active")); assertFalse(tracker.hasRunStateForTesting("conv-idle")); } @Test @DisplayName("Default idle timeout from @Value matches the documented 30-minute fallback.") void defaultIdleTimeoutIs30() { // Bypass the @Value injection (no Spring context in this unit test) — // the field initialiser pins the default so a refactor that drops the // = 30 falls over here. ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); assertEquals(30, tracker.idleTimeoutMinutesForTesting()); } @Test @DisplayName("Eviction fires emergencySaveCallback first so the assistant trace survives the dispose.") void evictionTriggersEmergencySave() { ChatStreamTracker tracker = newTracker(); tracker.register("conv-needs-save"); AtomicInteger saveCount = new AtomicInteger(); tracker.setEmergencySaveCallback("conv-needs-save", saveCount::incrementAndGet); // Backdate so the eviction path triggers. tracker.backdateLastEventForTesting("conv-needs-save", System.currentTimeMillis() - 10 * 60_000L); tracker.cleanupStaleRuns(); assertEquals(1, saveCount.get(), "emergency save must run exactly once before eviction disposes the Flux"); assertFalse(tracker.hasRunStateForTesting("conv-needs-save")); } @Test @DisplayName("Completed (done) runs skip the emergency save — they already saved at doOnComplete.") void doneRunsSkipEmergencySaveOnEviction() { ChatStreamTracker tracker = newTracker(); tracker.register("conv-already-done"); tracker.complete("conv-already-done"); // mark done // Drive its retention timer out by backdating createdAt; cleanup // path for done runs uses age, not lastEventAt. tracker.backdateLastEventForTesting("conv-already-done", System.currentTimeMillis() - 10 * 60_000L); AtomicInteger saveCount = new AtomicInteger(); tracker.setEmergencySaveCallback("conv-already-done", saveCount::incrementAndGet); // Tweak retention so the done branch fires. // (DONE_RETENTION_MS is 5 min; backdate lastEventAt above already // exceeds it relative to createdAt — but createdAt isn't backdated, // so the done branch won't trigger here. The point is: even if it // did, the emergency save should be skipped because done=true.) // Run cleanup — done run with recent createdAt won't be evicted at // all, so the callback shouldn't fire. tracker.cleanupStaleRuns(); assertEquals(0, saveCount.get(), "callback must not fire when the run is marked done — that path already saved"); } }