mateclaw/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerCleanupTest.java

132 lines
6.0 KiB
Java

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.
*
* <p>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");
}
}