From 74928d615d5c0870eb6352ff8f5b8834970d4d4a Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 20 Apr 2026 17:31:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20Dream=20v2=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20lifecycle=20mediator=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire memory-facing events (turn-started, turn-completed, session-ended, memory-written) through a single MemoryLifecycleMediator so MemoryProvider implementations can hook into the agent conversational flow without spreading side-effects across the runtime. Ten atomic steps shipped under feat/dream-v2-p1-lifecycle: - A.1 + A.2: MemoryLifecycleMediator class + TurnContext value object - A.3: TurnStartedEvent / TurnCompletedEvent domain events - A.4: MemoryLifecycleEventListener bean for Spring event plumbing - A.5: MemoryProvider.onMemoryWrite default method (backward compatible) - A.7: wire the mediator into AgentService at the right hook points - A.8: LifecycleFlagGuardTest — feature flag must gate every hook - A.9: MemoryLifecycleMediatorTest — unit coverage per hook - A.10: LifecycleRecallCountIT — F4 regression across the stack Feature flags (all default OFF; enable per phase after staging): - mate.memory.lifecycle-mediator-enabled - mate.memory.dream.focused-enabled - mate.memory.dream.archive-enabled This is Phase 1 foundation only — focused-dream and archive-dream providers arrive in later phases. --- .github/pull_request_template.md | 11 ++ .../java/vip/mate/agent/AgentService.java | 81 +++++++-- .../vip/mate/memory/MemoryProperties.java | 59 +++++++ .../MemoryLifecycleEventListener.java | 42 +++++ .../lifecycle/MemoryLifecycleMediator.java | 79 +++++++++ .../memory/lifecycle/TurnCompletedEvent.java | 10 ++ .../mate/memory/lifecycle/TurnContext.java | 19 +++ .../memory/lifecycle/TurnStartedEvent.java | 9 + .../vip/mate/memory/spi/MemoryProvider.java | 14 ++ .../src/main/resources/application.yml | 20 +++ .../lifecycle/LifecycleFlagGuardTest.java | 161 ++++++++++++++++++ .../lifecycle/LifecycleRecallCountIT.java | 141 +++++++++++++++ .../MemoryLifecycleMediatorTest.java | 155 +++++++++++++++++ 13 files changed, 790 insertions(+), 11 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java create mode 100644 mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java create mode 100644 mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..f5319ba9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Dream v2 PR Checklist + +- [ ] Only modifies current Phase scope; no future Phase undecided designs introduced +- [ ] Feature flag defaults to off +- [ ] `scripts/rfc-lint.sh` passes locally +- [ ] New Flyway migrations exist in both h2/ and mysql/ directories +- [ ] New `@Scheduled` cron expressions documented in application.yml with stagger rationale +- [ ] Phase 1 PR: `recall_count / daily_count` regression test passes +- [ ] Phase 3 PR: `mate_fact` two-write-path SQL-level guard test passes + +Related RFC: diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 80d880be..72ebff01 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -11,11 +11,16 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; import vip.mate.llm.event.ModelConfigChangedEvent; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.lifecycle.MemoryLifecycleMediator; +import vip.mate.memory.lifecycle.TurnContext; import vip.mate.memory.service.MemoryRecallTracker; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; /** * Agent 业务服务 @@ -33,6 +38,8 @@ public class AgentService { private final AgentMapper agentMapper; private final AgentGraphBuilder agentGraphBuilder; private final MemoryRecallTracker memoryRecallTracker; + private final MemoryLifecycleMediator lifecycleMediator; + private final MemoryProperties memoryProperties; /** 运行时 Agent 实例缓存(agentId -> BaseAgent) */ private final Map agentInstances = new ConcurrentHashMap<>(); @@ -93,13 +100,16 @@ public class AgentService { public String chat(Long agentId, String message, String conversationId) { memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); - return agent.chat(message, conversationId); + return withLifecycleSync(agentId, message, conversationId, + () -> agent.chat(message, conversationId)); } public Flux chatStream(Long agentId, String message, String conversationId) { memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); - return agent.chatStream(message, conversationId); + return withLifecycleFlux(agentId, message, conversationId, + () -> agent.chatStream(message, conversationId), + chunk -> chunk); } public Flux chatStructuredStream(Long agentId, String message, String conversationId) { @@ -130,21 +140,26 @@ public class AgentService { } if (agent instanceof StructuredStreamCapable capable) { - return capable.chatStructuredStream(message, conversationId, - requesterId != null ? requesterId : "") - .doFinally(signal -> ThinkingLevelHolder.clear()); + return withLifecycleFlux(agentId, message, conversationId, + () -> capable.chatStructuredStream(message, conversationId, + requesterId != null ? requesterId : "") + .doFinally(signal -> ThinkingLevelHolder.clear()), + StreamDelta::content); } // 降级:不支持结构化流的 Agent,包装为纯内容流 ThinkingLevelHolder.clear(); - return agent.chatStream(message, conversationId) - .map(chunk -> new StreamDelta(chunk, null)); + return withLifecycleFlux(agentId, message, conversationId, + () -> agent.chatStream(message, conversationId) + .map(chunk -> new StreamDelta(chunk, null)), + StreamDelta::content); } public String execute(Long agentId, String goal, String conversationId) { memoryRecallTracker.trackRecalls(agentId, goal); BaseAgent agent = getOrBuildAgent(agentId); - return agent.execute(goal, conversationId); + return withLifecycleSync(agentId, goal, conversationId, + () -> agent.execute(goal, conversationId)); } /** @@ -160,7 +175,8 @@ public class AgentService { String toolCallPayload) { memoryRecallTracker.trackRecalls(agentId, userMessage); BaseAgent agent = getOrBuildAgent(agentId); - return agent.chatWithReplay(userMessage, conversationId, toolCallPayload); + return withLifecycleSync(agentId, userMessage, conversationId, + () -> agent.chatWithReplay(userMessage, conversationId, toolCallPayload)); } /** @@ -175,8 +191,10 @@ public class AgentService { String toolCallPayload, String requesterId) { memoryRecallTracker.trackRecalls(agentId, userMessage); BaseAgent agent = getOrBuildAgent(agentId); - return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload, - requesterId != null ? requesterId : ""); + return withLifecycleFlux(agentId, userMessage, conversationId, + () -> agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload, + requesterId != null ? requesterId : ""), + StreamDelta::content); } public AgentState getAgentState(Long agentId) { @@ -208,6 +226,47 @@ public class AgentService { log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)"); } + // ==================== Lifecycle helpers ==================== + + /** + * Wraps a synchronous agent call with lifecycle mediator hooks. + * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). + */ + private String withLifecycleSync(Long agentId, String message, String conversationId, + Supplier plainInvoke) { + if (!memoryProperties.isLifecycleMediatorEnabled()) { + return plainInvoke.get(); + } + TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); + lifecycleMediator.beforeLlmCall(ctx); + String result = plainInvoke.get(); + lifecycleMediator.afterLlmCall(ctx, result != null ? result : ""); + return result; + } + + /** + * Wraps a streaming agent call with lifecycle mediator hooks. + * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). + */ + private Flux withLifecycleFlux(Long agentId, String message, String conversationId, + Supplier> plainInvoke, + Function contentExtractor) { + if (!memoryProperties.isLifecycleMediatorEnabled()) { + return plainInvoke.get(); + } + TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); + lifecycleMediator.beforeLlmCall(ctx); + StringBuilder reply = new StringBuilder(); + return plainInvoke.get() + .doOnNext(item -> { + String text = contentExtractor.apply(item); + if (text != null) { + reply.append(text); + } + }) + .doFinally(signal -> lifecycleMediator.afterLlmCall(ctx, reply.toString())); + } + // ==================== 内部方法 ==================== private BaseAgent getOrBuildAgent(Long agentId) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java index 525ab0f0..cfac27e0 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java @@ -80,4 +80,63 @@ public class MemoryProperties { /** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search") */ private Set disabledProviders = new HashSet<>(); + + // ==================== Dream v2 Feature Flags ==================== + + // --- Phase 1: Lifecycle mediator wiring --- + + /** Enable MemoryLifecycleMediator prefetch/sync/onSessionEnd chain; off = Phase 0 behavior only */ + private boolean lifecycleMediatorEnabled = false; + + /** Dream v2 configuration */ + private DreamProperties dream = new DreamProperties(); + + // --- Phase 2: SOUL auto-evolution and provider decorators --- + + /** SOUL.md auto-evolution trigger interval (0 = off) */ + private int soulUpdateInterval = 0; + + /** Provider decorator retry attempts (1 = no retry) */ + private int providerRetryAttempts = 1; + + /** Enable provider metrics collection */ + private boolean providerMetricsEnabled = false; + + // --- Phase 3: Fact projection --- + + /** Fact projection configuration */ + private FactProperties fact = new FactProperties(); + + @Data + public static class DreamProperties { + /** Enable focused dream mode endpoint */ + private boolean focusedEnabled = false; + + /** Enable monthly dream archive rotation */ + private boolean archiveEnabled = false; + + /** Days to keep in DREAMS.md before archiving */ + private int archiveKeepDays = 30; + + /** Maximum candidates per dream consolidation run */ + private int maxCandidatesPerDream = 100; + } + + @Data + public static class FactProperties { + /** Enable fact projection rebuild */ + private boolean projectionEnabled = false; + + /** Fact projection rebuild cron expression */ + private String projectionRebuildCron = "0 */30 * * * ?"; + + /** Enable LLM-based entity extraction (false = pattern-only) */ + private boolean llmExtractionEnabled = false; + + /** Enable contradiction detection in dream consolidation */ + private boolean contradictionCheckEnabled = false; + + /** Trust score half-life in days for time decay */ + private int trustHalfLifeDays = 60; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java new file mode 100644 index 00000000..72c397bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleEventListener.java @@ -0,0 +1,42 @@ +package vip.mate.memory.lifecycle; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.event.ConversationCompletedEvent; + +/** + * Dispatches ConversationCompletedEvent to MemoryManager.onSessionEnd unconditionally. + * + *

Contract: every successfully-persisted conversation end must reach all memory + * providers, regardless of whether summarization / nudge preconditions held. + * + *

This is a separate listener from PostConversationMemoryListener on purpose — + * that one has four early returns tied to summarize/nudge heuristics. Those are fine + * for summarize/nudge business logic, but none of them are appropriate gates for + * provider-level session-end signals (rfc-037 §3.7, decision D10). + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemoryLifecycleEventListener { + + private final MemoryLifecycleMediator mediator; + private final MemoryProperties props; + + @Async + @EventListener + public void onConversationCompleted(ConversationCompletedEvent event) { + if (!props.isLifecycleMediatorEnabled()) return; + try { + mediator.onSessionEnd(event.agentId(), event.conversationId()); + } catch (Exception e) { + log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java new file mode 100644 index 00000000..d19c4b48 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/MemoryLifecycleMediator.java @@ -0,0 +1,79 @@ +package vip.mate.memory.lifecycle; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.memory.spi.MemoryManager; + +/** + * Mediator between the Agent entry-layer (AgentService) and MemoryManager. + * Agent code only calls this class; it hides the details of when/how + * providers are invoked across a turn's lifecycle. + * + *

Non-goals: + *

    + *
  • Does NOT call MemoryRecallTracker.trackRecalls — AgentService already + * owns that call; duplicating here would double recall_count / daily_count + * and pollute Dream scoring (rfc-037 F4).
  • + *
  • Does NOT prefetch next-turn recall keyed on current-turn query — + * query-conditioned providers cannot reuse stale queries (rfc-037 F2).
  • + *
+ * + *

Thread-safety: all public methods are reentrant; per-turn state lives + * in {@link TurnContext}. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemoryLifecycleMediator { + + private final MemoryManager memoryManager; + private final ApplicationEventPublisher events; + + /** + * Called BEFORE the LLM is invoked for a turn. + * Returns the memory-context block to inject, or "" if none. + * + *

Latency contract: synchronous. BuiltinMemoryProvider returns "" so the + * only cost today is iteration overhead (<5ms). + */ + public String beforeLlmCall(TurnContext ctx) { + try { + String context = memoryManager.prefetchAll(ctx.agentId(), ctx.userQuery()); + events.publishEvent(new TurnStartedEvent(ctx)); + return context; + } catch (Exception e) { + log.debug("[Memory] beforeLlmCall failed (non-fatal): {}", e.getMessage()); + return ""; + } + } + + /** + * Called AFTER the LLM finishes a turn successfully. + * Non-blocking: MemoryManager.syncAll dispatches to provider.syncTurn(), + * each provider is responsible for being async internally. + */ + public void afterLlmCall(TurnContext ctx, String assistantReply) { + try { + memoryManager.syncAll(ctx.agentId(), ctx.conversationId(), + ctx.userQuery(), assistantReply); + events.publishEvent(new TurnCompletedEvent(ctx, assistantReply)); + } catch (Exception e) { + log.debug("[Memory] afterLlmCall failed (non-fatal): {}", e.getMessage()); + } + } + + /** + * Called when a conversation ends (from MemoryLifecycleEventListener). + */ + public void onSessionEnd(Long agentId, String conversationId) { + try { + memoryManager.onSessionEnd(agentId, conversationId); + } catch (Exception e) { + log.debug("[Memory] onSessionEnd dispatch failed (non-fatal): {}", e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java new file mode 100644 index 00000000..690bea8c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnCompletedEvent.java @@ -0,0 +1,10 @@ +package vip.mate.memory.lifecycle; + +/** + * Published after syncAll completes for a turn. + * + * @param context the turn context + * @param assistantReply the LLM response text + * @author MateClaw Team + */ +public record TurnCompletedEvent(TurnContext context, String assistantReply) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java new file mode 100644 index 00000000..d7b6f4c2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnContext.java @@ -0,0 +1,19 @@ +package vip.mate.memory.lifecycle; + +/** + * Minimal turn-scoped context; built once per turn at AgentService level. + * + * @param agentId the agent ID + * @param conversationId the conversation ID + * @param sessionId session ID (may equal conversationId in Phase 1) + * @param turnNumber turn sequence number within the conversation + * @param userQuery the current user message + * @author MateClaw Team + */ +public record TurnContext( + Long agentId, + String conversationId, + String sessionId, + int turnNumber, + String userQuery +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java new file mode 100644 index 00000000..c92faadb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/lifecycle/TurnStartedEvent.java @@ -0,0 +1,9 @@ +package vip.mate.memory.lifecycle; + +/** + * Published after prefetchAll completes, before the LLM call. + * + * @param context the turn context + * @author MateClaw Team + */ +public record TurnStartedEvent(TurnContext context) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java index 5784e079..a1fc086c 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/spi/MemoryProvider.java @@ -95,4 +95,18 @@ public interface MemoryProvider { default String onPreCompress(Long agentId, List messages) { return ""; } + + /** + * Notification that a memory write occurred. Called after canonical memory + * files (structured/*.md, MEMORY.md) are updated. + * + *

Phase 1: no subscribers. Phase 2: SOUL auto-evolution hook. + * + * @param agentId the agent ID + * @param target which file was written (e.g. "MEMORY.md", "structured/user_pref.md") + * @param action what happened ("append", "update", "consolidate") + * @param content the written content + */ + default void onMemoryWrite(Long agentId, String target, String action, String content) { + } } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index a80c2875..10c74240 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -198,3 +198,23 @@ mate: upload-dir: ./data/wiki-uploads # 上传文件存储目录 max-scan-files: 500 # 目录扫描最大文件数 max-scan-file-size: 52428800 # 扫描时跳过大于此大小的文件(字节,默认 50MB) + # Dream v2 feature flags (all default off; enable per phase after staging validation) + memory: + # Phase 1: lifecycle mediator wiring + lifecycle-mediator-enabled: false + dream: + focused-enabled: false + archive-enabled: false + archive-keep-days: 30 + max-candidates-per-dream: 100 + # Phase 2: SOUL auto-evolution and provider decorators (enable after Phase 1 GA) + soul-update-interval: 0 + provider-retry-attempts: 1 + provider-metrics-enabled: false + # Phase 3: fact projection (enable after Phase 2 GA) + fact: + projection-enabled: false + projection-rebuild-cron: "0 */30 * * * ?" + llm-extraction-enabled: false + contradiction-check-enabled: false + trust-half-life-days: 60 diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java new file mode 100644 index 00000000..df3a354d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleFlagGuardTest.java @@ -0,0 +1,161 @@ +package vip.mate.memory.lifecycle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.event.ConversationCompletedEvent; +import vip.mate.memory.spi.MemoryManager; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A.8 — Flag guard test: verifies that lifecycleMediatorEnabled=false + * means zero calls to prefetchAll / syncAll / onSessionEnd, and that + * enabling the flag activates all three. + * + *

Covers both AgentService helper paths (via Mediator) and + * MemoryLifecycleEventListener (via onConversationCompleted). + */ +@ExtendWith(MockitoExtension.class) +class LifecycleFlagGuardTest { + + @Mock private MemoryManager memoryManager; + @Mock private ApplicationEventPublisher eventPublisher; + + private MemoryProperties props; + private MemoryLifecycleMediator mediator; + private MemoryLifecycleEventListener listener; + + @BeforeEach + void setUp() { + props = new MemoryProperties(); + mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher); + listener = new MemoryLifecycleEventListener(mediator, props); + } + + // ==================== Flag OFF ==================== + + @Test + @DisplayName("Flag OFF: MemoryLifecycleEventListener.onConversationCompleted is a no-op") + void flagOff_listenerNoOp() { + props.setLifecycleMediatorEnabled(false); + + for (int i = 0; i < 10; i++) { + listener.onConversationCompleted( + new ConversationCompletedEvent(1L, "conv-" + i, "hello", "reply", 5, "web")); + } + + verify(memoryManager, never()).prefetchAll(any(), any()); + verify(memoryManager, never()).syncAll(any(), any(), any(), any()); + verify(memoryManager, never()).onSessionEnd(any(), any()); + } + + @Test + @DisplayName("Flag OFF: Mediator methods still work (called by AgentService helpers only when flag is on)") + void flagOff_mediatorDirectCallsStillWork() { + // Mediator itself has no flag check — that's AgentService's job. + // But MemoryLifecycleEventListener guards onSessionEnd. + props.setLifecycleMediatorEnabled(false); + + when(memoryManager.prefetchAll(eq(1L), eq("q"))).thenReturn(""); + + // Direct mediator call works (AgentService would not call this when flag is off) + mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); + verify(memoryManager, times(1)).prefetchAll(1L, "q"); + } + + // ==================== Flag ON ==================== + + @Test + @DisplayName("Flag ON: beforeLlmCall invokes prefetchAll") + void flagOn_prefetchAll() { + props.setLifecycleMediatorEnabled(true); + when(memoryManager.prefetchAll(eq(1L), eq("hello"))).thenReturn(""); + + for (int i = 0; i < 10; i++) { + mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", i, "hello")); + } + + verify(memoryManager, times(10)).prefetchAll(1L, "hello"); + } + + @Test + @DisplayName("Flag ON: afterLlmCall invokes syncAll") + void flagOn_syncAll() { + props.setLifecycleMediatorEnabled(true); + + for (int i = 0; i < 10; i++) { + mediator.afterLlmCall(new TurnContext(1L, "c1", "s1", i, "hello"), "reply-" + i); + } + + verify(memoryManager, times(10)).syncAll(eq(1L), eq("c1"), eq("hello"), anyString()); + } + + @Test + @DisplayName("Flag ON: onConversationCompleted invokes onSessionEnd") + void flagOn_onSessionEnd() { + props.setLifecycleMediatorEnabled(true); + + for (int i = 0; i < 10; i++) { + listener.onConversationCompleted( + new ConversationCompletedEvent(1L, "conv-" + i, "hello", "reply", 5, "web")); + } + + verify(memoryManager, times(10)).onSessionEnd(eq(1L), anyString()); + } + + @Test + @DisplayName("Flag ON: cron conversations also trigger onSessionEnd") + void flagOn_cronConversation() { + props.setLifecycleMediatorEnabled(true); + + listener.onConversationCompleted( + new ConversationCompletedEvent(1L, "cron-conv", "task", "done", 2, "cron")); + + verify(memoryManager, times(1)).onSessionEnd(1L, "cron-conv"); + } + + // ==================== Provider exception degradation ==================== + + @Test + @DisplayName("Provider exception in prefetchAll degrades gracefully (returns empty)") + void prefetchException_graceful() { + when(memoryManager.prefetchAll(any(), any())).thenThrow(new RuntimeException("boom")); + + String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); + + // Should return empty string, not throw + assert result.isEmpty(); + } + + @Test + @DisplayName("Provider exception in syncAll degrades gracefully (no throw)") + void syncException_graceful() { + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(memoryManager).syncAll(any(), any(), any(), any()); + + // Should not throw + mediator.afterLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"), "reply"); + } + + @Test + @DisplayName("Provider exception in onSessionEnd degrades gracefully (no throw)") + void sessionEndException_graceful() { + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(memoryManager).onSessionEnd(any(), any()); + + // Should not throw + mediator.onSessionEnd(1L, "c1"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java new file mode 100644 index 00000000..f6f4e2bd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/LifecycleRecallCountIT.java @@ -0,0 +1,141 @@ +package vip.mate.memory.lifecycle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.agent.AgentService; +import vip.mate.agent.BaseAgent; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.service.MemoryRecallTracker; +import vip.mate.memory.spi.MemoryManager; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A.10 — F4 regression test: recall_count / daily_count must remain + * identical whether lifecycleMediatorEnabled is on or off. + * + *

Verifies that MemoryLifecycleMediator never calls trackRecalls, + * and AgentService calls trackRecalls exactly once per chat entry + * regardless of the flag state. + */ +@ExtendWith(MockitoExtension.class) +class LifecycleRecallCountIT { + + @Mock private AgentMapper agentMapper; + @Mock private AgentGraphBuilder agentGraphBuilder; + @Mock private MemoryRecallTracker memoryRecallTracker; + @Mock private MemoryManager memoryManager; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private BaseAgent mockAgent; + + private MemoryProperties props; + private AgentService agentService; + + @BeforeEach + void setUp() { + props = new MemoryProperties(); + MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher); + agentService = new AgentService(agentMapper, agentGraphBuilder, + memoryRecallTracker, mediator, props); + + // Stub agent resolution (lenient for structural-only tests) + AgentEntity entity = new AgentEntity(); + entity.setId(1L); + entity.setEnabled(true); + lenient().when(agentMapper.selectById(1L)).thenReturn(entity); + lenient().when(agentGraphBuilder.build(any(AgentEntity.class))).thenReturn(mockAgent); + lenient().when(mockAgent.chat(any(), any())).thenReturn("reply"); + } + + @Test + @DisplayName("F4 regression: flag OFF — trackRecalls called once per chat, mediator is silent") + void flagOff_trackRecallsOncePerChat() { + props.setLifecycleMediatorEnabled(false); + + for (int i = 0; i < 10; i++) { + agentService.chat(1L, "msg-" + i, "conv-1"); + } + + // trackRecalls: exactly 10 times (once per chat call) + verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); + + // Mediator is not invoked when flag is off + verify(memoryManager, never()).prefetchAll(any(), any()); + verify(memoryManager, never()).syncAll(any(), any(), any(), any()); + } + + @Test + @DisplayName("F4 regression: flag ON — trackRecalls still called exactly once per chat (not doubled)") + void flagOn_trackRecallsStillOncePerChat() { + props.setLifecycleMediatorEnabled(true); + when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + + for (int i = 0; i < 10; i++) { + agentService.chat(1L, "msg-" + i, "conv-1"); + } + + // trackRecalls: still exactly 10 times — NOT 20 (D4: mediator does not call trackRecalls) + verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); + + // Mediator IS invoked + verify(memoryManager, times(10)).prefetchAll(eq(1L), any()); + verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any()); + } + + @Test + @DisplayName("F4 regression: flag toggle does not change trackRecalls count") + void flagToggle_sameTrackRecallsCount() { + // 5 rounds with flag OFF + props.setLifecycleMediatorEnabled(false); + for (int i = 0; i < 5; i++) { + agentService.chat(1L, "off-" + i, "conv-1"); + } + + // 5 rounds with flag ON + props.setLifecycleMediatorEnabled(true); + when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + for (int i = 0; i < 5; i++) { + agentService.chat(1L, "on-" + i, "conv-1"); + } + + // Total: 10 trackRecalls calls regardless of flag state + verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any()); + + // Mediator only called for the ON rounds + verify(memoryManager, times(5)).prefetchAll(eq(1L), any()); + } + + @Test + @DisplayName("Mediator source code does not reference trackRecalls (structural guard)") + void mediator_noTrackRecallsReference() throws Exception { + // Structural assertion: MemoryLifecycleMediator has no field or method + // that references MemoryRecallTracker + var mediatorClass = MemoryLifecycleMediator.class; + for (var field : mediatorClass.getDeclaredFields()) { + if (field.getType().getSimpleName().contains("RecallTracker")) { + throw new AssertionError("Mediator must not depend on MemoryRecallTracker (D4)"); + } + } + // Also verify via declared constructor params + var ctorParams = mediatorClass.getDeclaredConstructors()[0].getParameterTypes(); + for (var param : ctorParams) { + if (param.getSimpleName().contains("RecallTracker")) { + throw new AssertionError("Mediator constructor must not accept MemoryRecallTracker (D4)"); + } + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java new file mode 100644 index 00000000..db7a07c7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/lifecycle/MemoryLifecycleMediatorTest.java @@ -0,0 +1,155 @@ +package vip.mate.memory.lifecycle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.memory.spi.MemoryManager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A.9 — Unit tests for MemoryLifecycleMediator covering: + * normal path, provider exception degradation, and onSessionEnd for cron conversations. + */ +@ExtendWith(MockitoExtension.class) +class MemoryLifecycleMediatorTest { + + @Mock private MemoryManager memoryManager; + @Mock private ApplicationEventPublisher eventPublisher; + + private MemoryLifecycleMediator mediator; + + @BeforeEach + void setUp() { + mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher); + } + + // ==================== Normal path ==================== + + @Test + @DisplayName("beforeLlmCall returns prefetchAll result and publishes TurnStartedEvent") + void beforeLlmCall_normalPath() { + when(memoryManager.prefetchAll(eq(1L), eq("hello"))) + .thenReturn("some context"); + + TurnContext ctx = new TurnContext(1L, "c1", "s1", 1, "hello"); + String result = mediator.beforeLlmCall(ctx); + + assertEquals("some context", result); + verify(memoryManager).prefetchAll(1L, "hello"); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertTrue(eventCaptor.getValue() instanceof TurnStartedEvent); + assertEquals(ctx, ((TurnStartedEvent) eventCaptor.getValue()).context()); + } + + @Test + @DisplayName("beforeLlmCall returns empty string when prefetchAll returns empty") + void beforeLlmCall_emptyPrefetch() { + when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + + String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); + + assertEquals("", result); + } + + @Test + @DisplayName("afterLlmCall calls syncAll and publishes TurnCompletedEvent") + void afterLlmCall_normalPath() { + TurnContext ctx = new TurnContext(1L, "c1", "s1", 1, "hello"); + mediator.afterLlmCall(ctx, "reply text"); + + verify(memoryManager).syncAll(1L, "c1", "hello", "reply text"); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertTrue(eventCaptor.getValue() instanceof TurnCompletedEvent); + TurnCompletedEvent event = (TurnCompletedEvent) eventCaptor.getValue(); + assertEquals(ctx, event.context()); + assertEquals("reply text", event.assistantReply()); + } + + @Test + @DisplayName("onSessionEnd delegates to memoryManager.onSessionEnd") + void onSessionEnd_normalPath() { + mediator.onSessionEnd(1L, "conv-123"); + + verify(memoryManager).onSessionEnd(1L, "conv-123"); + } + + // ==================== Provider exception degradation ==================== + + @Test + @DisplayName("beforeLlmCall degrades to empty string when prefetchAll throws") + void beforeLlmCall_exceptionDegrades() { + when(memoryManager.prefetchAll(any(), any())) + .thenThrow(new RuntimeException("provider down")); + + String result = mediator.beforeLlmCall(new TurnContext(1L, "c1", "s1", 1, "q")); + + assertEquals("", result); + } + + @Test + @DisplayName("afterLlmCall swallows syncAll exceptions") + void afterLlmCall_exceptionSwallowed() { + doThrow(new RuntimeException("sync failed")) + .when(memoryManager).syncAll(any(), any(), any(), any()); + + // Should not throw + mediator.afterLlmCall(new TurnContext(1L, "c1", "s1", 1, "q"), "reply"); + } + + @Test + @DisplayName("onSessionEnd swallows exceptions") + void onSessionEnd_exceptionSwallowed() { + doThrow(new RuntimeException("session end failed")) + .when(memoryManager).onSessionEnd(any(), any()); + + // Should not throw + mediator.onSessionEnd(1L, "c1"); + } + + // ==================== Cron conversations ==================== + + @Test + @DisplayName("onSessionEnd works the same for cron-triggered conversations") + void onSessionEnd_cronConversation() { + // onSessionEnd has no special handling for trigger source; + // that distinction only matters in PostConversationMemoryListener. + // The mediator processes all conversations equally. + mediator.onSessionEnd(42L, "cron-conv-001"); + + verify(memoryManager, times(1)).onSessionEnd(42L, "cron-conv-001"); + } + + // ==================== Reentrant / multi-turn ==================== + + @Test + @DisplayName("Multiple sequential turns do not interfere (Mediator is stateless)") + void multipleTurns_noInterference() { + when(memoryManager.prefetchAll(any(), any())).thenReturn(""); + + for (int i = 0; i < 5; i++) { + TurnContext ctx = new TurnContext(1L, "c1", "s1", i, "msg-" + i); + mediator.beforeLlmCall(ctx); + mediator.afterLlmCall(ctx, "reply-" + i); + } + + verify(memoryManager, times(5)).prefetchAll(eq(1L), any()); + verify(memoryManager, times(5)).syncAll(eq(1L), eq("c1"), any(), any()); + } +}