feat(memory): Dream v2 Phase 1 — lifecycle mediator foundation

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.
This commit is contained in:
matevip 2026-04-20 17:31:21 +08:00
parent e99165f346
commit 74928d615d
13 changed files with 790 additions and 11 deletions

11
.github/pull_request_template.md vendored Normal file
View File

@ -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: <!-- rfc-035 P1-S{x} / rfc-037 P1-S{x} / ... -->

View File

@ -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<Long, BaseAgent> 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<String> 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<StreamDelta> 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<String> 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 <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
Supplier<Flux<T>> plainInvoke,
Function<T, String> 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) {

View File

@ -80,4 +80,63 @@ public class MemoryProperties {
/** 禁用的 MemoryProvider ID 集合(例如 "structured", "session_search" */
private Set<String> 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;
}
}

View File

@ -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.
*
* <p>Contract: every successfully-persisted conversation end must reach all memory
* providers, regardless of whether summarization / nudge preconditions held.
*
* <p>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());
}
}
}

View File

@ -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.
*
* <p>Non-goals:
* <ul>
* <li>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).</li>
* <li>Does NOT prefetch next-turn recall keyed on current-turn query
* query-conditioned providers cannot reuse stale queries (rfc-037 F2).</li>
* </ul>
*
* <p>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.
*
* <p>Latency contract: synchronous. BuiltinMemoryProvider returns "" so the
* only cost today is iteration overhead (&lt;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());
}
}
}

View File

@ -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) {}

View File

@ -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
) {}

View File

@ -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) {}

View File

@ -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.
*
* <p>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) {
}
}

View File

@ -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

View File

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

View File

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

View File

@ -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("<memory-context>some context</memory-context>");
TurnContext ctx = new TurnContext(1L, "c1", "s1", 1, "hello");
String result = mediator.beforeLlmCall(ctx);
assertEquals("<memory-context>some context</memory-context>", result);
verify(memoryManager).prefetchAll(1L, "hello");
ArgumentCaptor<Object> 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<Object> 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());
}
}